simple-module-cli 0.0.26__tar.gz → 0.0.28__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.
Files changed (121) hide show
  1. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/.gitignore +1 -0
  2. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/PKG-INFO +1 -1
  3. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/pyproject.toml +1 -1
  4. simple_module_cli-0.0.28/simple_module_cli/_module_build.py +92 -0
  5. simple_module_cli-0.0.28/simple_module_cli/_module_host.py +85 -0
  6. simple_module_cli-0.0.28/simple_module_cli/_templating.py +82 -0
  7. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/app_project.py +13 -1
  8. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/catalog.py +6 -0
  9. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/cli.py +12 -6
  10. simple_module_cli-0.0.28/simple_module_cli/docker_assets.py +100 -0
  11. simple_module_cli-0.0.28/simple_module_cli/module_cmd.py +82 -0
  12. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/new.py +2 -0
  13. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/recipes.py +11 -22
  14. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/scaffolding.py +43 -81
  15. simple_module_cli-0.0.28/simple_module_cli/templates/docker/Makefile.snippet +13 -0
  16. simple_module_cli-0.0.28/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl +34 -0
  17. simple_module_cli-0.0.28/simple_module_cli/templates/docker/compose-base-sqlite.yml.tpl +22 -0
  18. simple_module_cli-0.0.26/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml → simple_module_cli-0.0.28/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl +15 -43
  19. simple_module_cli-0.0.28/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl +70 -0
  20. simple_module_cli-0.0.28/simple_module_cli/templates/docker/dockerignore +16 -0
  21. simple_module_cli-0.0.28/simple_module_cli/templates/docker/host-flat.Dockerfile +68 -0
  22. simple_module_cli-0.0.28/simple_module_cli/templates/docker/host.Dockerfile +76 -0
  23. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/README.md.tpl +5 -0
  24. simple_module_cli-0.0.28/simple_module_cli/templates/host/client_app/styles.css +27 -0
  25. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/client_app/vite.config.ts +34 -0
  26. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/templates/index.html +6 -1
  27. simple_module_cli-0.0.28/simple_module_cli/templates/module/.github/workflows/ci.yml +65 -0
  28. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/.gitignore +5 -2
  29. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/README.md.tpl +18 -0
  30. simple_module_cli-0.0.28/simple_module_cli/templates/module/__PACKAGE__/endpoints/views.py.tpl +16 -0
  31. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +14 -0
  32. simple_module_cli-0.0.28/simple_module_cli/templates/module/__PACKAGE__/pages/Index.tsx.tpl +13 -0
  33. simple_module_cli-0.0.28/simple_module_cli/templates/module/_optional/standalone/package.json.tpl +27 -0
  34. {simple_module_cli-0.0.26/simple_module_cli/templates/module → simple_module_cli-0.0.28/simple_module_cli/templates/module/_optional/standalone}/tsconfig.json.tpl +1 -2
  35. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/pyproject.toml.tpl +11 -6
  36. simple_module_cli-0.0.28/simple_module_cli/templates/module/tsconfig.json.tpl +10 -0
  37. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/workspace/README.md.tpl +17 -0
  38. simple_module_cli-0.0.28/tests/test_cli_docker_assets.py +156 -0
  39. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_cli_new.py +17 -1
  40. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_cli_new_regressions.py +7 -5
  41. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_cli_recipes.py +10 -10
  42. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_cli_wizard.py +7 -1
  43. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_create_module_ci_skip.py +5 -5
  44. simple_module_cli-0.0.28/tests/test_module_cmd_build.py +71 -0
  45. simple_module_cli-0.0.28/tests/test_module_cmd_e2e.py +63 -0
  46. simple_module_cli-0.0.28/tests/test_module_cmd_verify.py +149 -0
  47. simple_module_cli-0.0.28/tests/test_module_css_assets.py +223 -0
  48. simple_module_cli-0.0.28/tests/test_module_css_build.py +72 -0
  49. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_module_pages_manifest.py +9 -2
  50. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_scaffolding_module.py +11 -7
  51. simple_module_cli-0.0.28/tests/test_scaffolding_module_js.py +105 -0
  52. simple_module_cli-0.0.26/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile +0 -44
  53. simple_module_cli-0.0.26/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +0 -37
  54. simple_module_cli-0.0.26/simple_module_cli/templates/host/client_app/styles.css +0 -12
  55. simple_module_cli-0.0.26/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -32
  56. simple_module_cli-0.0.26/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +0 -1
  57. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/LICENSE +0 -0
  58. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/README.md +0 -0
  59. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/__init__.py +0 -0
  60. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/_env.py +0 -0
  61. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/case.py +0 -0
  62. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/package_update.py +0 -0
  63. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/pins.py +0 -0
  64. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/plugins.py +0 -0
  65. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/README.md +0 -0
  66. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -0
  67. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
  68. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/simple-module-creating/SKILL.md +0 -0
  69. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  70. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/simple-module-doctor/SKILL.md +0 -0
  71. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +0 -0
  72. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  73. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -0
  74. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
  75. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
  76. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/skills_cmd.py +0 -0
  77. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/.env.example +0 -0
  78. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/.gitignore +0 -0
  79. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/Makefile +0 -0
  80. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  81. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
  82. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/alembic.ini +0 -0
  83. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
  84. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  85. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/client_app/package.json.tpl +0 -0
  86. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  87. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/client_app/pages/Landing.tsx +0 -0
  88. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  89. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  90. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/main.py +0 -0
  91. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/migrations/env.py +0 -0
  92. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  93. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  94. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
  95. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/host/routes.py +0 -0
  96. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  97. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  98. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  99. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  100. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  101. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  102. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/package.json.tpl +0 -0
  103. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
  104. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/workspace/.env.example +0 -0
  105. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/workspace/.gitignore +0 -0
  106. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/workspace/Makefile +0 -0
  107. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/workspace/package.json.tpl +0 -0
  108. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/templates/workspace/pyproject.toml.tpl +0 -0
  109. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/simple_module_cli/wizard.py +0 -0
  110. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_build_packaging.py +0 -0
  111. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_case.py +0 -0
  112. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_cli_catalog.py +0 -0
  113. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_cli_new_dest_tolerance.py +0 -0
  114. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_cli_new_scaffold_layout.py +0 -0
  115. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_cli_package_update.py +0 -0
  116. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_env_helper.py +0 -0
  117. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_no_framework_deps.py +0 -0
  118. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_plugin_discovery.py +0 -0
  119. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_scaffold_rollback.py +0 -0
  120. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_scaffolding_host.py +0 -0
  121. {simple_module_cli-0.0.26 → simple_module_cli-0.0.28}/tests/test_skills_cmd.py +0 -0
