simple-module-cli 0.0.2__tar.gz → 0.0.4__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 (81) hide show
  1. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/.gitignore +4 -0
  2. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/PKG-INFO +1 -1
  3. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/pyproject.toml +8 -1
  4. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/app_project.py +19 -1
  5. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/case.py +11 -3
  6. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/catalog.py +1 -8
  7. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/cli.py +6 -1
  8. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/new.py +8 -0
  9. simple_module_cli-0.0.4/simple_module_cli/package_update.py +287 -0
  10. simple_module_cli-0.0.4/simple_module_cli/skills/README.md +58 -0
  11. simple_module_cli-0.0.4/simple_module_cli/skills/simple-module-cli/SKILL.md +170 -0
  12. simple_module_cli-0.0.4/simple_module_cli/skills/simple-module-conventions/SKILL.md +97 -0
  13. simple_module_cli-0.0.4/simple_module_cli/skills/simple-module-creating/SKILL.md +104 -0
  14. simple_module_cli-0.0.4/simple_module_cli/skills/simple-module-database/SKILL.md +98 -0
  15. simple_module_cli-0.0.4/simple_module_cli/skills/simple-module-doctor/SKILL.md +41 -0
  16. simple_module_cli-0.0.4/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +93 -0
  17. simple_module_cli-0.0.4/simple_module_cli/skills/simple-module-locales/SKILL.md +125 -0
  18. simple_module_cli-0.0.4/simple_module_cli/skills/simple-module-migrations/SKILL.md +103 -0
  19. simple_module_cli-0.0.4/simple_module_cli/skills/simple-module-registries/SKILL.md +144 -0
  20. simple_module_cli-0.0.4/simple_module_cli/skills/simple-module-testing/SKILL.md +102 -0
  21. simple_module_cli-0.0.4/simple_module_cli/skills_cmd.py +249 -0
  22. simple_module_cli-0.0.4/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +26 -0
  23. simple_module_cli-0.0.4/simple_module_cli/templates/host/client_app/app.tsx +37 -0
  24. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/client_app/package.json.tpl +4 -0
  25. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/client_app/pages.ts +3 -3
  26. simple_module_cli-0.0.4/simple_module_cli/templates/host/client_app/styles.css +12 -0
  27. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/client_app/vite.config.ts +22 -1
  28. simple_module_cli-0.0.4/tests/test_build_packaging.py +87 -0
  29. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/tests/test_cli_catalog.py +3 -10
  30. simple_module_cli-0.0.4/tests/test_cli_package_update.py +173 -0
  31. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/tests/test_cli_wizard.py +2 -3
  32. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/tests/test_scaffolding_host.py +10 -10
  33. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/tests/test_scaffolding_module.py +5 -1
  34. simple_module_cli-0.0.4/tests/test_skills_cmd.py +254 -0
  35. simple_module_cli-0.0.2/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -17
  36. simple_module_cli-0.0.2/simple_module_cli/templates/host/client_app/app.tsx +0 -16
  37. simple_module_cli-0.0.2/simple_module_cli/templates/host/client_app/styles.css +0 -7
  38. simple_module_cli-0.0.2/simple_module_cli/templates/module/tests/__init__.py +0 -0
  39. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/LICENSE +0 -0
  40. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/README.md +0 -0
  41. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/__init__.py +0 -0
  42. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/_env.py +0 -0
  43. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/plugins.py +0 -0
  44. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/recipes.py +0 -0
  45. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/scaffolding.py +0 -0
  46. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/.env.example +0 -0
  47. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/.gitignore +0 -0
  48. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/Makefile +0 -0
  49. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/README.md.tpl +0 -0
  50. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  51. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +0 -0
  52. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +0 -0
  53. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/alembic.ini +0 -0
  54. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  55. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  56. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  57. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/main.py +0 -0
  58. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/migrations/env.py +0 -0
  59. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  60. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  61. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
  62. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/host/templates/index.html +0 -0
  63. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  64. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  65. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/.gitignore +0 -0
  66. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/README.md.tpl +0 -0
  67. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  68. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  69. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  70. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -0
  71. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +0 -0
  72. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  73. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/package.json.tpl +0 -0
  74. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  75. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
  76. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  77. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/simple_module_cli/wizard.py +0 -0
  78. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/tests/test_cli_new.py +0 -0
  79. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/tests/test_cli_recipes.py +0 -0
  80. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/tests/test_no_framework_deps.py +0 -0
  81. {simple_module_cli-0.0.2 → simple_module_cli-0.0.4}/tests/test_plugin_discovery.py +0 -0
