simple-module-cli 0.0.28__tar.gz → 0.0.30__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 (119) hide show
  1. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/PKG-INFO +1 -1
  2. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/pyproject.toml +1 -1
  3. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/README.md.tpl +2 -2
  4. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/client_app/vite.config.ts +34 -11
  5. simple_module_cli-0.0.30/tests/conftest.py +45 -0
  6. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_module_css_assets.py +50 -51
  7. simple_module_cli-0.0.30/tests/test_module_css_build.py +144 -0
  8. simple_module_cli-0.0.30/tests/test_module_npm_aliases.py +128 -0
  9. simple_module_cli-0.0.28/tests/test_module_css_build.py +0 -72
  10. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/.gitignore +0 -0
  11. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/LICENSE +0 -0
  12. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/README.md +0 -0
  13. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/__init__.py +0 -0
  14. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/_env.py +0 -0
  15. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/_module_build.py +0 -0
  16. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/_module_host.py +0 -0
  17. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/_templating.py +0 -0
  18. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/app_project.py +0 -0
  19. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/case.py +0 -0
  20. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/catalog.py +0 -0
  21. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/cli.py +0 -0
  22. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/docker_assets.py +0 -0
  23. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/module_cmd.py +0 -0
  24. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/new.py +0 -0
  25. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/package_update.py +0 -0
  26. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/pins.py +0 -0
  27. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/plugins.py +0 -0
  28. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/recipes.py +0 -0
  29. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/scaffolding.py +0 -0
  30. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/README.md +0 -0
  31. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -0
  32. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
  33. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/simple-module-creating/SKILL.md +0 -0
  34. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  35. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/simple-module-doctor/SKILL.md +0 -0
  36. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +0 -0
  37. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  38. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -0
  39. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
  40. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
  41. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/skills_cmd.py +0 -0
  42. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/docker/Makefile.snippet +0 -0
  43. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl +0 -0
  44. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/docker/compose-base-sqlite.yml.tpl +0 -0
  45. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl +0 -0
  46. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl +0 -0
  47. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/docker/dockerignore +0 -0
  48. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/docker/host-flat.Dockerfile +0 -0
  49. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/docker/host.Dockerfile +0 -0
  50. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/.env.example +0 -0
  51. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/.gitignore +0 -0
  52. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/Makefile +0 -0
  53. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  54. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
  55. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/alembic.ini +0 -0
  56. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
  57. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  58. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/client_app/package.json.tpl +0 -0
  59. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  60. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/client_app/pages/Landing.tsx +0 -0
  61. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  62. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/client_app/styles.css +0 -0
  63. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  64. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/main.py +0 -0
  65. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/migrations/env.py +0 -0
  66. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  67. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  68. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
  69. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/routes.py +0 -0
  70. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/host/templates/index.html +0 -0
  71. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  72. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  73. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/.gitignore +0 -0
  74. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/README.md.tpl +0 -0
  75. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  76. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  77. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  78. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/__PACKAGE__/endpoints/views.py.tpl +0 -0
  79. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -0
  80. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/__PACKAGE__/pages/Index.tsx.tpl +0 -0
  81. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  82. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  83. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/_optional/standalone/package.json.tpl +0 -0
  84. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/_optional/standalone/tsconfig.json.tpl +0 -0
  85. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/package.json.tpl +0 -0
  86. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  87. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
  88. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  89. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/workspace/.env.example +0 -0
  90. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/workspace/.gitignore +0 -0
  91. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/workspace/Makefile +0 -0
  92. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/workspace/README.md.tpl +0 -0
  93. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/workspace/package.json.tpl +0 -0
  94. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/templates/workspace/pyproject.toml.tpl +0 -0
  95. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/simple_module_cli/wizard.py +0 -0
  96. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_build_packaging.py +0 -0
  97. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_case.py +0 -0
  98. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_cli_catalog.py +0 -0
  99. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_cli_docker_assets.py +0 -0
  100. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_cli_new.py +0 -0
  101. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_cli_new_dest_tolerance.py +0 -0
  102. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_cli_new_regressions.py +0 -0
  103. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_cli_new_scaffold_layout.py +0 -0
  104. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_cli_package_update.py +0 -0
  105. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_cli_recipes.py +0 -0
  106. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_cli_wizard.py +0 -0
  107. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_create_module_ci_skip.py +0 -0
  108. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_env_helper.py +0 -0
  109. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_module_cmd_build.py +0 -0
  110. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_module_cmd_e2e.py +0 -0
  111. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_module_cmd_verify.py +0 -0
  112. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_module_pages_manifest.py +0 -0
  113. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_no_framework_deps.py +0 -0
  114. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_plugin_discovery.py +0 -0
  115. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_scaffold_rollback.py +0 -0
  116. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_scaffolding_host.py +0 -0
  117. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_scaffolding_module.py +0 -0
  118. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/tests/test_scaffolding_module_js.py +0 -0
  119. {simple_module_cli-0.0.28 → simple_module_cli-0.0.30}/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.28
