simple-module-cli 0.0.30__tar.gz → 0.0.32__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 (134) hide show
  1. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/.gitignore +1 -0
  2. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/PKG-INFO +2 -2
  3. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/pyproject.toml +1 -1
  4. simple_module_cli-0.0.32/simple_module_cli/add_cmd.py +267 -0
  5. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/cli.py +18 -0
  6. simple_module_cli-0.0.32/simple_module_cli/git_source.py +282 -0
  7. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/new.py +46 -6
  8. simple_module_cli-0.0.32/simple_module_cli/pyproject_edit.py +145 -0
  9. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/simple-module-cli/SKILL.md +1 -0
  10. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/simple-module-conventions/SKILL.md +15 -2
  11. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/simple-module-creating/SKILL.md +1 -0
  12. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +2 -0
  13. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/simple-module-registries/SKILL.md +1 -1
  14. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/README.md.tpl +2 -0
  15. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/alembic.ini +5 -1
  16. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/client_app/app.tsx +6 -0
  17. simple_module_cli-0.0.32/simple_module_cli/templates/host/client_app/module-assets.ts +127 -0
  18. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/client_app/vite.config.ts +15 -90
  19. simple_module_cli-0.0.32/simple_module_cli/templates/host/client_app/vite.dev-url.ts +92 -0
  20. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/migrations/env.py +16 -2
  21. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/workspace/Makefile +8 -2
  22. simple_module_cli-0.0.32/simple_module_cli/update_cmd.py +139 -0
  23. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/wizard.py +13 -2
  24. simple_module_cli-0.0.32/tests/conftest.py +119 -0
  25. simple_module_cli-0.0.32/tests/test_cli_add_cmd.py +168 -0
  26. simple_module_cli-0.0.32/tests/test_cli_add_spec_parse.py +91 -0
  27. simple_module_cli-0.0.32/tests/test_cli_git_scan.py +70 -0
  28. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_cli_new.py +1 -1
  29. simple_module_cli-0.0.32/tests/test_cli_new_git_module.py +53 -0
  30. simple_module_cli-0.0.32/tests/test_cli_new_migration_bootstrap.py +85 -0
  31. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_cli_new_regressions.py +0 -48
  32. simple_module_cli-0.0.32/tests/test_cli_pyproject_edit.py +94 -0
  33. simple_module_cli-0.0.32/tests/test_cli_update_sources.py +127 -0
  34. simple_module_cli-0.0.32/tests/test_cli_vite_port_env.py +48 -0
  35. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_cli_wizard.py +7 -7
  36. simple_module_cli-0.0.32/tests/test_cli_wizard_git.py +39 -0
  37. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_module_css_assets.py +52 -0
  38. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_scaffolding_module.py +22 -1
  39. simple_module_cli-0.0.30/tests/conftest.py +0 -45
  40. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/LICENSE +0 -0
  41. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/README.md +0 -0
  42. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/__init__.py +0 -0
  43. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/_env.py +0 -0
  44. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/_module_build.py +0 -0
  45. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/_module_host.py +0 -0
  46. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/_templating.py +0 -0
  47. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/app_project.py +0 -0
  48. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/case.py +0 -0
  49. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/catalog.py +0 -0
  50. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/docker_assets.py +0 -0
  51. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/module_cmd.py +0 -0
  52. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/package_update.py +0 -0
  53. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/pins.py +0 -0
  54. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/plugins.py +0 -0
  55. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/recipes.py +0 -0
  56. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/scaffolding.py +0 -0
  57. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/README.md +0 -0
  58. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  59. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/simple-module-doctor/SKILL.md +0 -0
  60. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  61. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -0
  62. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
  63. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/skills_cmd.py +0 -0
  64. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/docker/Makefile.snippet +0 -0
  65. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl +0 -0
  66. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/docker/compose-base-sqlite.yml.tpl +0 -0
  67. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl +0 -0
  68. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl +0 -0
  69. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/docker/dockerignore +0 -0
  70. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/docker/host-flat.Dockerfile +0 -0
  71. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/docker/host.Dockerfile +0 -0
  72. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/.env.example +0 -0
  73. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/.gitignore +0 -0
  74. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/Makefile +0 -0
  75. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  76. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
  77. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  78. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/client_app/package.json.tpl +0 -0
  79. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  80. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/client_app/pages/Landing.tsx +0 -0
  81. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  82. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/client_app/styles.css +0 -0
  83. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  84. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/main.py +0 -0
  85. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  86. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  87. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
  88. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/routes.py +0 -0
  89. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/host/templates/index.html +0 -0
  90. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  91. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  92. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/.gitignore +0 -0
  93. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/README.md.tpl +0 -0
  94. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  95. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  96. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  97. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/__PACKAGE__/endpoints/views.py.tpl +0 -0
  98. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -0
  99. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/__PACKAGE__/pages/Index.tsx.tpl +0 -0
  100. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  101. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  102. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/_optional/standalone/package.json.tpl +0 -0
  103. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/_optional/standalone/tsconfig.json.tpl +0 -0
  104. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/package.json.tpl +0 -0
  105. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  106. /simple_module_cli-0.0.30/simple_module_cli/templates/module/tests/test_module.py.tpl → /simple_module_cli-0.0.32/simple_module_cli/templates/module/tests/test___PACKAGE__.py.tpl +0 -0
  107. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  108. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/workspace/.env.example +0 -0
  109. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/workspace/.gitignore +0 -0
  110. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/workspace/README.md.tpl +0 -0
  111. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/workspace/package.json.tpl +0 -0
  112. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/simple_module_cli/templates/workspace/pyproject.toml.tpl +0 -0
  113. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_build_packaging.py +0 -0
  114. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_case.py +0 -0
  115. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_cli_catalog.py +0 -0
  116. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_cli_docker_assets.py +0 -0
  117. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_cli_new_dest_tolerance.py +0 -0
  118. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_cli_new_scaffold_layout.py +0 -0
  119. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_cli_package_update.py +0 -0
  120. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_cli_recipes.py +0 -0
  121. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_create_module_ci_skip.py +0 -0
  122. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_env_helper.py +0 -0
  123. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_module_cmd_build.py +0 -0
  124. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_module_cmd_e2e.py +0 -0
  125. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_module_cmd_verify.py +0 -0
  126. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_module_css_build.py +0 -0
  127. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_module_npm_aliases.py +0 -0
  128. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_module_pages_manifest.py +0 -0
  129. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_no_framework_deps.py +0 -0
  130. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_plugin_discovery.py +0 -0
  131. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_scaffold_rollback.py +0 -0
  132. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_scaffolding_host.py +0 -0
  133. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_scaffolding_module_js.py +0 -0
  134. {simple_module_cli-0.0.30 → simple_module_cli-0.0.32}/tests/test_skills_cmd.py +0 -0