@@ -36,6 +36,10 @@ uploads/
36
36
  # Vite
37
37
  host/static/dist/
38
38
 
39
+ # VitePress (docs)
40
+ docs/.vitepress/cache/
41
+ docs/.vitepress/dist/
42
+
39
43
  # Auto-generated frontend module manifest (regenerated by the host at boot
40
44
  # or via `make gen-pages`).
41
45
  host/client_app/modules.manifest.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_cli
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Standalone scaffolder for the SimpleModule framework — `sm new`, `sm 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.2"
3
+ version = "0.0.4"
4
4
  description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -38,6 +38,13 @@ requires = ["hatchling"]
38
38
  build-backend = "hatchling.build"
39
39
 
40
40
  [tool.hatch.build.targets.wheel]
41
+ # The canonical ``skills/`` lives at the repo root (so `npx skills add` from
42
+ # vercel-labs/skills picks it up) and is symlinked into
43
+ # ``simple_module_cli/skills`` for editable installs and packaging.
44
+ # Hatchling follows that symlink during sdist + wheel builds, so the real
45
+ # files land inside the package — no force-include needed (and force-include
46
+ # with ``../../skills`` breaks the standard sdist→wheel rebuild because the
47
+ # unpacked sdist has no parent ``skills/``).
41
48
  packages = ["simple_module_cli"]
42
49
 
43
50
  [tool.hatch.build.targets.wheel.shared-data]
@@ -15,6 +15,8 @@ from __future__ import annotations
15
15
  import json as _json
16
16
  import secrets as _secrets
17
17
  from collections.abc import Sequence
18
+ from importlib.metadata import PackageNotFoundError
19
+ from importlib.metadata import version as _pkg_version
18
20
  from pathlib import Path
19
21
 
20
22
  from simple_module_cli._env import set_env_key
@@ -25,7 +27,23 @@ from simple_module_cli.scaffolding import create_host
25
27
 
26
28
  __all__ = ["create_app_project"]
27
29
 
28
- _FRAMEWORK_VERSION = "0.0.1"
30
+
31
+ def _resolve_framework_version() -> str:
32
+ """Resolve the framework version to pin scaffolded apps against.
33
+
34
+ The CLI ships in lockstep with the rest of the framework (one
35
+ ``bump_version.py`` rewrites every ``pyproject.toml`` in the repo), so
36
+ its own installed version is the source of truth. Falling back to a
37
+ placeholder lets editable installs without dist-info still scaffold —
38
+ but that path should never be reached in a release wheel.
39
+ """
40
+ try:
41
+ return _pkg_version("simple_module_cli")
42
+ except PackageNotFoundError:
43
+ return "0.0.0"
44
+
45
+
46
+ _FRAMEWORK_VERSION = _resolve_framework_version()
29
47
 
30
48
  _APP_PY_DEV_DEPS = [f"simple_module_test=={_FRAMEWORK_VERSION}", "pytest>=8.0"]
31
49
 
@@ -13,9 +13,17 @@ __all__ = ["to_kebab_case", "to_pascal_case", "to_snake_case"]
13
13
 
14
14
 
15
15
  def to_snake_case(name: str) -> str:
16
- """'MyFeature' / 'my-feature' / 'My Feature' -> 'my_feature'."""
17
- s = re.sub(r"(?<!^)(?=[A-Z])", "_", name)
18
- s = re.sub(r"[\s\-]+", "_", s)
16
+ """'MyFeature' / 'my-feature' / 'My Feature' / 'URLPath' -> 'my_feature' / 'url_path'.
17
+
18
+ Handles acronyms by treating ``Acronym|Word`` and ``word|Capital`` as
19
+ boundaries: ``URLPath`` -> ``url_path``, ``APIClient`` -> ``api_client``,
20
+ ``HTTPServer2`` -> ``http_server2``. The single-pass ``(?=[A-Z])`` form
21
+ that preceded this would emit ``u_r_l_path`` and propagate the typo
22
+ into the PyPI slug + display name.
23
+ """
24
+ s = re.sub(r"[\s\-]+", "_", name)
25
+ s = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", s)
26
+ s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
19
27
  return s.lower()
20
28
 
21
29
 
@@ -36,12 +36,11 @@ CATALOG: dict[str, ModuleEntry] = {
36
36
  "Permissions",
37
37
  requires=("auth", "users"),
38
38
  ),
39
- "products": ModuleEntry("products", "simple_module_products", "Products"),
40
39
  "dashboard": ModuleEntry(
41
40
  "dashboard",
42
41
  "simple_module_dashboard",
43
42
  "Dashboard",
44
- requires=("users", "products"),
43
+ requires=("users",),
45
44
  ),
46
45
  "settings": ModuleEntry("settings", "simple_module_settings", "Settings"),
47
46
  "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"),
@@ -58,12 +57,6 @@ CATALOG: dict[str, ModuleEntry] = {
58
57
  requires=("users",),
59
58
  recipe="background_tasks",
60
59
  ),
61
- "datasets": ModuleEntry(
62
- "datasets",
63
- "simple_module_datasets",
64
- "Datasets",
65
- requires=("file_storage", "background_tasks"),
66
- ),
67
60
  }
68
61
 
69
62
 
@@ -4,6 +4,7 @@ Built-in commands:
4
4
  sm new
5
5
  sm create-host
6
6
  sm create-module
7
+ sm skills add / list / update
7
8
 
8
9
  Plugins discovered via the ``simple_module_cli.cli_plugins`` entry-point
9
10
  group are mounted as named subgroups (e.g. ``sm host gen-pages``).
@@ -18,9 +19,11 @@ import typer
18
19
 
19
20
  from simple_module_cli.case import to_kebab_case
20
21
  from simple_module_cli.new import new_project
22
+ from simple_module_cli.package_update import package_update
21
23
  from simple_module_cli.plugins import discover_and_mount
22
24
  from simple_module_cli.scaffolding import create_host as _create_host
23
25
  from simple_module_cli.scaffolding import create_module as _create_module
26
+ from simple_module_cli.skills_cmd import app as skills_app
24
27
 
25
28
  app = typer.Typer(
26
29
  help="SimpleModule developer CLI.",
@@ -29,6 +32,8 @@ app = typer.Typer(
29
32
  )
30
33
 
31
34
  app.command("new")(new_project)
35
+ app.command("package-update")(package_update)
36
+ app.add_typer(skills_app, name="skills")
32
37
 
33
38
 
34
39
  @app.command("create-host")
@@ -42,7 +47,7 @@ def create_host(
42
47
  str,
43
48
  typer.Option(
44
49
  "--with",
45
- help="Comma-separated module names to declare as deps (e.g. Auth,Products).",
50
+ help="Comma-separated module names to declare as deps (e.g. Auth,Dashboard).",
46
51
  ),
47
52
  ] = "",
48
53
  ) -> None:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import shutil
5
6
  import subprocess
6
7
  from enum import StrEnum
7
8
  from pathlib import Path
@@ -109,6 +110,13 @@ def new_project(
109
110
 
110
111
  typer.echo("Installing dependencies...")
111
112
  for cmd in (["uv", "sync"], ["npm", "install"]):
113
+ if shutil.which(cmd[0]) is None:
114
+ typer.echo(
115
+ f"WARNING: '{cmd[0]}' not found on PATH; skipping `{' '.join(cmd)}`. "
116
+ "Install it and finish setup manually.",
117
+ err=True,
118
+ )
119
+ return
112
120
  result = subprocess.run(cmd, cwd=target, check=False)
113
121
  if result.returncode != 0:
114
122
  typer.echo(
@@ -0,0 +1,287 @@
1
+ """``sm package-update`` — bump simple_module_* deps to latest PyPI versions.
2
+
3
+ Walks the project's ``pyproject.toml`` (and any ``[tool.uv.workspace]`` members),
4
+ finds every dependency whose distribution name starts with ``simple_module_`` /
5
+ ``simple-module-``, queries PyPI for the latest non-yanked release, and rewrites
6
+ the constraint to ``name>=<latest>``.
7
+
8
+ Dependencies whose ``[tool.uv.sources]`` entry points at a workspace member, a
9
+ local path, a git ref, or a URL are left untouched — those aren't installed
10
+ from PyPI.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import urllib.error
18
+ import urllib.request
19
+ from collections.abc import Callable
20
+ from concurrent.futures import ThreadPoolExecutor
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Annotated, Any
24
+
25
+ import tomlkit
26
+ import typer
27
+ from tomlkit.items import Array, Table
28
+
29
+ __all__ = ["package_update", "run_update"]
30
+
31
+ Fetcher = Callable[[str], dict[str, Any]]
32
+
33
+ _PYPI_URL = "https://pypi.org/pypi/{name}/json"
34
+ _SM_PREFIX_RE = re.compile(r"^simple[_-]module[_-]", re.IGNORECASE)
35
+ # PEP 440 release segments contain only digits + dots; any letter signals
36
+ # a pre/post/dev release (a, b, rc, post, dev). Coarser than packaging.version
37
+ # but `packaging` isn't a CLI dep (see test_no_framework_deps.py).
38
+ _PRE_RELEASE_RE = re.compile(r"[a-zA-Z]")
39
+ _REQ_OPS = ("===", "==", ">=", "<=", "!=", "~=", ">", "<")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Change:
44
+ file: Path
45
+ package: str
46
+ old: str
47
+ new: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Skip:
52
+ file: Path
53
+ package: str
54
+ reason: str
55
+
56
+
57
+ def _is_sm_package(name: str) -> bool:
58
+ return bool(_SM_PREFIX_RE.match(name))
59
+
60
+
61
+ def _dep_name(spec: str) -> str | None:
62
+ """Extract the distribution name from a PEP 508 requirement string."""
63
+ base = spec.split(";", 1)[0].strip()
64
+ base = base.split("[", 1)[0]
65
+ for op in _REQ_OPS:
66
+ if op in base:
67
+ base = base.split(op, 1)[0]
68
+ break
69
+ name = base.strip()
70
+ return name or None
71
+
72
+
73
+ def _is_local_source(source: Any) -> bool:
74
+ if not isinstance(source, dict):
75
+ return False
76
+ if source.get("workspace") is True:
77
+ return True
78
+ return any(key in source for key in ("path", "git", "url"))
79
+
80
+
81
+ def _get_uv_section(doc: tomlkit.TOMLDocument, key: str) -> dict[str, Any] | None:
82
+ tool = doc.get("tool")
83
+ if not isinstance(tool, dict):
84
+ return None
85
+ uv = tool.get("uv")
86
+ if not isinstance(uv, dict):
87
+ return None
88
+ section = uv.get(key)
89
+ return section if isinstance(section, dict) else None
90
+
91
+
92
+ def _fetch_latest(name: str, *, include_pre: bool, fetcher: Fetcher) -> str | None:
93
+ try:
94
+ data = fetcher(_PYPI_URL.format(name=name))
95
+ except (urllib.error.HTTPError, urllib.error.URLError):
96
+ return None
97
+ releases = data.get("releases") or {}
98
+ candidates: list[str] = []
99
+ for version, files in releases.items():
100
+ if not files:
101
+ continue
102
+ if any(f.get("yanked") for f in files):
103
+ continue
104
+ if not include_pre and _PRE_RELEASE_RE.search(version):
105
+ continue
106
+ candidates.append(version)
107
+ if candidates:
108
+ return max(candidates, key=_version_key)
109
+ info = data.get("info") or {}
110
+ return info.get("version")
111
+
112
+
113
+ def _version_key(v: str) -> tuple[int, ...]:
114
+ parts: list[int] = []
115
+ for part in v.split("."):
116
+ digits = re.match(r"\d+", part)
117
+ parts.append(int(digits.group()) if digits else 0)
118
+ return tuple(parts)
119
+
120
+
121
+ def _default_fetcher(url: str) -> dict[str, Any]:
122
+ with urllib.request.urlopen(url, timeout=10) as resp:
123
+ return json.loads(resp.read().decode("utf-8"))
124
+
125
+
126
+ def _workspace_member_dirs(root_pyproject: Path, doc: tomlkit.TOMLDocument) -> list[Path]:
127
+ workspace = _get_uv_section(doc, "workspace")
128
+ if workspace is None:
129
+ return []
130
+ members = workspace.get("members") or []
131
+ base = root_pyproject.parent
132
+ out: list[Path] = []
133
+ for pattern in members:
134
+ for match in sorted(base.glob(str(pattern))):
135
+ if (match / "pyproject.toml").is_file():
136
+ out.append(match / "pyproject.toml")
137
+ return out
138
+
139
+
140
+ def _local_sources(doc: tomlkit.TOMLDocument) -> set[str]:
141
+ sources = _get_uv_section(doc, "sources")
142
+ if sources is None:
143
+ return set()
144
+ return {name for name, src in sources.items() if _is_local_source(src)}
145
+
146
+
147
+ def _collect_sm_deps(doc: tomlkit.TOMLDocument) -> list[str]:
148
+ """Return distribution names of simple_module_* deps in this doc that aren't local-sourced."""
149
+ project = doc.get("project")
150
+ if not isinstance(project, (dict, Table)):
151
+ return []
152
+ deps = project.get("dependencies")
153
+ if not isinstance(deps, (list, Array)):
154
+ return []
155
+ local = _local_sources(doc)
156
+ out: list[str] = []
157
+ for raw in deps:
158
+ name = _dep_name(str(raw))
159
+ if name and _is_sm_package(name) and name not in local:
160
+ out.append(name)
161
+ return out
162
+
163
+
164
+ def _process_file(
165
+ path: Path,
166
+ doc: tomlkit.TOMLDocument,
167
+ *,
168
+ cache: dict[str, str | None],
169
+ ) -> tuple[list[Change], list[Skip], tomlkit.TOMLDocument | None]:
170
+ project = doc.get("project")
171
+ if not isinstance(project, (dict, Table)):
172
+ return [], [], None
173
+ deps = project.get("dependencies")
174
+ if not isinstance(deps, (list, Array)):
175
+ return [], [], None
176
+
177
+ local = _local_sources(doc)
178
+ changes: list[Change] = []
179
+ skips: list[Skip] = []
180
+
181
+ for idx, raw in enumerate(deps):
182
+ dep_str = str(raw)
183
+ name = _dep_name(dep_str)
184
+ if not name or not _is_sm_package(name):
185
+ continue
186
+ if name in local:
187
+ skips.append(Skip(path, name, "workspace/local source"))
188
+ continue
189
+ latest = cache.get(name)
190
+ if latest is None:
191
+ skips.append(Skip(path, name, "not found on PyPI"))
192
+ continue
193
+ new_dep = f"{name}>={latest}"
194
+ if new_dep == dep_str.strip():
195
+ continue
196
+ deps[idx] = new_dep
197
+ changes.append(Change(path, name, dep_str.strip(), new_dep))
198
+
199
+ return changes, skips, doc if changes else None
200
+
201
+
202
+ def _print_summary(changes: list[Change], skips: list[Skip], dry_run: bool) -> None:
203
+ if not changes and not skips:
204
+ typer.echo("No simple_module_* dependencies found.")
205
+ return
206
+ by_file: dict[Path, list[str]] = {}
207
+ for c in changes:
208
+ by_file.setdefault(c.file, []).append(f" {c.package}: {c.old} → {c.new}")
209
+ for s in skips:
210
+ by_file.setdefault(s.file, []).append(f" {s.package}: skipped ({s.reason})")
211
+ for file, lines in by_file.items():
212
+ typer.echo(f"\n{file}")
213
+ for line in lines:
214
+ typer.echo(line)
215
+ if changes:
216
+ verb = "Would update" if dry_run else "Updated"
217
+ typer.echo(f"\n{verb} {len(changes)} dependency(ies) across {len(by_file)} file(s).")
218
+ if not dry_run:
219
+ typer.echo("Run `uv sync` to apply.")
220
+ elif skips:
221
+ typer.echo("\nNo updates applied.")
222
+
223
+
224
+ def run_update(
225
+ path: Path,
226
+ *,
227
+ dry_run: bool = False,
228
+ include_pre: bool = False,
229
+ fetcher: Fetcher | None = None,
230
+ ) -> None:
231
+ """Programmatic entry point — separated from the Typer command for testing."""
232
+ root = path if path.name == "pyproject.toml" else path / "pyproject.toml"
233
+ if not root.is_file():
234
+ typer.echo(f"ERROR: {root} not found.", err=True)
235
+ raise typer.Exit(code=1)
236
+
237
+ fetch = fetcher or _default_fetcher
238
+ root_doc = tomlkit.parse(root.read_text(encoding="utf-8"))
239
+ files: list[tuple[Path, tomlkit.TOMLDocument]] = [(root, root_doc)]
240
+ for member in _workspace_member_dirs(root, root_doc):
241
+ files.append((member, tomlkit.parse(member.read_text(encoding="utf-8"))))
242
+
243
+ # Pre-fetch all unique sm packages in parallel — PyPI calls dominate runtime.
244
+ unique_names = sorted({n for _, doc in files for n in _collect_sm_deps(doc)})
245
+ cache: dict[str, str | None] = {}
246
+ if unique_names:
247
+ with ThreadPoolExecutor(max_workers=min(8, len(unique_names))) as pool:
248
+ results = pool.map(
249
+ lambda n: (n, _fetch_latest(n, include_pre=include_pre, fetcher=fetch)),
250
+ unique_names,
251
+ )
252
+ cache = dict(results)
253
+
254
+ all_changes: list[Change] = []
255
+ all_skips: list[Skip] = []
256
+ pending: list[tuple[Path, tomlkit.TOMLDocument]] = []
257
+
258
+ for file, doc in files:
259
+ changes, skips, new_doc = _process_file(file, doc, cache=cache)
260
+ all_changes.extend(changes)
261
+ all_skips.extend(skips)
262
+ if new_doc is not None:
263
+ pending.append((file, new_doc))
264
+
265
+ if not dry_run:
266
+ for file, doc in pending:
267
+ file.write_text(tomlkit.dumps(doc), encoding="utf-8")
268
+
269
+ _print_summary(all_changes, all_skips, dry_run)
270
+
271
+
272
+ def package_update(
273
+ path: Annotated[
274
+ Path,
275
+ typer.Option("--path", help="Project root or pyproject.toml. Defaults to cwd."),
276
+ ] = Path(),
277
+ dry_run: Annotated[
278
+ bool,
279
+ typer.Option("--dry-run", help="Show planned changes without writing."),
280
+ ] = False,
281
+ include_pre: Annotated[
282
+ bool,
283
+ typer.Option("--include-pre", help="Include pre-release versions."),
284
+ ] = False,
285
+ ) -> None:
286
+ """Update all simple_module_* dependencies to the latest PyPI versions."""
287
+ run_update(path, dry_run=dry_run, include_pre=include_pre)
@@ -0,0 +1,58 @@
1
+ # simple_module_python skills
2
+
3
+ Agent skills for working in a [simple_module_python](https://github.com/antosubash/simple_module_python) codebase. Compatible with Claude Code, Codex, Cursor, Windsurf, OpenCode, and any other agent that supports the [Agent Skills format](https://agentskills.io/specification).
4
+
5
+ ## Install
6
+
7
+ There are two install paths — pick whichever fits your project.
8
+
9
+ ### Option A — `sm skills` (recommended for `simple_module_cli` users)
10
+
11
+ Every project produced by `sm new` already depends on `simple_module_cli`, which ships these skills inside its wheel. From the project root:
12
+
13
+ ```bash
14
+ sm skills list # see what's available
15
+ sm skills add # install ALL skills into ./.claude/skills/
16
+ sm skills add simple-module-creating # install just one
17
+ sm skills add -g # install into ~/.claude/skills (machine-wide)
18
+ sm skills add --dest agents/skills # custom target dir
19
+ sm skills add --symlink # symlink to the bundled source (good for skill devs)
20
+ sm skills update # re-pull updates for skills already installed
21
+ sm skills update simple-module-doctor # explicit re-pull (force-overwrites)
22
+ ```
23
+
24
+ `sm skills` resolves the bundled set against whatever version of `simple_module_cli` is installed, so upgrading the CLI ships skill updates the next time you run `sm skills update`.
25
+
26
+ ### Option B — `npx skills` (no Python install needed)
27
+
28
+ ```bash
29
+ npx skills add antosubash/simple_module_python # all skills, current project
30
+ npx skills add antosubash/simple_module_python -g # globally
31
+ npx skills add antosubash/simple_module_python --skill simple-module-creating -a claude-code
32
+ npx skills add antosubash/simple_module_python --list
33
+ ```
34
+
35
+ The CLI is [vercel-labs/skills](https://github.com/vercel-labs/skills); see its README for symlink-vs-copy and other options.
36
+
37
+ ## What's here
38
+
39
+ | Skill | Use when |
40
+ |---|---|
41
+ | [simple-module-cli](./simple-module-cli/SKILL.md) | Invoking the `sm` CLI — `sm new`, `sm create-host`, `sm create-module`, `sm host gen-pages`, `sm users create-admin`, etc. |
42
+ | [simple-module-creating](./simple-module-creating/SKILL.md) | Adding a new feature package — scaffolding, entry-point, `ModuleMeta` |
43
+ | [simple-module-conventions](./simple-module-conventions/SKILL.md) | Writing or reviewing module code — the invariant list (SQLModel everywhere, settings layout, framework→plugin direction, etc.) |
44
+ | [simple-module-database](./simple-module-database/SKILL.md) | Adding SQLModel tables, picking a mixin, or debugging session/transaction behavior |
45
+ | [simple-module-migrations](./simple-module-migrations/SKILL.md) | Generating, applying, or reviewing Alembic migrations after installing or changing a module |
46
+ | [simple-module-inertia-pages](./simple-module-inertia-pages/SKILL.md) | Adding or debugging an Inertia page in a module — render keys, shared props, common pitfalls |
47
+ | [simple-module-locales](./simple-module-locales/SKILL.md) | Adding or debugging i18n in a module — `locale_dirs()`, namespaces, CLDR plurals, the Zod-in-hook rule |
48
+ | [simple-module-registries](./simple-module-registries/SKILL.md) | Contributing menu items, permissions, feature flags, or events from a module |
49
+ | [simple-module-testing](./simple-module-testing/SKILL.md) | Writing pytest tests — picking the right fixture (`db_session` / `app` / `authenticated_client`), single-test runs, e2e |
50
+ | [simple-module-doctor](./simple-module-doctor/SKILL.md) | Interpreting a diagnostic code (`SM001`–`SM018`) printed at boot |
51
+
52
+ The skills are designed to stand alone — install them into any host or module-package project and they'll work without needing access to the framework's source repo.
53
+
54
+ ## Contributing
55
+
56
+ Each skill is a directory containing a `SKILL.md` with YAML frontmatter (`name`, `description`). The description is "use when…" triggers only — never a workflow summary, because agents will follow the description in lieu of reading the body.
57
+
58
+ PRs welcome.