3
+ Version: 0.0.30
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.28"
3
+ version = "0.0.30"
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"
@@ -14,7 +14,7 @@ cp .env.example .env
14
14
  # First time only — initialize the migration history for the modules you
15
15
  # picked. Inspect the generated file, then apply it.
16
16
  alembic revision --autogenerate -m "initial schema"
17
- alembic upgrade head
17
+ alembic upgrade heads
18
18
 
19
19
  # Run the API
20
20
  python main.py
@@ -39,7 +39,7 @@ uv add simple_module_my_module
39
39
 
40
40
  # 2) generate & apply the migration (new tables + any schema changes)
41
41
  alembic revision --autogenerate -m "add my-module"
42
- alembic upgrade head
42
+ alembic upgrade heads
43
43
 
44
44
  # 3) restart the host — the module's routes, menu items, permissions,
45
45
  # feature flags, events, and health checks register automatically
@@ -69,20 +69,35 @@ if (fs.existsSync(manifestPath)) {
69
69
  }
70
70
  }
71
71
 
72
- // Per-module aliases, so generated CSS can say
73
- // @import "#module/gis/styles.css"
74
- // instead of a ../../../.venv/lib/python3.12/site-packages/gis/styles.css
75
- // path that breaks the moment the interpreter version changes.
72
+ // Three things come out of modules.assets.json.
73
+ //
74
+ // 1. `server.fs.allow` entries. The dev server must be allowed to read each
75
+ // module's package dir. Read from modules.assets.json rather than
76
+ // modules.manifest.json because the manifest is keyed off `pages/`, so a
77
+ // module shipping only CSS never appears in it.
78
+ //
79
+ // 2. A convenience `#module/<pkg>` alias. This is NOT required by
80
+ // `modules.generated.css` — that file imports module stylesheets by
81
+ // absolute path, so it resolves with no alias configured at all. Emitting
82
+ // an alias there made a generated file depend on this hand-owned config,
83
+ // and since `vite.config.ts` is scaffolded once and then owned by the app,
84
+ // a Python-only version bump broke every host scaffolded earlier
85
+ // (GH issue #253). The alias stays because it costs nothing.
86
+ //
87
+ // 3. An `<npm_name>` alias per module, so one module can import another's
88
+ // TS/TSX by package name. Aimed at the module's *Python package* dir —
89
+ // a wheel ships `site-packages/foo/**` and nothing above it, so the
90
+ // source-tree module root is not a target both layouts have. Needed in
91
+ // both: a wheel module is never in node_modules, and npm symlinks a
92
+ // workspace member onto the module root, one level too high.
93
+ // See docs/module-authoring.md § Importing another module's TS/TSX.
76
94
  //
77
95
  // `@tailwindcss/vite` builds its CSS import resolver with
78
96
  // `createResolver({ ...config.resolve, ... })`, so `resolve.alias` governs
79
97
  // CSS `@import` as well as JS — verified against @tailwindcss/vite 4.2.4.
80
- //
81
- // Read from modules.assets.json rather than modules.manifest.json: the
82
- // manifest is keyed off `pages/`, so a module shipping only CSS never
83
- // appears in it.
84
- type ModuleAsset = { package_name: string; package: string };
98
+ type ModuleAsset = { package_name: string; package: string; npm_name?: string | null };
85
99
  const moduleAliases: { find: string; replacement: string }[] = [];
100
+ const moduleNpmNames = new Set<string>();
86
101
  const assetsPath = path.resolve(__dirname, 'modules.assets.json');
87
102
  let moduleAssets: Record<string, ModuleAsset> = {};