@@ -45,6 +45,7 @@ docs/.vitepress/dist/
45
45
  host/client_app/modules.manifest.json
46
46
  host/client_app/modules.generated.ts
47
47
  host/client_app/modules.generated.css
48
+ host/client_app/modules.assets.json
48
49
 
49
50
  # Worktrees
50
51
  .worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_cli
3
- Version: 0.0.26
3
+ Version: 0.0.28
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_cli"
3
- version = "0.0.26"
3
+ version = "0.0.28"
4
4
  description = "Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,92 @@
1
+ """``smpy module build`` — bundle ``<pkg>/assets_src/`` into ``<pkg>/static/dist/``.
2
+
3
+ Only for assets served via :meth:`ModuleBase.static_mounts` (vendor JS,
4
+ widgets, images). Inertia pages do NOT need this — the consuming host's Vite
5
+ build compiles ``pages/*.tsx`` straight from the wheel.
6
+
7
+ The module repo carries no bundler of its own: we borrow the verify host's
8
+ node toolchain (``.smpy/verify-host/client_app``) and point Vite at a
9
+ generated lib-mode config.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ import typer
18
+
19
+ from simple_module_cli._module_host import ModuleInfo, ensure_verify_host, require_binary
20
+ from simple_module_cli.case import to_pascal_case
21
+
22
+ _ENTRY_CANDIDATES = ("index.ts", "index.tsx", "index.js")
23
+
24
+
25
+ def run_build(info: ModuleInfo, *, fresh: bool = False, runner=subprocess.run) -> None:
26
+ assets_src = info.root / info.package_name / "assets_src"
27
+ entry = next((assets_src / n for n in _ENTRY_CANDIDATES if (assets_src / n).is_file()), None)
28
+ if entry is None:
29
+ typer.echo(
30
+ f"error: no {info.package_name}/assets_src/index.(ts|tsx|js) found. "
31
+ "`smpy module build` bundles static_mounts() assets only — Inertia pages "
32
+ "are built by the consuming host and need no bundling.",
33
+ err=True,
34
+ )
35
+ raise typer.Exit(code=1)
36
+
37
+ host = ensure_verify_host(info, fresh=fresh)
38
+ client_app = host / "client_app"
39
+ npm = require_binary("npm")
40
+ if not (client_app / "node_modules").is_dir():
41
+ typer.echo("[build] npm install (first run)")
42
+ if runner([npm, "install"], cwd=client_app).returncode != 0:
43
+ typer.echo("[build] FAILED at: npm install", err=True)
44
+ raise typer.Exit(code=1)
45
+
46
+ out_dir = info.root / info.package_name / "static" / "dist"
47
+ config_path = info.root / ".smpy" / "module-build.config.mjs"
48
+ config_path.parent.mkdir(parents=True, exist_ok=True)
49
+ config_path.write_text(
50
+ _vite_lib_config(entry, out_dir, global_name=to_pascal_case(info.package_name)),
51
+ encoding="utf-8",
52
+ )
53
+ typer.echo(f"[build] vite build -> {info.package_name}/static/dist")
54
+ npx = require_binary("npx")
55
+ if runner([npx, "vite", "build", "--config", str(config_path)], cwd=client_app).returncode != 0:
56
+ typer.echo("[build] FAILED at: vite build", err=True)
57
+ raise typer.Exit(code=1)
58
+
59
+ _warn_missing_force_include(info)
60
+ typer.echo("[build] OK")
61
+
62
+
63
+ def _vite_lib_config(entry: Path, out_dir: Path, *, global_name: str) -> str:
64
+ return f"""\
65
+ import {{ defineConfig }} from 'vite'
66
+
67
+ export default defineConfig({{
68
+ build: {{
69
+ lib: {{
70
+ entry: {entry.as_posix()!r},
71
+ formats: ['iife'],
72
+ name: {global_name!r},
73
+ fileName: () => 'index.js',
74
+ }},
75
+ outDir: {out_dir.as_posix()!r},
76
+ emptyOutDir: true,
77
+ }},
78
+ }})
79
+ """
80
+
81
+
82
+ def _warn_missing_force_include(info: ModuleInfo) -> None:
83
+ """static/dist is gitignored — without an artifacts entry the wheel silently omits it."""
84
+ pyproject = (info.root / "pyproject.toml").read_text(encoding="utf-8")
85
+ if f"{info.package_name}/static/dist" not in pyproject:
86
+ typer.echo(
87
+ "warning: pyproject.toml does not ship "
88
+ f'"{info.package_name}/static/dist" — the built bundle will NOT be in the wheel. '
89
+ "Add under [tool.hatch.build.targets.wheel]:\n"
90
+ f' artifacts = ["{info.package_name}/static/dist/"]',
91
+ err=True,
92
+ )
@@ -0,0 +1,85 @@
1
+ """Shared plumbing for ``smpy module`` commands.
2
+
3
+ Out-of-tree module repos have no frontend toolchain of their own — the
4
+ commands borrow one by scaffolding a throwaway host (via the same templates
5
+ as ``smpy create-host``) into ``.smpy/verify-host/`` and caching it there.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import tomllib
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import typer
16
+
17
+ from simple_module_cli.scaffolding import create_host, resolve_framework_version
18
+
19
+ VERIFY_HOST_RELPATH = Path(".smpy") / "verify-host"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ModuleInfo:
24
+ """Identity of the module under the cwd, read from its pyproject."""
25
+
26
+ root: Path
27
+ pypi_name: str # [project].name, e.g. simple_module_my_feature
28
+ package_name: str # the simple_module entry-point key, e.g. my_feature
29
+
30
+
31
+ def read_module_info(root: Path) -> ModuleInfo:
32
+ pyproject = root / "pyproject.toml"
33
+ if not pyproject.is_file():
34
+ typer.echo(f"error: no pyproject.toml in {root} — run from the module repo root.", err=True)
35
+ raise typer.Exit(code=1)
36
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
37
+ entry_points = data.get("project", {}).get("entry-points", {}).get("simple_module", {})
38
+ if not entry_points:
39
+ typer.echo(
40
+ "error: pyproject.toml declares no [project.entry-points.simple_module] — "
41
+ "this does not look like a SimpleModule module.",
42
+ err=True,
43
+ )
44
+ raise typer.Exit(code=1)
45
+ return ModuleInfo(
46
+ root=root,
47
+ pypi_name=data["project"]["name"],
48
+ package_name=next(iter(entry_points)),
49
+ )
50
+
51
+
52
+ def require_binary(name: str) -> str:
53
+ """Resolve ``name`` on PATH or exit with a clear message (npm on Windows is npm.cmd)."""
54
+ path = shutil.which(name)
55
+ if path is None:
56
+ typer.echo(f"error: '{name}' not found on PATH — install it and retry.", err=True)
57
+ raise typer.Exit(code=1)
58
+ return path
59
+
60
+
61
+ def ensure_verify_host(info: ModuleInfo, *, fresh: bool = False) -> Path:
62
+ """Scaffold (or reuse) the cached verify host; return its directory."""
63
+ host_dir = info.root / VERIFY_HOST_RELPATH
64
+ if fresh and host_dir.exists():
65
+ shutil.rmtree(host_dir)
66
+ if not (host_dir / "pyproject.toml").is_file():
67
+ create_host(
68
+ host_dir,
69
+ name="verify_host",
70
+ modules=[],
71
+ framework_version=resolve_framework_version(),
72
+ )
73
+ _wire_module_dep(host_dir / "pyproject.toml", info.pypi_name)
74
+ return host_dir
75
+
76
+
77
+ def _wire_module_dep(pyproject_path: Path, pypi_name: str) -> None:
78
+ """Add the module as an editable path dependency of the verify host."""
79
+ text = pyproject_path.read_text(encoding="utf-8")
80
+ # Unpinned on purpose: [tool.uv.sources] overrides it with the local path.
81
+ # Note: pin_framework_deps may have rewritten the array onto one line, so
82
+ # match the opening bracket only — TOML tolerates the injected newline.
83
+ text = text.replace("dependencies = [", f'dependencies = [\n "{pypi_name}",', 1)
84
+ text += f'\n[tool.uv.sources]\n{pypi_name} = {{ path = "../..", editable = true }}\n'
85
+ pyproject_path.write_text(text, encoding="utf-8")
@@ -0,0 +1,82 @@
1
+ """Package-data template engine shared by the scaffolders.
2
+
3
+ Extracted from :mod:`simple_module_cli.scaffolding` (which keeps the public
4
+ ``create_workspace`` / ``create_host`` / ``create_module`` entry points) so
5
+ each file has one job: this one renders template trees, that one decides what
6
+ to render.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.resources
12
+ import shutil
13
+ from collections.abc import Mapping
14
+ from pathlib import Path
15
+
16
+ _TEMPLATES_PACKAGE = "simple_module_cli.templates"
17
+ _PACKAGE_PATH_TOKEN = "__PACKAGE__"
18
+
19
+
20
+ def _iter_template_files(template_root: Path):
21
+ """Yield every file under ``template_root``. Skips ``_optional/`` paths."""
22
+ for path in template_root.rglob("*"):
23
+ if not path.is_file():
24
+ continue
25
+ if "_optional" in path.relative_to(template_root).parts:
26
+ continue
27
+ yield path
28
+
29
+
30
+ def _require_empty_dest(dest: Path, *, preserve_existing: frozenset[str] = frozenset()) -> None:
31
+ """Refuse a non-empty destination unless every top-level entry is allowed.
32
+
33
+ ``preserve_existing`` is matched against the *name* of each top-level entry,
34
+ so callers can permit common pre-existing files (``.git``, ``README.md``,
35
+ ...) without silently overwriting unrelated user content.
36
+ """
37
+ if dest.exists():
38
+ unexpected = sorted(p.name for p in dest.iterdir() if p.name not in preserve_existing)
39
+ if unexpected:
40
+ raise FileExistsError(
41
+ f"Destination {dest} exists and contains files that would collide "
42
+ f"with the scaffold: {', '.join(unexpected)}. "
43
+ "Move them aside or choose another path."
44
+ )
45
+ dest.mkdir(parents=True, exist_ok=True)
46
+
47
+
48
+ def _resolve_template_root(subdir: str, override: Path | None) -> Path:
49
+ if override is not None:
50
+ return Path(override)
51
+ return Path(str(importlib.resources.files(_TEMPLATES_PACKAGE) / subdir))
52
+
53
+
54
+ def _apply_template_files(
55
+ src_root: Path,
56
+ dest: Path,
57
+ substitutions: Mapping[str, str],
58
+ *,
59
+ path_rewrites: Mapping[str, str] | None = None,
60
+ preserve_existing: frozenset[str] = frozenset(),
61
+ ) -> list[Path]:
62
+ """Write template files into ``dest``; return paths skipped to preserve the user's copy."""
63
+ preserved: list[Path] = []
64
+ for src in _iter_template_files(src_root):
65
+ rel_str = str(src.relative_to(src_root))
66
+ for old, new in (path_rewrites or {}).items():
67
+ rel_str = rel_str.replace(old, new)
68
+ rel_str = rel_str.removesuffix(".tpl")
69
+ target = dest / rel_str
70
+ top = Path(rel_str).parts[0] if rel_str else ""
71
+ if top in preserve_existing and target.exists():
72
+ preserved.append(target)
73
+ continue
74
+ target.parent.mkdir(parents=True, exist_ok=True)
75
+ if src.suffix == ".tpl":
76
+ text = src.read_text(encoding="utf-8")
77
+ for placeholder, value in substitutions.items():
78
+ text = text.replace(placeholder, value)
79
+ target.write_text(text, encoding="utf-8")
80
+ else:
81
+ shutil.copy2(src, target)
82
+ return preserved
@@ -22,6 +22,7 @@ from typing import Any
22
22
  from simple_module_cli._env import set_env_key
