simple-module-cli 0.0.13__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.
Files changed (93) hide show
  1. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/PKG-INFO +1 -1
  2. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/pyproject.toml +1 -1
  3. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/case.py +5 -0
  4. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/scaffolding.py +21 -11
  5. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/vite.config.ts +17 -7
  6. simple_module_cli-0.0.14/simple_module_cli/templates/host/main.py +46 -0
  7. simple_module_cli-0.0.14/tests/test_case.py +63 -0
  8. simple_module_cli-0.0.14/tests/test_env_helper.py +54 -0
  9. simple_module_cli-0.0.14/tests/test_scaffold_rollback.py +73 -0
  10. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_scaffolding_host.py +96 -0
  11. simple_module_cli-0.0.13/simple_module_cli/templates/host/main.py +0 -30
  12. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/.gitignore +0 -0
  13. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/LICENSE +0 -0
  14. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/README.md +0 -0
  15. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/__init__.py +0 -0
  16. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/_env.py +0 -0
  17. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/app_project.py +0 -0
  18. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/catalog.py +0 -0
  19. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/cli.py +0 -0
  20. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/new.py +0 -0
  21. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/package_update.py +0 -0
  22. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/plugins.py +0 -0
  23. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/recipes.py +0 -0
  24. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/README.md +0 -0
  25. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -0
  26. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
  27. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-creating/SKILL.md +0 -0
  28. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  29. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-doctor/SKILL.md +0 -0
  30. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +0 -0
  31. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  32. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -0
  33. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
  34. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
  35. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/skills_cmd.py +0 -0
  36. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/.env.example +0 -0
  37. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/.gitignore +0 -0
  38. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/Makefile +0 -0
  39. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/README.md.tpl +0 -0
  40. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  41. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +0 -0
  42. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile +0 -0
  43. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
  44. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +0 -0
  45. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/alembic.ini +0 -0
  46. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
  47. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  48. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/package.json.tpl +0 -0
  49. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  50. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/pages/Landing.tsx +0 -0
  51. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  52. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/styles.css +0 -0
  53. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  54. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/migrations/env.py +0 -0
  55. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  56. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  57. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
  58. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/routes.py +0 -0
  59. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/host/templates/index.html +0 -0
  60. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  61. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  62. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/.gitignore +0 -0
  63. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/README.md.tpl +0 -0
  64. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  65. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  66. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  67. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -0
  68. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +0 -0
  69. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  70. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  71. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/package.json.tpl +0 -0
  72. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  73. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
  74. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  75. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/.env.example +0 -0
  76. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/.gitignore +0 -0
  77. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/Makefile +0 -0
  78. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/README.md.tpl +0 -0
  79. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/package.json.tpl +0 -0
  80. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/templates/workspace/pyproject.toml.tpl +0 -0
  81. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/simple_module_cli/wizard.py +0 -0
  82. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_build_packaging.py +0 -0
  83. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_cli_catalog.py +0 -0
  84. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_cli_new.py +0 -0
  85. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_cli_new_dest_tolerance.py +0 -0
  86. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_cli_new_regressions.py +0 -0
  87. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_cli_package_update.py +0 -0
  88. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_cli_recipes.py +0 -0
  89. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_cli_wizard.py +0 -0
  90. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_no_framework_deps.py +0 -0
  91. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_plugin_discovery.py +0 -0
  92. {simple_module_cli-0.0.13 → simple_module_cli-0.0.14}/tests/test_scaffolding_module.py +0 -0
  93. {simple_module_cli-0.0.13 → 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.13
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_cli"
3
- version = "0.0.13"
3
+ version = "0.0.14"
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"
@@ -41,6 +41,11 @@ def to_snake_case(name: str) -> str:
41
41
  s = re.sub(r"[\s\-]+", "_", name)
42
42
  s = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", s)
43
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)
44
49
  return s.lower()
45
50
 
46
51
 
@@ -193,20 +193,30 @@ def create_module(
193
193
  template_root: Path | None = None,
194
194
  ) -> Path:
195
195
  dest = Path(dest)