@@ -63,3 +63,4 @@ Thumbs.db
63
63
  host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
+ .verify/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simple_module_cli
3
- Version: 0.0.30
3
+ Version: 0.0.32
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.30"
3
+ version = "0.0.32"
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,267 @@
1
+ """``smpy add`` — add a module from PyPI, any git repo, or a local path.
2
+
3
+ Git specs: ``git+<url>[@<tag|branch|sha>][#subdirectory=<dir>]``. The spec
4
+ is resolved first (ls-remote + shallow clone + entry-point scan); the host
5
+ pyproject is written only after resolution succeeds. A git spec without
6
+ ``#subdirectory`` is scanned for module packages — one match is added
7
+ directly, several go through ``--module``/``--all`` or an interactive
8
+ picker. Then: ``uv sync`` → ``smpy host gen-pages`` → ``smpy host
9
+ sync-js-deps`` → entry-point verification, unless ``--no-sync``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ import tempfile
16
+ from collections.abc import Callable
17
+ from pathlib import Path
18
+ from typing import Annotated
19
+
20
+ import typer
21
+
22
+ from simple_module_cli.git_source import (
23
+ FoundModule,
24
+ SpecError,
25
+ classify_ref,
26
+ derive_range,
27
+ parse_add_spec,
28
+ scan_modules,
29
+ shallow_clone,
30
+ )
31
+ from simple_module_cli.pyproject_edit import (
32
+ has_git_sources,
33
+ load_pyproject,
34
+ save_pyproject,
35
+ write_dependency,
36
+ write_git_source,
37
+ write_path_source,
38
+ )
39
+
40
+ __all__ = ["ExecRunner", "add_module", "post_install", "run_add"]
41
+
42
+ ExecRunner = Callable[[list[str], Path], int]
43
+
44
+ _SECURITY_NOTICE = (
45
+ "note: this installs and runs code from a URL you chose — review the "
46
+ "repository before booting the host."
47
+ )
48
+
49
+ _ENTRYPOINT_CHECK = (
50
+ "import sys\n"
51
+ "from importlib.metadata import distributions\n"
52
+ "target = sys.argv[1].replace('-', '_').lower()\n"
53
+ "for dist in distributions():\n"
54
+ " if any(ep.group == 'simple_module' for ep in dist.entry_points):\n"
55
+ " name = (dist.metadata['Name'] or '').replace('-', '_').lower()\n"
56
+ " if name == target:\n"
57
+ " sys.exit(0)\n"
58
+ "sys.exit(1)\n"
59
+ )
60
+
61
+
62
+ def _default_exec_runner(cmd: list[str], cwd: Path) -> int:
63
+ return subprocess.run(cmd, cwd=cwd, check=False).returncode
64
+
65
+
66
+ def _fail(message: str) -> typer.Exit:
67
+ typer.echo(f"ERROR: {message}", err=True)
68
+ return typer.Exit(code=1)
69
+
70
+
71
+ def _choose(
72
+ found: list[FoundModule],
73
+ *,
74
+ select: list[str] | None,
75
+ all_modules: bool,
76
+ assume_yes: bool,
77
+ ) -> list[FoundModule]:
78
+ if len(found) == 1:
79
+ return found
80
+ if all_modules:
81
+ return found
82
+ if select:
83
+ wanted = {s.replace("-", "_").lower() for s in select}
84
+ chosen = [m for m in found if m.dist_name.replace("-", "_").lower() in wanted]
85
+ missing = wanted - {m.dist_name.replace("-", "_").lower() for m in chosen}
86
+ if missing:
87
+ raise _fail(f"module(s) not found in repo: {', '.join(sorted(missing))}")
88
+ return chosen
89
+ if assume_yes:
90
+ names = ", ".join(m.dist_name for m in found)
91
+ raise _fail(f"repo contains multiple modules ({names}); pick with --module a,b or --all")
92
+ return [
93
+ m
94
+ for m in found
95
+ if typer.confirm(f"Include {m.dist_name} ({m.subdirectory or 'repo root'})?")
96
+ ]
97
+
98
+
99
+ def post_install(
100
+ project_dir: Path,
101
+ dists: list[str],
102
+ models_dists: list[str],
103
+ exec_runner: ExecRunner,
104
+ ) -> None:
105
+ """uv sync → gen-pages → sync-js-deps → entry-point check → reminders."""
106
+ if exec_runner(["uv", "sync"], project_dir) != 0:
107
+ raise _fail("`uv sync` failed — pyproject was written; fix and re-run `uv sync`.")
108
+ if (project_dir / "client_app").is_dir():
109
+ exec_runner(["uv", "run", "smpy", "host", "gen-pages"], project_dir)
110
+ exec_runner(["uv", "run", "smpy", "host", "sync-js-deps"], project_dir)
111
+ for dist in dists:
112
+ code = exec_runner(["uv", "run", "python", "-c", _ENTRYPOINT_CHECK, dist], project_dir)
113
+ if code != 0:
114
+ raise _fail(
115
+ f"{dist} installed but exposes no [project.entry-points.simple_module] "
116
+ "entry point — it is not a SimpleModule module."
117
+ )
118
+ for dist in models_dists:
119
+ typer.echo(
120
+ f"{dist} ships database models — generate and apply the migration:\n"
121
+ f' make migration msg="add {dist}"\n make migrate'
122
+ )
123
+
124
+
125
+ def _add_pypi(doc, pyproject: Path, raw: str, no_sync: bool, exec_runner: ExecRunner) -> list[str]:
126
+ name = raw
127
+ for i, ch in enumerate(raw):
128
+ if ch in "<>=!~[; ":
129
+ name = raw[:i]
130
+ break
131
+ constraint = raw[len(name) :]
132
+ write_dependency(doc, name, constraint)
133
+ save_pyproject(pyproject, doc)
134
+ typer.echo(f"Added {name} (PyPI).")
135
+ if not no_sync:
136
+ post_install(pyproject.parent, [name], [], exec_runner)
137
+ return [name]
138
+
139
+
140
+ def _add_path(
141
+ doc, pyproject: Path, spec_path: Path, no_sync: bool, exec_runner: ExecRunner
142
+ ) -> list[str]:
143
+ root = spec_path if spec_path.is_absolute() else pyproject.parent / spec_path
144
+ found = [m for m in scan_modules(root) if m.subdirectory is None]
145
+ if not found:
146
+ raise _fail(f"{root} has no [project.entry-points.simple_module] entry point at its root")
147
+ mod = found[0]
148
+ write_dependency(doc, mod.dist_name, derive_range(mod.version))
149
+ write_path_source(doc, mod.dist_name, path=str(spec_path))
150
+ save_pyproject(pyproject, doc)
151
+ typer.echo(f"Added {mod.dist_name} (editable path {spec_path}).")
152
+ if not no_sync:
153
+ post_install(
154
+ pyproject.parent,
155
+ [mod.dist_name],
156
+ [mod.dist_name] if mod.ships_models else [],
157
+ exec_runner,
158
+ )
159
+ return [mod.dist_name]
160
+
161
+
162
+ def run_add(
163
+ spec: str,
164
+ *,
165
+ pyproject: Path,
166
+ select: list[str] | None = None,
167
+ all_modules: bool = False,
168
+ no_sync: bool = False,
169
+ assume_yes: bool = False,
170
+ git_runner=None,
171
+ exec_runner: ExecRunner | None = None,
172
+ ) -> list[str]:
173
+ if not pyproject.is_file():
174
+ raise _fail(f"{pyproject} not found")
175
+ exec_runner = exec_runner or _default_exec_runner
176
+ try:
177
+ parsed = parse_add_spec(spec)
178
+ except SpecError as exc:
179
+ raise _fail(str(exc)) from exc
180
+ doc = load_pyproject(pyproject)
181
+
182
+ if parsed.kind == "pypi":
183
+ return _add_pypi(doc, pyproject, parsed.raw, no_sync, exec_runner)
184
+ if parsed.kind == "path":
185
+ assert parsed.path is not None
186
+ return _add_path(doc, pyproject, parsed.path, no_sync, exec_runner)
187
+
188
+ assert parsed.git is not None
189
+ git_kwargs = {"run": git_runner} if git_runner else {}
190
+ ref_info = classify_ref(parsed.git.url, parsed.git.ref, **git_kwargs)
191
+ with tempfile.TemporaryDirectory(prefix="smpy-add-") as tmp:
192
+ clone = shallow_clone(parsed.git.url, ref_info, Path(tmp) / "repo", **git_kwargs)
193
+ found = scan_modules(clone)
194
+ if parsed.git.subdirectory is not None:
195
+ found = [m for m in found if m.subdirectory == parsed.git.subdirectory]
196
+ if not found:
197
+ where = (
198
+ f"subdirectory {parsed.git.subdirectory!r} of {parsed.git.url}"
199
+ if parsed.git.subdirectory
200
+ else parsed.git.url
201
+ )
202
+ raise _fail(f"{where} has no package declaring [project.entry-points.simple_module]")
203
+ chosen = _choose(found, select=select, all_modules=all_modules, assume_yes=assume_yes)
204
+ if not chosen:
205
+ raise _fail("no modules selected")
206
+
207
+ if not has_git_sources(doc):
208
+ typer.echo(_SECURITY_NOTICE)
209
+ for mod in chosen:
210
+ write_dependency(doc, mod.dist_name, derive_range(mod.version))
211
+ write_git_source(
212
+ doc,
213
+ mod.dist_name,
214
+ url=parsed.git.url,
215
+ ref_info=ref_info,
216
+ subdirectory=mod.subdirectory,
217
+ )
218
+ pin = f"{ref_info.kind} {ref_info.value}" if ref_info.value else "default branch"
219
+ typer.echo(f"Added {mod.dist_name} {mod.version} from git ({pin}).")
220
+ if ref_info.kind == "branch":
221
+ typer.echo(
222
+ f" {mod.dist_name} tracks a branch — dev-mode pin; prefer a v* tag for releases."
223
+ )
224
+ save_pyproject(pyproject, doc)
225
+ if not no_sync:
226
+ post_install(
227
+ pyproject.parent,
228
+ [m.dist_name for m in chosen],
229
+ [m.dist_name for m in chosen if m.ships_models],
230
+ exec_runner,
231
+ )
232
+ return [m.dist_name for m in chosen]
233
+
234
+
235
+ def add_module(
236
+ spec: Annotated[
237
+ str,
238
+ typer.Argument(help="PyPI requirement, git+URL[@ref][#subdirectory=dir], or local path."),
239
+ ],
240
+ module: Annotated[
241
+ str,
242
+ typer.Option("--module", help="Comma-separated module dist names (multi-module repos)."),
243
+ ] = "",
244
+ all_modules: Annotated[
245
+ bool, typer.Option("--all", help="Add every module found in the repo.")
246
+ ] = False,
247
+ path: Annotated[
248
+ Path,
249
+ typer.Option("--path", help="Project root or pyproject.toml. Defaults to cwd."),
250
+ ] = Path(),
251
+ no_sync: Annotated[
252
+ bool,
253
+ typer.Option("--no-sync", help="Write pyproject only; skip uv sync / gen-pages."),
254
+ ] = False,
255
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Never prompt (fail instead).")] = False,
256
+ ) -> None:
257
+ """Add a module dependency from PyPI, a git repo, or a local path."""
258
+ pyproject = path if path.name == "pyproject.toml" else path / "pyproject.toml"
259
+ select = [m.strip() for m in module.split(",") if m.strip()]
260
+ run_add(
261
+ spec,
262
+ pyproject=pyproject,
263
+ select=select or None,
264
+ all_modules=all_modules,
265
+ no_sync=no_sync,
266
+ assume_yes=yes,
267
+ )
@@ -2,6 +2,8 @@
2
2
 
3
3
  Built-in commands:
4
4
  smpy new
5
+ smpy add
6
+ smpy update
5
7
  smpy create-host
6
8
  smpy create-module
7
9
  smpy skills add / list / update
@@ -17,6 +19,7 @@ from typing import Annotated
17
19
 
18
20
  import typer
19
21
 
22
+ from simple_module_cli.add_cmd import add_module, run_add
20
23
  from simple_module_cli.case import to_kebab_case
21
24
  from simple_module_cli.module_cmd import module_app
22
25
  from simple_module_cli.new import new_project
@@ -26,6 +29,7 @@ from simple_module_cli.scaffolding import create_host as _create_host
26
29
  from simple_module_cli.scaffolding import create_module as _create_module
27
30
  from simple_module_cli.scaffolding import is_inside_existing_repo, resolve_framework_version
28
31
  from simple_module_cli.skills_cmd import app as skills_app
32
+ from simple_module_cli.update_cmd import update_modules
29
33
 
30
34
  app = typer.Typer(
31
35
  help="SimpleModule developer CLI.",
@@ -34,6 +38,8 @@ app = typer.Typer(
34
38
  )
35
39
 
36
40
  app.command("new")(new_project)
41
+ app.command("add")(add_module)
42
+ app.command("update")(update_modules)
37
43
  app.command("package-update")(package_update)
38
44
  app.add_typer(skills_app, name="skills")
39
45
  app.add_typer(module_app, name="module")
@@ -53,6 +59,14 @@ def create_host(
53
59
  help="Comma-separated module names to declare as deps (e.g. Auth,Dashboard).",
54
60
  ),
55
61
  ] = "",
62
+ git_module: Annotated[
63
+ list[str] | None,
64
+ typer.Option(
65
+ "--git-module",
66
+ help="git+URL[@ref][#subdirectory=dir] module source; repeatable. "
67
+ "Multi-module repos need an explicit #subdirectory here.",
68
+ ),
69
+ ] = None,
56
70
  ) -> None:
57
71
  """Scaffold a new SimpleModule host project at ./<NAME>."""
58
72
  target = dest or Path.cwd() / name
@@ -73,6 +87,10 @@ def create_host(
73
87
  typer.echo(f"ERROR: {exc}", err=True)
74
88
  raise typer.Exit(code=1) from exc
75
89
 
90
+ # create-host is non-interactive: multi-module repos need #subdirectory.
91
+ for spec in git_module or []:
92
+ run_add(spec, pyproject=target / "pyproject.toml", no_sync=True, assume_yes=True)
93
+
76
94
  typer.echo(f"Created host '{name}' at {target}")
77
95
  if selected:
78
96
  typer.echo(f"Declared modules: {', '.join(selected)}")
@@ -0,0 +1,282 @@
1
+ """Parsing and git-side resolution for ``smpy add`` / ``smpy update``.
2
+
3
+ A git module source is a normal named dependency plus a ``[tool.uv.sources]``
4
+ redirect. This module owns everything before the host pyproject is written:
5
+ parsing the spec string, classifying ``@ref`` against the remote, shallow
6
+ cloning for metadata, and scanning a clone for module packages.
7
+
8
+ Stdlib + the ``git`` binary only: the CLI distribution depends on typer +
9
+ tomlkit alone (see test_no_framework_deps.py); clones are read with tomllib.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ import tomllib
16
+ from collections.abc import Callable, Iterable
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Literal
20
+
21
+ __all__ = [
22
+ "FoundModule",
23
+ "GitAddSpec",
24
+ "ParsedSpec",
25
+ "RefInfo",
26
+ "SpecError",
27
+ "classify_ref",
28
+ "derive_range",
29
+ "list_remote_refs",
30
+ "parse_add_spec",
31
+ "pick_latest_tag",
32
+ "satisfies",
33
+ "scan_modules",
34
+ "shallow_clone",
35
+ "version_tuple",
36
+ ]
37
+
38
+ GitRunner = Callable[..., str]
39
+
40
+
41
+ class SpecError(ValueError):
42
+ """Malformed or unsupported `smpy add` spec."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class GitAddSpec:
47
+ url: str
48
+ ref: str | None
49
+ subdirectory: str | None
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ParsedSpec:
54
+ kind: Literal["pypi", "git", "path"]
55
+ raw: str
56
+ git: GitAddSpec | None = None
57
+ path: Path | None = None
58
+
59
+
60
+ def parse_add_spec(spec: str) -> ParsedSpec:
61
+ raw = spec.strip()
62
+ if not raw:
63
+ raise SpecError("empty spec")
64
+ if raw.startswith("git+"):
65
+ return ParsedSpec("git", raw, git=_parse_git(raw))
66
+ if raw.startswith((".", "/", "~")):
67
+ return ParsedSpec("path", raw, path=Path(raw).expanduser())
68
+ if "://" in raw:
69
+ raise SpecError(f"unsupported URL {raw!r}: git sources must start with git+")
70
+ return ParsedSpec("pypi", raw)
71
+
72
+
73
+ def _parse_git(raw: str) -> GitAddSpec:
74
+ body = raw[len("git+") :]
75
+ subdirectory: str | None = None
76
+ if "#" in body:
77
+ body, fragment = body.split("#", 1)
78
+ for part in fragment.split("&"):
79
+ if part.startswith("subdirectory="):
80
+ subdirectory = part[len("subdirectory=") :] or None
81
+ elif part:
82
+ raise SpecError(
83
+ f"unsupported fragment {part!r}: only subdirectory=<dir> is understood"
84
+ )
85
+ ref: str | None = None
86
+ head, sep, tail = body.rpartition("@")
87
+ # `@` also appears in URL userinfo (ssh://git@host/...). A ref candidate
88
+ # never contains "/" and what precedes it must still be a full URL.
89
+ if sep and tail and "/" not in tail and "://" in head:
90
+ body, ref = head, tail
91
+ if "://" not in body:
92
+ raise SpecError(f"cannot parse a git URL out of {raw!r}")
93
+ return GitAddSpec(url=body, ref=ref, subdirectory=subdirectory)
94
+
95
+
96
+ # --- version helpers (no `packaging` dep — CLI depends on typer + tomlkit only) ---
97
+
98
+
99
+ def version_tuple(v: str) -> tuple[int, ...]:
100
+ parts: list[int] = []
101
+ for part in v.split("."):
102
+ digits = ""
103
+ for ch in part:
104
+ if ch.isdigit():
105
+ digits += ch
106
+ else:
107
+ break
108
+ parts.append(int(digits) if digits else 0)
109
+ return tuple(parts)
110
+
111
+
112
+ def _cmp(a: str, b: str) -> int:
113
+ ta, tb = version_tuple(a), version_tuple(b)
114
+ width = max(len(ta), len(tb))
115
+ ta += (0,) * (width - len(ta))
116
+ tb += (0,) * (width - len(tb))
117
+ return (ta > tb) - (ta < tb)
118
+
119
+
120
+ _OPS = ("<=", ">=", "==", "!=", "<", ">")
121
+
122
+
123
+ def satisfies(version: str, constraint: str) -> bool:
124
+ for clause in constraint.split(","):
125
+ clause = clause.strip()
126
+ if not clause:
127
+ continue
128
+ for op in _OPS:
129
+ if clause.startswith(op):
130
+ bound = clause[len(op) :].strip()
131
+ c = _cmp(version, bound)
132
+ ok = {
133
+ "<=": c <= 0,
134
+ ">=": c >= 0,
135
+ "==": c == 0,
136
+ "!=": c != 0,
137
+ "<": c < 0,
138
+ ">": c > 0,
139
+ }[op]
140
+ if not ok:
141
+ return False
142
+ break
143
+ else:
144
+ return False
145
+ return True
146
+
147
+
148
+ def derive_range(version: str) -> str:
149
+ major = version_tuple(version)[0]
150
+ upper = "1.0" if major == 0 else f"{major + 1}.0"
151
+ return f">={version},<{upper}"
152
+
153
+
154
+ def pick_latest_tag(tags: Iterable[str], constraint: str | None) -> str | None:
155
+ best: str | None = None
156
+ for tag in tags:
157
+ if not tag.startswith("v") or len(tag) < 2 or not tag[1].isdigit():
158
+ continue
159
+ ver = tag[1:]
160
+ if constraint and not satisfies(ver, constraint):
161
+ continue
162
+ if best is None or _cmp(ver, best[1:]) > 0:
163
+ best = tag
164
+ return best
165
+
166
+
167
+ # --- remote resolution + clone scanning ---
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class RefInfo:
172
+ kind: Literal["tag", "branch", "rev", "default"]
173
+ value: str | None
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class FoundModule:
178
+ dist_name: str
179
+ version: str
180
+ subdirectory: str | None
181
+ ships_models: bool
182
+ framework_range: str | None
183
+
184
+
185
+ def _default_git_runner(args: list[str], cwd: Path | None = None) -> str:
186
+ result = subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True)
187
+ return result.stdout
188
+
189
+
190
+ def list_remote_refs(
191
+ url: str, *, run: GitRunner = _default_git_runner
192
+ ) -> tuple[set[str], set[str]]:
193
+ out = run(["ls-remote", "--tags", "--heads", url])
194
+ tags: set[str] = set()
195
+ branches: set[str] = set()
196
+ for line in out.splitlines():
197
+ parts = line.split("\t")
198
+ if len(parts) != 2:
199
+ continue
200
+ ref = parts[1].removesuffix("^{}")
201
+ if ref.startswith("refs/tags/"):
202
+ tags.add(ref.removeprefix("refs/tags/"))
203
+ elif ref.startswith("refs/heads/"):
204
+ branches.add(ref.removeprefix("refs/heads/"))
205
+ return tags, branches
206
+
207
+
208
+ def classify_ref(url: str, ref: str | None, *, run: GitRunner = _default_git_runner) -> RefInfo:
209
+ if ref is None:
210
+ return RefInfo("default", None)
211
+ tags, branches = list_remote_refs(url, run=run)
212
+ if ref in tags:
213
+ return RefInfo("tag", ref)
214
+ if ref in branches:
215
+ return RefInfo("branch", ref)
216
+ return RefInfo("rev", ref)
217
+
218
+
219
+ def shallow_clone(
220
+ url: str, ref_info: RefInfo, dest: Path, *, run: GitRunner = _default_git_runner
221
+ ) -> Path:
222
+ dest.parent.mkdir(parents=True, exist_ok=True)
223
+ base = ["clone", "--depth", "1", "--quiet"]
224
+ if ref_info.kind in ("tag", "branch") and ref_info.value:
225
+ run([*base, "--branch", ref_info.value, url, str(dest)])
226
+ elif ref_info.kind == "rev" and ref_info.value:
227
+ # --branch can't take a SHA; fetch the rev into a fresh clone instead.
228
+ run([*base, url, str(dest)])
229
+ run(["fetch", "--depth", "1", "origin", ref_info.value], cwd=dest)
230
+ run(["checkout", "--quiet", "FETCH_HEAD"], cwd=dest)
231
+ else:
232
+ run([*base, url, str(dest)])
233
+ return dest
234
+
235
+
236
+ _SKIP_DIRS = {".git", "node_modules", ".venv", "__pycache__", "dist", "build"}
237
+
238
+
239
+ def _framework_range(deps: list[str]) -> str | None:
240
+ for dep in deps:
241
+ text = str(dep).strip()
242
+ if text.replace("-", "_").startswith("simple_module_core"):
243
+ for i, ch in enumerate(text):
244
+ if ch in "<>=!~":
245
+ return text[i:].strip()
246
+ return None
247
+
248
+
249
+ def scan_modules(repo_root: Path) -> list[FoundModule]:
250
+ """Find every package in the clone declaring the simple_module entry point."""
251
+ candidates: list[Path] = []
252
+ for pattern in ("pyproject.toml", "*/pyproject.toml", "*/*/pyproject.toml"):
253
+ candidates.extend(sorted(repo_root.glob(pattern)))
254
+ found: list[FoundModule] = []
255
+ for pyproject in candidates:
256
+ rel_parts = pyproject.relative_to(repo_root).parts
257
+ if any(part in _SKIP_DIRS for part in rel_parts):
258
+ continue
259
+ try:
260
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
261
+ except (OSError, tomllib.TOMLDecodeError):
262
+ continue
263
+ project = data.get("project") or {}
264
+ entry_points = project.get("entry-points") or {}
265
+ if "simple_module" not in entry_points or "name" not in project:
266
+ continue
267
+ pkg_root = pyproject.parent
268
+ subdirectory = None if pkg_root == repo_root else "/".join(rel_parts[:-1])
269
+ ships_models = any(
270
+ not any(part in _SKIP_DIRS for part in p.relative_to(pkg_root).parts)
271
+ for p in pkg_root.glob("*/models.py")
272
+ )
273
+ found.append(
274
+ FoundModule(
275
+ dist_name=str(project["name"]),
276
+ version=str(project.get("version", "0")),
277
+ subdirectory=subdirectory,
278
+ ships_models=ships_models,
279
+ framework_range=_framework_range(project.get("dependencies") or []),
280
+ )
281
+ )
282
+ return found