23
23
  from simple_module_cli.case import to_kebab_case, to_pascal_case
24
24
  from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps
25
+ from simple_module_cli.docker_assets import scaffold_docker_assets
25
26
  from simple_module_cli.recipes import RECIPES, ScaffoldCtx
26
27
  from simple_module_cli.scaffolding import (
27
28
  SAFE_PRESERVED_NAMES,
@@ -141,6 +142,15 @@ def create_app_project(
141
142
  env_path = target / ".env.example"
142
143
  env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
143
144
  env_text = set_env_key(env_text, "SM_SECRET_KEY", _secrets.token_urlsafe(32))
145
+ if "users" in resolved:
146
+ # UsersSettings refuses its placeholder token secrets when
147
+ # SM_ENVIRONMENT=production — the mode the compose containers run
148
+ # in — so generate real ones, same as SM_SECRET_KEY above.
149
+ for key in (
150
+ "SM_USERS_RESET_PASSWORD_TOKEN_SECRET",
151
+ "SM_USERS_VERIFICATION_TOKEN_SECRET",
152
+ ):
153
+ env_text = set_env_key(env_text, key, _secrets.token_urlsafe(32))
144
154
  env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name), flat=flat))
145
155
  env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false")
146
156
  env_path.write_text(env_text, encoding="utf-8")