196
+ existed_before = dest.exists()
196
197
  _require_empty_dest(dest)
197
198
  display_name = to_pascal_case(name)
198
199
  slug = to_kebab_case(name)
199
200
  package_name = to_snake_case(name)
200
- _apply_template_files(
201
- _resolve_template_root("module", template_root),
202
- dest,
203
- substitutions={
204
- "{{MODULE_NAME}}": display_name,
205
- "{{MODULE_SLUG}}": slug,
206
- "{{PACKAGE_NAME}}": package_name,
207
- "{{PACKAGE_NAME_UPPER}}": package_name.upper(),
208
- },
209
- path_rewrites={_PACKAGE_PATH_TOKEN: package_name},
210
- )
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
211
221
  logger.info("Scaffolded module '%s' at %s (package: %s)", display_name, dest, package_name)
212
222
  return dest
@@ -68,7 +68,6 @@ if (fs.existsSync(manifestPath)) {
68
68
  }
69
69
  }
70
70
  }
71
- const fsRootPrefix = fsRoot + path.sep;
72
71
  const fakeWorkspaceImporter = path.join(fsRoot, 'package.json');
73
72
 
74
73
  // CJS-only deps like `clsx`, `tailwind-merge`, `class-variance-authority`
@@ -158,11 +157,13 @@ function collectOptimizeIncludes(): string[] {
158
157
 
159
158
  // Cross-package bare imports from module pages (`maplibre-gl`, `pmtiles`,
160
159
  // `@inertiajs/react`, …) live in fsRoot/node_modules after `npm install`.
161
- // But when a module's pages sit outside fsRoot under
162
- // `.venv/.../site-packages/<pkg>/pages/` for wheel installs — Vite's
163
- // resolver walks up from the importer looking for node_modules and never
164
- // reaches fsRoot/node_modules. Resolution fails with: "Failed to resolve
165
- // import Does the file exist?".
160
+ // But Vite's resolver walks up from the importer looking for node_modules
161
+ // and doesn't always reach fsRoot/node_modules — true for wheel installs
162
+ // under `.venv/.../site-packages/<pkg>/pages/` (outside fsRoot) AND for
163
+ // workspace-member modules at `modules/<name>/<pkg>/pages/` whose upward
164
+ // walk hits intermediate dirs without node_modules before reaching the
165
+ // hoisted workspace root. Resolution fails with: "Failed to resolve import
166
+ // … Does the file exist?".
166
167
  //
167
168
  // This plugin recovers by retrying any unresolved bare import from a
168
169
  // module-pages importer as if the importer lived at fsRoot, which puts
@@ -184,7 +185,6 @@ function moduleBareImportResolver(): Plugin {
184
185
  return null;
185
186
  }
186
187
  const importerPath = importer.split('?')[0];
187
- if (importerPath.startsWith(fsRootPrefix)) return null;
188
188
  if (!modulePagesPrefixes.some((prefix) => importerPath.startsWith(prefix))) {
189
189
  return null;
190
190
  }
@@ -205,6 +205,16 @@ export default defineConfig({
205
205
  optimizeDeps: {
206
206
  entries: ['main.tsx', 'pages/**/*.tsx', ...moduleOptimizeEntries],
207
207
  include: collectOptimizeIncludes(),
208
+ // Vite's dep scanner runs esbuild against every entry above. When the
209
+ // entry sits outside the workspace (a wheel-installed module's
210
+ // pages/, or sometimes even a workspace module's pages/), esbuild's
211
+ // upward node_modules walk from the importer does not always reach
212
+ // the hoisted node_modules at fsRoot. Seeding it as NODE_PATH-style
213
+ // fallback ensures bare specifiers like `maplibre-gl` resolve during
214
+ // scan-imports. See GitHub issue #152.
215
+ esbuildOptions: {
216
+ nodePaths: [path.join(fsRoot, 'node_modules')],
217
+ },
208
218
  },
209
219
  build: {
210
220
  outDir: '../static/dist',
@@ -0,0 +1,46 @@
1
+ """Host application entry point.
2
+
3
+ This file was generated by `smpy create-host`. Modules are discovered at boot
4
+ via entry_points; add them to this host's pyproject.toml to install them.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from simple_module_core.dotenv import load_dotenv_into_environ
11
+
12
+ # Resolve the workspace root from this file's location so the web process
13
+ # behaves the same regardless of where uvicorn was launched (the scaffolded
14
+ # Makefile uses ``cd host && uvicorn main:app``, but ``uv run --project host``
15
+ # or a wheel deployment may run from elsewhere). chdir up front so cwd-relative
16
+ # paths in ``.env`` (e.g. ``sqlite+aiosqlite:///./host/app.db``) resolve
17
+ # consistently; load ``.env`` into ``os.environ`` so framework code reading
18
+ # ``os.environ.get("SM_…")`` directly sees the same values pydantic does.
19
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
20
+ os.chdir(_REPO_ROOT)
21
+ load_dotenv_into_environ(_REPO_ROOT / ".env")
22
+
23
+ from simple_module_hosting import Settings, create_app
24
+ from simple_module_hosting.logging import setup_logging
25
+
26
+ from routes import router as host_router
27
+
28
+ settings = Settings()
29
+
30
+ setup_logging(
31
+ level=settings.log_level,
32
+ json_format=settings.log_format == "json",
33
+ )
34
+
35
+ app = create_app(settings)
36
+ app.include_router(host_router)
37
+
38
+ if __name__ == "__main__":
39
+ import uvicorn
40
+
41
+ uvicorn.run(
42
+ "main:app",
43
+ host="0.0.0.0",
44
+ port=8000,
45
+ reload=settings.is_development,
46
+ )
@@ -0,0 +1,63 @@
1
+ """Direct unit tests for the identifier case helpers.
2
+
3
+ Every scaffolder pipes a user-supplied module name through these — a typo
4
+ that emits ``u_r_l_path`` from ``URLPath`` would propagate into the PyPI
5
+ slug *and* the display name. Existing tests touch ``to_pascal_case`` only
6
+ indirectly via ``test_helpers.py``; we pin the snake/kebab forms too,
7
+ including the acronym edge cases the docstring promises.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+ from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case
14
+
15
+
16
+ class TestToSnakeCase:
17
+ @pytest.mark.parametrize(
18
+ ("raw", "expected"),
19
+ [
20
+ ("MyFeature", "my_feature"),
21
+ ("my-feature", "my_feature"),
22
+ ("my_feature", "my_feature"),
23
+ ("My Feature", "my_feature"),
24
+ ("MY_FEATURE", "my_feature"),
25
+ ("URLPath", "url_path"),
26
+ ("APIClient", "api_client"),
27
+ ("HTTPServer2", "http_server2"),
28
+ ("simple", "simple"),
29
+ ("simple-thing-name", "simple_thing_name"),
30
+ ("Already_Snake_Mixed", "already_snake_mixed"),
31
+ ("trailing-", "trailing_"),
32
+ ],
33
+ )
34
+ def test_canonicalises(self, raw, expected):
35
+ assert to_snake_case(raw) == expected
36
+
37
+
38
+ class TestToKebabCase:
39
+ @pytest.mark.parametrize(
40
+ ("raw", "expected"),
41
+ [
42
+ ("MyFeature", "my-feature"),
43
+ ("my_feature", "my-feature"),
44
+ ("URLPath", "url-path"),
45
+ ],
46
+ )
47
+ def test_canonicalises(self, raw, expected):
48
+ assert to_kebab_case(raw) == expected
49
+
50
+
51
+ class TestToPascalCase:
52
+ @pytest.mark.parametrize(
53
+ ("raw", "expected"),
54
+ [
55
+ ("my-feature", "MyFeature"),
56
+ ("my_feature", "MyFeature"),
57
+ ("MyFeature", "MyFeature"),
58
+ ("URLPath", "UrlPath"), # consequence of the snake-cased pipeline
59
+ ("__name__", "Name"), # empty parts dropped
60
+ ],
61
+ )
62
+ def test_canonicalises(self, raw, expected):
63
+ assert to_pascal_case(raw) == expected
@@ -0,0 +1,54 @@
1
+ """``set_env_key`` is the single helper that edits scaffold-time .env files.
2
+
3
+ A regression here writes a duplicate ``KEY=`` line or, worse, leaves the old
4
+ value unstripped — both manifest as "my recipe didn't take effect" which is
5
+ hard to debug downstream.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from simple_module_cli._env import set_env_key
11
+
12
+
13
+ def test_appends_to_empty_body():
14
+ assert set_env_key("", "FOO", "bar") == "FOO=bar\n"
15
+
16
+
17
+ def test_replaces_existing_key():
18
+ body = "FOO=old\nBAR=keep\n"
19
+ out = set_env_key(body, "FOO", "new")
20
+ # Replaced line lives at the bottom (append-after-strip strategy).
21
+ assert "FOO=old" not in out
22
+ assert "FOO=new\n" in out
23
+ assert "BAR=keep" in out
24
+
25
+
26
+ def test_unrelated_lines_preserved_in_order():
27
+ body = "A=1\nB=2\nC=3\n"
28
+ out = set_env_key(body, "Z", "9")
29
+ lines = out.splitlines()
30
+ assert lines[0] == "A=1"
31
+ assert lines[1] == "B=2"
32
+ assert lines[2] == "C=3"
33
+ assert lines[-1] == "Z=9"
34
+
35
+
36
+ def test_idempotent_when_key_already_at_value():
37
+ body = "FOO=bar\n"
38
+ once = set_env_key(body, "FOO", "bar")
39
+ twice = set_env_key(once, "FOO", "bar")
40
+ assert once == twice == "FOO=bar\n"
41
+
42
+
43
+ def test_prefix_match_is_exact():
44
+ """``KEY=`` must not match ``KEY_LONGER=``."""
45
+ body = "FOO_BAR=keep_me\n"
46
+ out = set_env_key(body, "FOO", "new")
47
+ assert "FOO_BAR=keep_me" in out
48
+ assert "FOO=new" in out
49
+
50
+
51
+ def test_output_always_ends_with_newline():
52
+ body = "X=1" # no trailing newline
53
+ out = set_env_key(body, "Y", "2")
54
+ assert out.endswith("\n")
@@ -0,0 +1,73 @@
1
+ """Scaffold rollback on partial failure.
2
+
3
+ Before the rollback added to ``create_module``, a mid-pipeline error left
4
+ the user with a non-empty destination directory — the next ``smpy new``
5
+ invocation against the same path would then refuse to overwrite, but the
6
+ files already written wouldn't form a valid Python package either.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+ from simple_module_cli import scaffolding
15
+
16
+
17
+ def test_create_module_rolls_back_on_template_failure(tmp_path, monkeypatch):
18
+ """An exception during ``_apply_template_files`` must clear ``dest``."""
19
+ dest = tmp_path / "broken_module"
20
+
21
+ def boom(*_args, **_kwargs):
22
+ # Simulate a mid-write error after the dest directory exists but
23
+ # before all files have been laid down.
24
+ dest.mkdir(exist_ok=True)
25
+ (dest / "half_written.py").write_text("# truncated", encoding="utf-8")
26
+ raise RuntimeError("simulated template engine failure")
27
+
28
+ monkeypatch.setattr(scaffolding, "_apply_template_files", boom)
29
+
30
+ with pytest.raises(RuntimeError, match="simulated template engine failure"):
31
+ scaffolding.create_module(dest, "my_thing")
32
+
33
+ assert not dest.exists(), (
34
+ "Partial scaffold left on disk — rollback didn't fire. Subsequent "
35
+ "smpy new attempts at this path would refuse to overwrite."
36
+ )
37
+
38
+
39
+ def test_rollback_does_not_delete_pre_existing_directory(tmp_path, monkeypatch):
40
+ """A pre-existing (empty) destination must stay on disk on rollback.
41
+
42
+ We can't tell from inside ``create_module`` whether ``dest`` was made
43
+ by us or by the caller, but the directory's *prior existence* is a
44
+ reliable signal: if the caller mkdir'd it, leaving their dir alone is
45
+ the conservative choice. (The half-written scaffold contents inside
46
+ are an unavoidable consequence — preventing those needs a transactional
47
+ file system, which we don't have.)
48
+ """
49
+ dest = tmp_path / "owned_by_caller"
50
+ dest.mkdir()
51
+
52
+ def boom(*_args, **_kwargs):
53
+ raise RuntimeError("simulated failure before any files written")
54
+
55
+ monkeypatch.setattr(scaffolding, "_apply_template_files", boom)
56
+
57
+ with pytest.raises(RuntimeError):
58
+ scaffolding.create_module(dest, "thing")
59
+
60
+ # The dir survives — we didn't make it.
61
+ assert dest.exists()
62
+
63
+
64
+ def test_successful_scaffold_keeps_dest():
65
+ """Sanity check: the rollback path only fires on failure."""
66
+ import tempfile
67
+
68
+ with tempfile.TemporaryDirectory() as tmp:
69
+ dest = Path(tmp) / "real_module"
70
+ scaffolding.create_module(dest, "real_module")
71
+ assert dest.exists()
72
+ # The template materialises at least the package directory.
73
+ assert any(dest.iterdir())
@@ -155,6 +155,28 @@ class TestCreateHost:
155
155
  assert "make_include_object" in env_py
156
156
  assert "for mod in modules:" not in env_py
157
157
 
158
+ async def test_main_py_loads_dotenv_before_settings(self, tmp_path):
159
+ """Regression for #158: scaffolded main.py must populate ``os.environ``
160
+ from ``.env`` *before* ``Settings`` is imported, otherwise framework
161
+ code reading ``os.environ`` directly (e.g. users.bootstrap's dotenv
162
+ fallback under uvicorn launched from ``host/``) silently misses values
163
+ that pydantic-settings would have picked up.
164
+ """
165
+ from simple_module_cli.scaffolding import create_host
166
+
167
+ dest = tmp_path / "demo"
168
+ create_host(dest, name="demo", modules=[])
169
+ main_py = (dest / "main.py").read_text(encoding="utf-8")
170
+
171
+ assert "load_dotenv_into_environ" in main_py
172
+ assert "os.chdir" in main_py
173
+ # The load_dotenv call must precede the first ``Settings`` import so
174
+ # ``BootstrapSettings``' ``env_file=".env"`` lookup and any direct
175
+ # ``os.environ.get(...)`` reads see the same view of the environment.
176
+ dotenv_idx = main_py.index("load_dotenv_into_environ(")
177
+ settings_import_idx = main_py.index("from simple_module_hosting import")
178
+ assert dotenv_idx < settings_import_idx
179
+
158
180
  async def test_cli_create_host_runs_end_to_end(self, tmp_path):
159
181
  """The Click `smpy create-host` command produces a working scaffold."""
160
182
  from simple_module_cli.cli import app
@@ -171,3 +193,77 @@ class TestCreateHost:
171
193
  assert "simple_module_dashboard" in (tmp_path / "out" / "pyproject.toml").read_text(
172
194
  encoding="utf-8"
173
195
  )
196
+
197
+ async def test_scaffold_vite_config_includes_node_paths_fallback(self, tmp_path):
198
+ """vite.config.ts must seed optimizeDeps.esbuildOptions.nodePaths
199
+ with the workspace node_modules so esbuild's scan-imports pass can
200
+ resolve cross-package bare imports from module pages whose importers
201
+ sit outside the host's client_app (e.g. wheel-installed modules).
202
+
203
+ Regression test for GitHub issue #152.
204
+ """
205
+ from simple_module_cli.scaffolding import create_host
206
+
207
+ dest = tmp_path / "demo"
208
+ create_host(name="demo", dest=dest, modules=[])
209
+
210
+ vite_config = (dest / "client_app" / "vite.config.ts").read_text(encoding="utf-8")
211
+
212
+ # The scanner fallback must be configured under optimizeDeps so it
213
+ # applies to both dev pre-bundling and `vite build`'s pre-bundle pass.
214
+ assert "esbuildOptions" in vite_config, (
215
+ "vite.config.ts must configure optimizeDeps.esbuildOptions"
216
+ )
217
+ assert "nodePaths" in vite_config, (
218
+ "vite.config.ts must seed optimizeDeps.esbuildOptions.nodePaths "
219
+ "with the workspace node_modules (GH issue #152)."
220
+ )
221
+ # The seeded path must reference fsRoot — the dir that contains the
222
+ # hoisted node_modules — not a hardcoded literal that breaks in
223
+ # flat-vs-workspace layouts.
224
+ assert "fsRoot" in vite_config, (
225
+ "nodePaths must reference fsRoot (not a hardcoded literal) so the "
226
+ "fallback works in both flat and workspace layouts (GH issue #152)."
227
+ )
228
+ assert "node_modules" in vite_config, (
229
+ "nodePaths entry must include 'node_modules' (GH issue #152)."
230
+ )
231
+
232
+ async def test_scaffold_vite_resolver_does_not_skip_workspace_modules(self, tmp_path):
233
+ """The moduleBareImportResolver plugin must NOT short-circuit on
234
+ ``fsRootPrefix`` containment.
235
+
236
+ In an npm-workspaces scaffold, ``fsRoot`` resolves to the workspace
237
+ root, which means workspace-member modules at ``modules/<name>/`` sit
238
+ *under* ``fsRoot``. An early-return gating on ``fsRootPrefix`` skips
239
+ them, leaving cross-package bare imports (`maplibre-gl`, `pmtiles`,
240
+ ...) unresolved during dev-mode resolveId.
241
+
242
+ The plugin should guard only on the module-pages prefix set — the
243
+ condition that actually identifies module-page importers regardless of
244
+ whether they sit inside or outside ``fsRoot``.
245
+
246
+ Regression test for GitHub issue #156.
247
+ """
248
+ from simple_module_cli.scaffolding import create_host
249
+
250
+ dest = tmp_path / "demo"
251
+ create_host(name="demo", dest=dest, modules=[])
252
+
253
+ vite_config = (dest / "client_app" / "vite.config.ts").read_text(encoding="utf-8")
254
+
255
+ # The resolver must still exist — the fix shouldn't remove the plugin.
256
+ assert "moduleBareImportResolver" in vite_config, (
257
+ "vite.config.ts must register the cross-package bare-import resolver."
258
+ )
259
+ # The buggy early-return must be gone (GH issue #156).
260
+ assert "startsWith(fsRootPrefix)" not in vite_config, (
261
+ "vite.config.ts must not early-return on fsRootPrefix containment — "
262
+ "in npm-workspaces mode workspace-member module pages live under "
263
+ "fsRoot and would be incorrectly skipped (GH issue #156)."
264
+ )
265
+ # The workspace-root re-resolution must still run.
266
+ assert "fakeWorkspaceImporter" in vite_config, (
267
+ "vite.config.ts must re-resolve unresolved bare imports against the "
268
+ "workspace root so hoisted node_modules wins (GH issue #156)."
269
+ )
@@ -1,30 +0,0 @@
1
- """Host application entry point.
2
-
3
- This file was generated by `smpy create-host`. Modules are discovered at boot
4
- via entry_points; add them to this host's pyproject.toml to install them.
5
- """
6
-
7
- from simple_module_hosting import Settings, create_app
8
- from simple_module_hosting.logging import setup_logging
9
-
10
- from routes import router as host_router
11
-
12
- settings = Settings()
13
-
14
- setup_logging(
15
- level=settings.log_level,
16
- json_format=settings.log_format == "json",
17
- )
18
-
19
- app = create_app(settings)
20
- app.include_router(host_router)
21
-
22
- if __name__ == "__main__":
23
- import uvicorn
24
-
25
- uvicorn.run(
26
- "main:app",
27
- host="0.0.0.0",
28
- port=8000,
29
- reload=settings.is_development,
30
- )