88
103
  try {
@@ -92,6 +107,10 @@ try {
92
107
  }
93
108
  for (const entry of Object.values(moduleAssets)) {
94
109
  moduleAliases.push({ find: `#module/${entry.package_name}`, replacement: entry.package });
110
+ if (entry.npm_name) {
111
+ moduleAliases.push({ find: entry.npm_name, replacement: entry.package });
112
+ moduleNpmNames.add(entry.npm_name);
113
+ }
95
114
  if (!moduleFsAllow.includes(entry.package)) moduleFsAllow.push(entry.package);
96
115
  }
97
116
  // Keep the alias list in a stable, longest-first order. Vite matches a string
@@ -169,6 +188,10 @@ function collectOptimizeIncludes(): string[] {
169
188
  for (const block of [pkg.dependencies, pkg.peerDependencies]) {
170
189
  for (const name of Object.keys(block ?? {})) {
171
190
  if (name.startsWith('@types/')) continue;
191
+ // A sibling module declared as a dep/peer dep is not a node_modules
192
+ // package — it is aliased to a source directory above. Pre-bundling
193
+ // it would point the optimizer at raw .tsx with no entry point.
194
+ if (moduleNpmNames.has(name)) continue;
172
195
  const nested = findPackageJSON(name);
173
196
  if (!nested) continue;
174
197
  const nestedPkg = readPackageJSON(nested);
@@ -231,8 +254,8 @@ export default defineConfig({
231
254
  plugins: [moduleBareImportResolver(), react(), tailwindcss()],
232
255
  root: __dirname,
233
256
  resolve: {
234
- // `#module/<pkg>` -> that module's package directory. Consumed by the
235
- // @import lines in modules.generated.css.
257
+ // `#module/<pkg>` -> that module's package directory. Optional sugar for
258
+ // hand-written imports; modules.generated.css does not rely on it.
236
259
  alias: moduleAliases,
237
260
  dedupe: [...REACT_CORE_DEPS, '@simple-module-py/ui', '@simple-module-py/i18n'],
238
261
  },
@@ -0,0 +1,45 @@
1
+ """Fixtures shared by the module-asset tests in this directory.
2
+
3
+ These test files have no ``__init__.py`` (test basenames are globally unique
4
+ instead), so a plain helper import across files is not available — a fixture is
5
+ how the factory below gets shared.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+
16
+ @pytest.fixture
17
+ def make_importable_module(tmp_path: Path):
18
+ """Return a factory creating a real importable package bound to a ModuleBase.
19
+
20
+ ``get_module_package_name`` derives the package from the class's
21
+ ``__module__``, and ``compute_module_assets`` then resolves that package via
22
+ ``importlib.resources.files``. So the package has to genuinely exist on
23
+ ``sys.path`` — a mock won't exercise the code path we care about.
24
+
25
+ Returns ``(module_instance, package_dir)``.
26
+ """
27
+ from simple_module_core import ModuleBase, ModuleMeta
28
+
29
+ def _make(pkg_name: str, klass_name: str, *, root: Path | None = None):
30
+ base = root or tmp_path
31
+ pkg = base / pkg_name
32
+ pkg.mkdir(parents=True, exist_ok=True)
33
+ (pkg / "__init__.py").write_text("", encoding="utf-8")
34
+ if str(base) not in sys.path:
35
+ sys.path.insert(0, str(base))
36
+ sys.modules.pop(pkg_name, None)
37
+
38
+ klass = type(
39
+ klass_name,
40
+ (ModuleBase,),
41
+ {"meta": ModuleMeta(name=klass_name), "__module__": f"{pkg_name}.module"},
42
+ )
43
+ return klass(), pkg
44
+
45
+ return _make
@@ -2,41 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import sys
6
5
  from pathlib import Path
7
6
 
8
7
 
9
- def _make_importable_module(tmp_path: Path, pkg_name: str, klass_name: str):
10
- """Create a real importable package on disk and return a ModuleBase bound to it.
11
-
12
- ``get_module_package_name`` derives the package from the class's
13
- ``__module__``, and ``compute_module_assets`` then resolves that package
14
- via ``importlib.resources.files``. So the package has to genuinely exist
15
- on ``sys.path`` — a mock won't exercise the code path we care about.
16
- """
17
- from simple_module_core import ModuleBase, ModuleMeta
18
-
19
- pkg = tmp_path / pkg_name
20
- pkg.mkdir(parents=True, exist_ok=True)
21
- (pkg / "__init__.py").write_text("", encoding="utf-8")
22
- if str(tmp_path) not in sys.path:
23
- sys.path.insert(0, str(tmp_path))
24
- sys.modules.pop(pkg_name, None)
25
-
26
- klass = type(
27
- klass_name,
28
- (ModuleBase,),
29
- {"meta": ModuleMeta(name=klass_name), "__module__": f"{pkg_name}.module"},
30
- )
31
- return klass(), pkg
32
-
33
-
34
8
  class TestComputeModuleAssets:
35
- async def test_detects_theme_and_styles(self, tmp_path):
9
+ async def test_detects_theme_and_styles(self, make_importable_module):
36
10
  """A module shipping theme.css/styles.css has both detected."""
37
11
  from simple_module_hosting.assets import compute_module_assets
38
12
 
39
- mod, pkg = _make_importable_module(tmp_path, "styled_mod", "Styled")
13
+ mod, pkg = make_importable_module("styled_mod", "Styled")
40
14
  (pkg / "pages").mkdir()
41
15
  (pkg / "theme.css").write_text("@theme { --color-x: red; }\n", encoding="utf-8")
42
16
  (pkg / "styles.css").write_text(".x { color: red; }\n", encoding="utf-8")
@@ -50,11 +24,11 @@ class TestComputeModuleAssets:
50
24
  assert entry.styles_css is not None and entry.styles_css.name == "styles.css"
51
25
  assert entry.pages_dir is not None
52
26
 
53
- async def test_css_only_module_is_included(self, tmp_path):
27
+ async def test_css_only_module_is_included(self, make_importable_module):
54
28
  """A module with CSS but no pages/ still appears — the manifest.json gap."""
55
29
  from simple_module_hosting.assets import compute_module_assets
56
30
 
57
- mod, pkg = _make_importable_module(tmp_path, "cssonly_mod", "CssOnly")
31
+ mod, pkg = make_importable_module("cssonly_mod", "CssOnly")
58
32
  (pkg / "styles.css").write_text(".y { color: blue; }\n", encoding="utf-8")
59
33
 
60
34
  result = compute_module_assets([mod])
@@ -64,11 +38,11 @@ class TestComputeModuleAssets:
64
38
  assert result[0].theme_css is None
65
39
  assert result[0].styles_css is not None
66
40
 
67
- async def test_module_with_no_assets_is_omitted(self, tmp_path):
41
+ async def test_module_with_no_assets_is_omitted(self, make_importable_module):
68
42
  """A module contributing nothing frontend-ish is skipped, not an error."""
69
43
  from simple_module_hosting.assets import compute_module_assets
70
44
 
71
- mod, _pkg = _make_importable_module(tmp_path, "headless_mod", "Headless")
45
+ mod, _pkg = make_importable_module("headless_mod", "Headless")
72
46
 
73
47
  assert compute_module_assets([mod]) == []
74
48
 
@@ -105,15 +79,13 @@ class TestCssEmission:
105
79
  """theme.css imports unlayered; styles.css imports into layer(components)."""
106
80
  from simple_module_hosting.assets import render_modules_css
107
81
 
108
- entry = _assets(
109
- tmp_path,
110
- theme_css=tmp_path / "gis" / "theme.css",
111
- styles_css=tmp_path / "gis" / "styles.css",
112
- )
82
+ theme = tmp_path / "gis" / "theme.css"
83
+ styles = tmp_path / "gis" / "styles.css"
84
+ entry = _assets(tmp_path, theme_css=theme, styles_css=styles)
113
85
  css = render_modules_css([entry], in_repo=lambda _p: False)
114
86
 
115
- assert '@import "#module/gis/theme.css";' in css
116
- assert '@import "#module/gis/styles.css" layer(components);' in css
87
+ assert f'@import "{theme.as_posix()}";' in css
88
+ assert f'@import "{styles.as_posix()}" layer(components);' in css
117
89
  # Tokens must be declared before the rules that consume them.
118
90
  assert css.index("theme.css") < css.index("styles.css")
119
91
 
@@ -121,17 +93,18 @@ class TestCssEmission:
121
93
  """@source is wheel-only; @import is emitted for every module."""
122
94
  from simple_module_hosting.assets import render_modules_css
123
95
 
96
+ styles = tmp_path / "local" / "styles.css"
124
97
  entry = _assets(
125
98
  tmp_path,
126
99
  name="Local",
127
100
  package_name="local",
128
101
  pages_dir=tmp_path / "local" / "pages",
129
- styles_css=tmp_path / "local" / "styles.css",
102
+ styles_css=styles,
130
103
  )
131
104
  css = render_modules_css([entry], in_repo=lambda _p: True)
132
105
 
133
106
  assert "@source" not in css, "in-repo pages are covered by the static glob"
134
- assert '@import "#module/local/styles.css" layer(components);' in css
107
+ assert f'@import "{styles.as_posix()}" layer(components);' in css
135
108
 
136
109
  async def test_source_emitted_for_wheel_modules(self, tmp_path):
137
110
  """A wheel-installed module gets an absolute @source glob."""
@@ -154,9 +127,11 @@ class TestCssEmission:
154
127
  async def test_output_is_formatter_clean(self, tmp_path):
155
128
  """No doubled blank lines, exactly one trailing newline.
156
129
 
157
- `biome ci .` lints the whole tree, and `modules.generated.css` is
158
- untracked but not exempt — so a stray blank line here fails `make lint`
159
- for anyone who has run `gen-pages`.
130
+ `modules.generated.css` is excluded from `biome ci .` (it is generated,
131
+ and its absolute paths make its formatting machine-dependent — whether
132
+ a line exceeds biome's 100-char `lineWidth` depends on where the repo
133
+ happens to live). Nothing enforces tidiness here but this test, and a
134
+ file humans read when debugging a missing style should stay readable.
160
135
  """
161
136
  from simple_module_hosting.assets import render_modules_css
162
137
 
@@ -171,18 +146,34 @@ class TestCssEmission:
171
146
  empty = render_modules_css([], in_repo=lambda _p: False)
172
147
  assert "\n\n\n" not in empty and empty.endswith("\n")
173
148
 
174
- async def test_no_relative_paths_anywhere(self, tmp_path):
175
- """The whole point of aliases: generated @import lines never use ../.."""
149
+ async def test_generated_css_is_self_contained(self, tmp_path):
150
+ """Every emitted path resolves without any host `vite.config.ts` help.
151
+
152
+ Regression guard for GH issue #253. `modules.generated.css` is
153
+ regenerated from the installed Python packages, but `vite.config.ts` is
154
+ scaffold output owned by the app — so anything here that needs a host
155
+ alias to resolve breaks every host scaffolded before that alias
156
+ existed, on a Python-only version bump.
157
+
158
+ Absolute, on-disk paths are the invariant that makes the two files
159
+ independently versionable.
160
+ """
176
161
  from simple_module_core import discover_modules
177
162
  from simple_module_hosting.manifest import write_module_pages_manifest
178
163
 
179
164
  written = write_module_pages_manifest(discover_modules(), tmp_path)
180
165
  css = written["css"].read_text(encoding="utf-8")
181
166
 
182
- for line in css.splitlines():
183
- if line.startswith("@import"):
184
- assert "../" not in line, f"@import must not use a relative path: {line}"
185
- assert '"#module/' in line, f"@import must use the alias prefix: {line}"
167
+ imports = [ln for ln in css.splitlines() if ln.startswith("@import")]
168
+ assert imports, "expected at least one module stylesheet import"
169
+ for line in imports:
170
+ target = Path(line.split('"')[1])
171
+ assert target.is_absolute(), f"@import must be absolute: {line}"
172
+ assert "../" not in line, f"@import must not use a relative path: {line}"
173
+ assert not line.split('"')[1].startswith("#"), (
174
+ f"@import must not use an alias the host has to define: {line}"
175
+ )
176
+ assert target.is_file(), f"@import points at a file that does not exist: {line}"
186
177
 
187
178
 
188
179
  class TestAssetsManifest:
@@ -203,7 +194,15 @@ class TestAssetsManifest:
203
194
  entry = data["Dashboard"]
204
195
  assert entry["package_name"] == "dashboard"
205
196
  assert entry["package"].endswith("dashboard")
206
- assert set(entry) == {"package_name", "package", "pages", "theme", "styles"}
197
+ assert entry["npm_name"] == "@simple-module-py/dashboard"
198
+ assert set(entry) == {
199
+ "package_name",
200
+ "package",
201
+ "pages",
202
+ "theme",
203
+ "styles",
204
+ "npm_name",
205
+ }
207
206
 
208
207
  async def test_manifest_json_shape_unchanged(self, tmp_path):
209
208
  """modules.manifest.json stays {name: pages_dir} for downstream vite configs.
@@ -0,0 +1,144 @@
1
+ """Proves module-shipped CSS survives a real Tailwind build.
2
+
3
+ Every unit test around `render_modules_css` can stay green while Tailwind
4
+ emits nothing at all — the generated `@import` only pays off if Tailwind
5
+ resolves the path and keeps the rule. This is the one assertion that exercises
6
+ that whole chain, so it drives a genuine build rather than inspecting the
7
+ generated text.
8
+
9
+ Skipped where a build is impossible: CI's `python-tests` job installs Python
10
+ deps only (`make install-py`), so `node_modules` is absent there. The JS jobs
11
+ and local runs do have it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import shutil
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+ REPO_ROOT = Path(__file__).resolve().parents[3]
23
+
24
+ pytestmark = [
25
+ pytest.mark.skipif(
26
+ not (REPO_ROOT / "node_modules").is_dir(),
27
+ reason="node_modules absent — run `npm install` to exercise the real build",
28
+ ),
29
+ pytest.mark.skipif(shutil.which("npm") is None, reason="npm not on PATH"),
30
+ ]
31
+
32
+
33
+ class TestModuleCssReachesBundle:
34
+ def test_module_class_is_emitted_by_vite_build(self):
35
+ """A class defined only in a module's styles.css appears in the built CSS.
36
+
37
+ `.dashboard-stat-grid` is declared in modules/dashboard/dashboard/styles.css
38
+ and referenced by no TSX anywhere, so it can only reach the bundle via the
39
+ generated `@import` of that stylesheet.
40
+ """
41
+ subprocess.run(
42
+ [
43
+ "uv",
44
+ "run",
45
+ "--project",
46
+ "host",
47
+ "smpy",
48
+ "host",
49
+ "gen-pages",
50
+ "--host-dir=host/client_app",
51
+ ],
52
+ cwd=REPO_ROOT,
53
+ check=True,
54
+ capture_output=True,
55
+ )
56
+
57
+ generated = (REPO_ROOT / "host/client_app/modules.generated.css").read_text(
58
+ encoding="utf-8"
59
+ )
60
+ dashboard_css = REPO_ROOT / "modules/dashboard/dashboard/styles.css"
61
+ assert f'@import "{dashboard_css.as_posix()}" layer(components);' in generated, (
62
+ f"gen-pages did not emit the dashboard stylesheet import:\n{generated}"
63
+ )
64
+
65
+ subprocess.run(["npm", "run", "build"], cwd=REPO_ROOT, check=True, capture_output=True)
66
+
67
+ built = list((REPO_ROOT / "host/static/dist/assets").glob("*.css"))
68
+ assert built, "no CSS emitted by the build"
69
+ combined = "\n".join(p.read_text(encoding="utf-8") for p in built)
70
+ assert "dashboard-stat-grid" in combined, (
71
+ "module-shipped CSS did not reach the bundle — the generated "
72
+ "@import most likely failed to resolve"
73
+ )
74
+
75
+
76
+ # Written into a real module's pages/ so the host build treats it exactly like
77
+ # any other module page. Removed again in the fixture's teardown.
78
+ PROBE_MODULE_PAGES = REPO_ROOT / "modules/audit_log/audit_log/pages"
79
+ PROBE_PAGE = PROBE_MODULE_PAGES / "CrossModuleImportProbe.tsx"
80
+ PROBE_SOURCE = """\
81
+ // Generated by test_module_css_build.py — safe to delete.
82
+ import { DemoPlaceholders } from '@simple-module-py/dashboard/pages/components/DemoPlaceholders';
83
+
84
+ export default function CrossModuleImportProbe() {
85
+ return <DemoPlaceholders totalUsers={0} />;
86
+ }
87
+ """
88
+
89
+
90
+ @pytest.fixture
91
+ def cross_module_probe_page():
92
+ """Add a page importing a sibling module by npm package name, then remove it."""
93
+ PROBE_PAGE.write_text(PROBE_SOURCE, encoding="utf-8")
94
+ try:
95
+ yield PROBE_PAGE
96
+ finally:
97
+ PROBE_PAGE.unlink(missing_ok=True)
98
+
99
+
100
+ class TestCrossModuleImportResolves:
101
+ def test_module_can_import_a_sibling_by_npm_package_name(self, cross_module_probe_page):
102
+ """`@simple-module-py/<slug>/…` resolves to the sibling's package dir.
103
+
104
+ Nothing in Node's own resolution makes this work. A wheel-installed
105
+ module is not an npm workspace member at all, so it never lands in
106
+ node_modules; a workspace member *is* symlinked, but onto the
107
+ source-tree module root — one level above the Python package — so the
108
+ subpath lands somewhere that does not exist. Both cases are covered by
109
+ the `npm_name` aliases the host builds from modules.assets.json.
110
+
111
+ This can only be caught by a real build: every unit test around
112
+ `npm_name` stays green while the alias itself is missing or misaimed.
113
+ """
114
+ subprocess.run(
115
+ [
116
+ "uv",
117
+ "run",
118
+ "--project",
119
+ "host",
120
+ "smpy",
121
+ "host",
122
+ "gen-pages",
123
+ "--host-dir=host/client_app",
124
+ ],
125
+ cwd=REPO_ROOT,
126
+ check=True,
127
+ capture_output=True,
128
+ )
129
+
130
+ result = subprocess.run(
131
+ ["npm", "run", "build"], cwd=REPO_ROOT, capture_output=True, text=True
132
+ )
133
+ assert result.returncode == 0, (
134
+ "build failed — a module could not import a sibling by npm package "
135
+ f"name:\n{result.stderr[-2000:]}"
136
+ )
137
+
138
+ chunks = list((REPO_ROOT / "host/static/dist/assets").glob("CrossModuleImportProbe*.js"))
139
+ assert chunks, "probe page produced no chunk"
140
+ # Resolution really happened if the sibling's component was pulled in
141
+ # rather than the import being dropped or stubbed out.
142
+ assert any("DemoPlaceholders" in c.read_text(encoding="utf-8") for c in chunks), (
143
+ "probe chunk does not reference the sibling module's component"
144
+ )
@@ -0,0 +1,128 @@
1
+ """Tests for a module's npm identity — the `npm_name` in `modules.assets.json`.
2
+
3
+ That field is what lets one module import another's TS/TSX by package name.
4
+ The host aliases it onto the module's *Python package directory*, and the
5
+ tests here pin both halves: that the name is discovered in either install
6
+ layout, and that it is aimed at the one directory both layouts share.
7
+
8
+ The real end-to-end proof lives in `test_module_css_build.py` — a bundler that
9
+ never resolves the alias would leave every assertion here green.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+
18
+ class TestNpmNameDiscovery:
19
+ async def test_read_from_wheel_layout(self, make_importable_module):
20
+ """A wheel embeds package.json *inside* the Python package.
21
+
22
+ Hatch force-includes the module-root `package.json` as
23
+ `<pkg>/package.json`, so that is where an installed module carries its
24
+ npm identity.
25
+ """
26
+ from simple_module_hosting.assets import compute_module_assets
27
+
28
+ mod, pkg = make_importable_module("wheelish_mod", "Wheelish")
29
+ (pkg / "pages").mkdir()
30
+ (pkg / "package.json").write_text(
31
+ json.dumps({"name": "@simple-module-py/wheelish"}), encoding="utf-8"
32
+ )
33
+
34
+ assert compute_module_assets([mod])[0].npm_name == "@simple-module-py/wheelish"
35
+
36
+ async def test_read_from_workspace_layout(self, make_importable_module):
37
+ """An editable install leaves package.json at the source-tree module root."""
38
+ from simple_module_hosting.assets import compute_module_assets
39
+
40
+ mod, pkg = make_importable_module("srcish_mod", "Srcish")
41
+ (pkg / "pages").mkdir()
42
+ (pkg.parent / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
43
+ (pkg.parent / "package.json").write_text(
44
+ json.dumps({"name": "@simple-module-py/srcish"}), encoding="utf-8"
45
+ )
46
+
47
+ assert compute_module_assets([mod])[0].npm_name == "@simple-module-py/srcish"
48
+
49
+ async def test_parent_package_json_ignored_without_pyproject(self, make_importable_module):
50
+ """No `pyproject.toml` beside it means that directory is not a module root.
51
+
52
+ Without this guard a wheel-installed module would read whatever
53
+ `package.json` happened to sit in `site-packages/` and alias itself
54
+ onto a stranger's name.
55
+ """
56
+ from simple_module_hosting.assets import compute_module_assets
57
+
58
+ mod, pkg = make_importable_module("stray_mod", "Stray")
59
+ (pkg / "pages").mkdir()
60
+ (pkg.parent / "package.json").write_text(
61
+ json.dumps({"name": "totally-unrelated"}), encoding="utf-8"
62
+ )
63
+
64
+ assert compute_module_assets([mod])[0].npm_name is None
65
+
66
+ async def test_module_without_package_json(self, make_importable_module):
67
+ """A Python-only module contributes no npm identity — and must not crash."""
68
+ from simple_module_hosting.assets import compute_module_assets
69
+
70
+ mod, pkg = make_importable_module("nojs_mod", "NoJs")
71
+ (pkg / "styles.css").write_text(".z { color: red; }\n", encoding="utf-8")
72
+
73
+ assert compute_module_assets([mod])[0].npm_name is None
74
+
75
+ async def test_malformed_package_json_is_ignored(self, make_importable_module):
76
+ """A broken package.json degrades to "no npm name", never to a boot failure."""
77
+ from simple_module_hosting.assets import compute_module_assets
78
+
79
+ mod, pkg = make_importable_module("broken_mod", "Broken")
80
+ (pkg / "pages").mkdir()
81
+ (pkg / "package.json").write_text("{not json", encoding="utf-8")
82
+
83
+ assert compute_module_assets([mod])[0].npm_name is None
84
+
85
+
86
+ class TestNpmAliasContract:
87
+ async def test_alias_target_is_the_python_package_dir(self, tmp_path):
88
+ """`npm_name` must alias onto `package`, not the source-tree module root.
89
+
90
+ This is the invariant that makes a cross-module import mean the same
91
+ thing in both install layouts. A wheel ships `site-packages/<pkg>/**`
92
+ and nothing above it — the module root does not survive installation —
93
+ so the Python package directory is the only anchor both layouts share.
94
+
95
+ Concretely: `@simple-module-py/dashboard/pages/Home` has to land on
96
+ `<package>/pages/Home`, which exists in a wheel and in a checkout.
97
+ """
98
+ from simple_module_core import discover_modules
99
+ from simple_module_hosting.manifest import write_module_pages_manifest
100
+
101
+ write_module_pages_manifest(discover_modules(), tmp_path)
102
+ data = json.loads((tmp_path / "modules.assets.json").read_text(encoding="utf-8"))
103
+
104
+ aliased = {k: v for k, v in data.items() if v["npm_name"]}
105
+ assert aliased, "expected at least one module shipping a package.json"
106
+ for name, entry in aliased.items():
107
+ package = Path(entry["package"])
108
+ assert package.is_dir(), f"{name}: alias target is not a directory"
109
+ assert package.name == entry["package_name"], (
110
+ f"{name}: npm name must alias onto the Python package dir "
111
+ f"({entry['package_name']}), not {package.name}"
112
+ )
113
+ if entry["pages"]:
114
+ assert Path(entry["pages"]).parent == package, (
115
+ f"{name}: pages/ must sit directly under the alias target, "
116
+ "or subpath imports resolve differently per install layout"
117
+ )
118
+
119
+ async def test_npm_names_are_unique(self, tmp_path):
120
+ """Two modules claiming one npm name would make the alias order-dependent."""
121
+ from simple_module_core import discover_modules
122
+ from simple_module_hosting.manifest import write_module_pages_manifest
123
+
124
+ write_module_pages_manifest(discover_modules(), tmp_path)
125
+ data = json.loads((tmp_path / "modules.assets.json").read_text(encoding="utf-8"))
126
+
127
+ names = [v["npm_name"] for v in data.values() if v["npm_name"]]
128
+ assert len(names) == len(set(names)), f"duplicate npm package names: {names}"
@@ -1,72 +0,0 @@
1
- """Proves module-shipped CSS survives a real Tailwind build.
2
-
3
- Every unit test around `render_modules_css` can stay green while Tailwind
4
- emits nothing at all — the generated `@import` only pays off if Vite resolves
5
- the `#module/<pkg>` alias and Tailwind keeps the rule. This is the one
6
- assertion that exercises that whole chain, so it drives a genuine build
7
- rather than inspecting the generated text.
8
-
9
- Skipped where a build is impossible: CI's `python-tests` job installs Python
10
- deps only (`make install-py`), so `node_modules` is absent there. The JS jobs
11
- and local runs do have it.
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import shutil
17
- import subprocess
18
- from pathlib import Path
19
-
20
- import pytest
21
-
22
- REPO_ROOT = Path(__file__).resolve().parents[3]
23
-
24
- pytestmark = [
25
- pytest.mark.skipif(
26
- not (REPO_ROOT / "node_modules").is_dir(),
27
- reason="node_modules absent — run `npm install` to exercise the real build",
28
- ),
29
- pytest.mark.skipif(shutil.which("npm") is None, reason="npm not on PATH"),
30
- ]
31
-
32
-
33
- class TestModuleCssReachesBundle:
34
- def test_module_class_is_emitted_by_vite_build(self):
35
- """A class defined only in a module's styles.css appears in the built CSS.
36
-
37
- `.dashboard-stat-grid` is declared in modules/dashboard/dashboard/styles.css
38
- and referenced by no TSX anywhere, so it can only reach the bundle via the
39
- generated `@import "#module/dashboard/styles.css"`.
40
- """
41
- subprocess.run(
42
- [
43
- "uv",
44
- "run",
45
- "--project",
46
- "host",
47
- "smpy",
48
- "host",
49
- "gen-pages",
50
- "--host-dir=host/client_app",
51
- ],
52
- cwd=REPO_ROOT,
53
- check=True,
54
- capture_output=True,
55
- )
56
-
57
- generated = (REPO_ROOT / "host/client_app/modules.generated.css").read_text(
58
- encoding="utf-8"
59
- )
60
- assert '@import "#module/dashboard/styles.css" layer(components);' in generated, (
61
- f"gen-pages did not emit the dashboard stylesheet import:\n{generated}"
62
- )
63
-
64
- subprocess.run(["npm", "run", "build"], cwd=REPO_ROOT, check=True, capture_output=True)
65
-
66
- built = list((REPO_ROOT / "host/static/dist/assets").glob("*.css"))
67
- assert built, "no CSS emitted by the build"
68
- combined = "\n".join(p.read_text(encoding="utf-8") for p in built)
69
- assert "dashboard-stat-grid" in combined, (
70
- "module-shipped CSS did not reach the bundle — the #module alias "
71
- "most likely failed to resolve"
72
- )