@@ -176,6 +186,8 @@ def create_app_project(
176
186
  if recipe_key is not None and recipe_key in RECIPES:
177
187
  RECIPES[recipe_key].apply(target, ctx)
178
188
 
189
+ scaffold_docker_assets(target, ctx, flat=flat)
190
+
179
191
  return host_dir, preserved
180
192
 
181
193
 
@@ -199,7 +211,7 @@ def _scaffold_sample_module(target: Path) -> None:
199
211
  sample_dest,
200
212
  name=_SAMPLE_MODULE_NAME,
201
213
  framework_version=_FRAMEWORK_VERSION,
202
- include_ci=False,
214
+ standalone=False,
203
215
  )
204
216
  _seed_static_dist_placeholder(sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist")
205
217
 
@@ -57,6 +57,12 @@ CATALOG: dict[str, ModuleEntry] = {
57
57
  requires=("users",),
58
58
  recipe="background_tasks",
59
59
  ),
60
+ "site_lock": ModuleEntry(
61
+ "site_lock",
62
+ "simple_module_site_lock",
63
+ "Site Lock",
64
+ requires=("auth", "settings"),
65
+ ),
60
66
  }
61
67
 
62
68
 
@@ -18,6 +18,7 @@ from typing import Annotated
18
18
  import typer
19
19
 
20
20
  from simple_module_cli.case import to_kebab_case
21
+ from simple_module_cli.module_cmd import module_app
21
22
  from simple_module_cli.new import new_project
22
23
  from simple_module_cli.package_update import package_update
23
24
  from simple_module_cli.plugins import discover_and_mount
@@ -35,6 +36,7 @@ app = typer.Typer(
35
36
  app.command("new")(new_project)
36
37
  app.command("package-update")(package_update)
37
38
  app.add_typer(skills_app, name="skills")
39
+ app.add_typer(module_app, name="module")
38
40
 
39
41
 
40
42
  @app.command("create-host")
@@ -104,10 +106,11 @@ def create_module(
104
106
  slug = to_kebab_case(name)
105
107
  package = slug.replace("-", "_")
106
108
  target = dest or Path.cwd() / f"simple_module_{package}"
107
- # An in-repo module (the documented modules/* layout) gets no .github/: those
108
- # nested workflows never run and publish.yml is a PyPI footgun. --standalone
109
- # forces them for a module that lives in its own repo. See GH #210.
110
- include_ci = standalone or not is_inside_existing_repo(target)
109
+ # An in-repo module (the documented modules/* layout) gets no .github/ and
110
+ # keeps the workspace JS configs: nested workflows never run and publish.yml
111
+ # is a PyPI footgun. --standalone forces the standalone layout for a module
112
+ # that lives in its own repo. See GH #210.
113
+ standalone_mode = standalone or not is_inside_existing_repo(target)
111
114
  try:
112
115
  # Pin framework deps to the installed framework version so the module
113
116
  # resolves against the app that created it (the template's >=1.0,<2.0
@@ -116,14 +119,14 @@ def create_module(
116
119
  target,
117
120
  name=name,
118
121
  framework_version=resolve_framework_version(),
119
- include_ci=include_ci,
122
+ standalone=standalone_mode,
120
123
  )
121
124
  except FileExistsError as exc:
122
125
  typer.echo(f"ERROR: {exc}", err=True)
123
126
  raise typer.Exit(code=1) from exc
124
127
 
125
128
  typer.echo(f"Created module 'simple_module_{package}' at {target}")
126
- if not include_ci:
129
+ if not standalone_mode:
127
130
  typer.echo(
128
131
  "Skipped .github/ workflows: this module is inside an existing repo, "
129
132
  "where nested workflows never run. Use --standalone to emit them."
@@ -132,6 +135,9 @@ def create_module(
132
135
  typer.echo(f" cd {target}")
133
136
  typer.echo(" uv sync --extra dev")
134
137
  typer.echo(" uv run pytest")
138
+ typer.echo(" npm install")
139
+ typer.echo(" npm run typecheck")
140
+ typer.echo(" uv run smpy module verify")
135
141
 
136
142
 
137
143
  discover_and_mount(app)
@@ -0,0 +1,100 @@
1
+ """Default Docker assets for ``smpy new`` scaffolds.
2
+
3
+ Every new app ships a container story out of the box: a multi-stage
4
+ ``docker/host.Dockerfile``, a ``docker-compose.yml`` matched to the
5
+ scaffold's DB choice (app on a SQLite volume, or postgres + app; plus
6
+ redis/worker/beat when ``background_tasks`` is selected), a
7
+ ``.dockerignore``, and ``docker-*`` Make targets. This runs for every
8
+ scaffold — unlike per-module recipes — because the compose service set
9
+ depends on the *whole* module selection, which only the scaffolder knows.
10
+
11
+ worker/beat reuse the app image with a different command: the workspace
12
+ venv already contains everything (``uv sync --all-packages``), so a
13
+ separate worker Dockerfile would only duplicate build logic.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib.resources
19
+ import shutil
20
+ from pathlib import Path
21
+
22
+ from simple_module_cli.case import to_kebab_case
23
+ from simple_module_cli.recipes import ScaffoldCtx
24
+
25
+ __all__ = ["scaffold_docker_assets"]
26
+
27
+ _MAKEFILE_MARKER = "# --- docker --"
28
+ _PG_DB_TOKEN = "{{PG_DB}}"
29
+ _APP_EXTRA_ENV_TOKEN = "{{APP_EXTRA_ENV}}"
30
+
31
+ # BackgroundTasksSettings refuses a localhost broker when
32
+ # SM_ENVIRONMENT=production, so the app container needs the compose-network
33
+ # broker URLs too — not just worker/beat.
34
+ _APP_BROKER_ENV = (
35
+ " SM_BG_TASKS_BROKER_URL: redis://redis:6379/0\n"
36
+ " SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1\n"
37
+ )
38
+
39
+
40
+ def _template_root() -> Path:
41
+ base = importlib.resources.files("simple_module_cli")
42
+ return Path(str(base / "templates" / "docker"))
43
+
44
+
45
+ def scaffold_docker_assets(target: Path, ctx: ScaffoldCtx, *, flat: bool = False) -> None:
46
+ """Emit compose + Dockerfile + .dockerignore + Make targets at ``target``.
47
+
48
+ The compose file is assembled from fragments: a per-DB base (app alone
49
+ on SQLite, or postgres + app), per-DB worker services when
50
+ ``background_tasks`` is selected, and a computed ``volumes:`` block —
51
+ appending fragments keeps the templates plain YAML instead of merge
52
+ logic.
53
+ """
54
+ templates = _template_root()
55
+ compose_dest = target / "docker-compose.yml"
56
+ dockerfile_dest = target / "docker" / "host.Dockerfile"
57
+ for path in (compose_dest, dockerfile_dest):
58
+ if path.exists():
59
+ raise FileExistsError(
60
+ f"{path} already exists — refusing to clobber. "
61
+ "Remove the file or run `smpy new` against an empty directory."
62
+ )
63
+
64
+ dockerfile_dest.parent.mkdir(parents=True, exist_ok=True)
65
+ src_name = "host-flat.Dockerfile" if flat else "host.Dockerfile"
66
+ shutil.copy2(templates / src_name, dockerfile_dest)
67
+
68
+ # The compose stack must match the scaffold's DB choice: a migration
69
+ # history is dialect-frozen at autogenerate time (e.g. sa.false()
70
+ # compiles to DEFAULT 0 on SQLite, which Postgres rejects), so pointing
71
+ # a sqlite-scaffolded app at a Postgres container fails on first boot.
72
+ db = "postgres" if ctx.db == "postgres" else "sqlite"
73
+ pg_db = to_kebab_case(ctx.name)
74
+ with_tasks = "background_tasks" in ctx.selected
75
+ compose = (
76
+ _read(templates / f"compose-base-{db}.yml.tpl")
77
+ .replace(_PG_DB_TOKEN, pg_db)
78
+ .replace(_APP_EXTRA_ENV_TOKEN, _APP_BROKER_ENV if with_tasks else "")
79
+ )
80
+ volumes = ["pgdata"] if db == "postgres" else ["appdata"]
81
+ if with_tasks:
82
+ compose += _read(templates / f"compose-tasks-{db}.yml.tpl").replace(_PG_DB_TOKEN, pg_db)
83
+ volumes.append("redisdata")
84
+ compose += "\nvolumes:\n" + "".join(f" {name}:\n" for name in volumes)
85
+ compose_dest.write_text(compose, encoding="utf-8")
86
+
87
+ ignore_dest = target / ".dockerignore"
88
+ if not ignore_dest.exists():
89
+ shutil.copy2(templates / "dockerignore", ignore_dest)
90
+
91
+ makefile_path = target / "Makefile"
92
+ existing = makefile_path.read_text(encoding="utf-8") if makefile_path.exists() else ""
93
+ if _MAKEFILE_MARKER not in existing:
94
+ snippet = _read(templates / "Makefile.snippet")
95
+ sep = "" if existing.endswith("\n") or not existing else "\n"
96
+ makefile_path.write_text(existing + sep + snippet, encoding="utf-8")
97
+
98
+
99
+ def _read(path: Path) -> str:
100
+ return path.read_text(encoding="utf-8")
@@ -0,0 +1,82 @@
1
+ """``smpy module`` — commands for developing a module out-of-tree (own repo)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from simple_module_cli._module_host import (
11
+ ModuleInfo,
12
+ ensure_verify_host,
13
+ read_module_info,
14
+ require_binary,
15
+ )
16
+
17
+ module_app = typer.Typer(help="Develop a SimpleModule module outside a host repo.")
18
+
19
+
20
+ def run_verify(info: ModuleInfo, *, fresh: bool = False, runner=subprocess.run) -> None:
21
+ """Prove the module's TSX + CSS compile against a real scaffolded host.
22
+
23
+ Output streams straight to the terminal (CI logs want the full Vite/tsc
24
+ output); on failure we name the step and exit 1.
25
+ """
26
+ host = ensure_verify_host(info, fresh=fresh)
27
+ client_app = host / "client_app"
28
+ uv, npm = require_binary("uv"), require_binary("npm")
29
+ steps: tuple[tuple[str, list[str], Path], ...] = (
30
+ # The module's own node_modules first: esbuild resolves the module
31
+ # tsconfig's `extends` (@simple-module-py/tsconfig) from there when
32
+ # compiling pages out of the editable checkout.
33
+ *(
34
+ (("npm install (module)", [npm, "install"], info.root),)
35
+ if (info.root / "package.json").is_file()
36
+ else ()
37
+ ),
38
+ ("uv sync", [uv, "sync"], host),
39
+ ("npm install", [npm, "install"], client_app),
40
+ (
41
+ "gen-pages",
42
+ [
43
+ uv,
44
+ "run",
45
+ "python",
46
+ "-m",
47
+ "simple_module_hosting",
48
+ "gen-pages",
49
+ "--host-dir=client_app",
50
+ ],
51
+ host,
52
+ ),
53
+ ("frontend build (tsc + vite)", [npm, "run", "build"], client_app),
54
+ )
55
+ for label, cmd, cwd in steps:
56
+ typer.echo(f"[verify] {label}")
57
+ if runner(cmd, cwd=cwd).returncode != 0:
58
+ typer.echo(f"[verify] FAILED at: {label}", err=True)
59
+ raise typer.Exit(code=1)
60
+ typer.echo("[verify] OK — module frontend builds against a scaffolded host")
61
+
62
+
63
+ @module_app.command("verify")
64
+ def verify_command(
65
+ fresh: bool = typer.Option(
66
+ False, "--fresh", help="Rebuild the cached .smpy/verify-host from scratch."
67
+ ),
68
+ ) -> None:
69
+ """Build this module's frontend inside a throwaway scaffolded host."""
70
+ run_verify(read_module_info(Path.cwd()), fresh=fresh)
71
+
72
+
73
+ @module_app.command("build")
74
+ def build_command(
75
+ fresh: bool = typer.Option(
76
+ False, "--fresh", help="Rebuild the cached .smpy/verify-host from scratch."
77
+ ),
78
+ ) -> None:
79
+ """Bundle <pkg>/assets_src/ into <pkg>/static/dist/ for static_mounts()."""
80
+ from simple_module_cli._module_build import run_build
81
+
82
+ run_build(read_module_info(Path.cwd()), fresh=fresh)
@@ -141,6 +141,7 @@ def new_project(
141
141
  typer.echo(' make migration msg="initial schema"')
142
142
  typer.echo(" make migrate")
143
143
  typer.echo(" make dev")
144
+ typer.echo(" make docker-up # or run the full stack in containers")
144
145
  if "background_tasks" in resolved:
145
146
  typer.echo(" docker compose up -d redis worker beat # background jobs")
146
147
  return
@@ -168,6 +169,7 @@ def new_project(
168
169
  # errors once a second module ships its own migration branch label.
169
170
  subprocess.run([*_ALEMBIC, "upgrade", "heads"], cwd=host_dir, check=False)
170
171
  typer.echo("\nSetup complete. Run `make dev` in the new directory.")
172
+ typer.echo("To run the full stack in containers instead: make docker-up")
171
173
  if "background_tasks" in resolved:
172
174
  typer.echo("For background jobs, also run: docker compose up -d redis worker beat")
173
175
 
@@ -49,37 +49,26 @@ def _optional_template_root(name: str) -> Path:
49
49
 
50
50
 
51
51
  class BackgroundTasksRecipe:
52
- """Lays down run_worker.py + compose + Dockerfiles + Make targets."""
52
+ """Lays down run_worker.py + broker env keys + Make targets.
53
+
54
+ The compose services (redis/worker/beat) and the shared app image come
55
+ from the default Docker assets every scaffold gets — see
56
+ :mod:`simple_module_cli.docker_assets`.
57
+ """
53
58
 
54
59
  def apply(self, target: Path, ctx: ScaffoldCtx) -> None:
55
60
  templates = _optional_template_root("background_tasks")
56
61
 
57
62
  run_worker_dest = target / "scripts" / "run_worker.py"
58
- compose_dest = target / "docker-compose.yml"
59
- host_dockerfile_dest = target / "docker" / "host.Dockerfile"
60
- worker_dockerfile_dest = target / "docker" / "worker.Dockerfile"
61
-
62
- for path in (
63
- run_worker_dest,
64
- compose_dest,
65
- host_dockerfile_dest,
66
- worker_dockerfile_dest,
67
- ):
68
- if path.exists():
69
- raise FileExistsError(
70
- f"{path} already exists — refusing to clobber. "
71
- "Remove the file or run `smpy new` against an empty directory."
72
- )
63
+ if run_worker_dest.exists():
64
+ raise FileExistsError(
65
+ f"{run_worker_dest} already exists — refusing to clobber. "
66
+ "Remove the file or run `smpy new` against an empty directory."
67
+ )
73
68
 
74
69
  run_worker_dest.parent.mkdir(parents=True, exist_ok=True)
75
70
  shutil.copy2(templates / "run_worker.py", run_worker_dest)
76
71
 
77
- shutil.copy2(templates / "docker-compose.yml", compose_dest)
78
-
79
- host_dockerfile_dest.parent.mkdir(parents=True, exist_ok=True)
80
- shutil.copy2(templates / "host.Dockerfile", host_dockerfile_dest)
81
- shutil.copy2(templates / "worker.Dockerfile", worker_dockerfile_dest)
82
-
83
72
  env_path = target / ".env.example"
84
73
  env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
85
74
  env_path.write_text(