Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ All notable changes to this project are documented in this file. The format is b
distinguishing an unconfigured worker from single-tenant operation (#685).

### Fixed
- **Behaviour change:** bare UI borders now use the theme border colour in light
and dark themes, including pseudo-elements. Use `border-current` to retain the
previous foreground-coloured look; explicit border colour utilities still win (#714).
- `smpy package-update` now includes Python dependency groups and optional extras,
plus root/member and client-app `@simple-module-py/*` pins. npm updates follow
the selected core release, preserve pin style and skip unavailable releases;
all manifest changes are planned before writing, including dry runs (#715).
- Deprecate `auth.deps.require_permission` for removal in 0.1.0; recommend
`RequiresPermission` and enforce the removal release in the shim ledger (#698).
- **Behaviour change:** gate Files and Branding navigation by their page
Expand Down
23 changes: 21 additions & 2 deletions docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,26 @@ When new releases of `simple_module_*` ship to PyPI, bump every dep in lockstep:
smpy package-update
```

Pass `--dry-run` first to preview the diff.
Pass `--dry-run` first to preview the diff. The command scans the root and uv
workspace members: project dependencies, every optional-dependency extra and
every dependency group, including workspace roots with only dev groups.
Include-group objects and workspace/local uv sources are left alone; source
names are normalized and member sources override root sources.

It also scans each root/member `package.json` and `client_app/package.json`, in
`dependencies`, `devDependencies`, `optionalDependencies` and `peerDependencies`.
`@simple-module-py/*` pins follow the selected `simple_module_core` PyPI version
in lockstep, preserving exact, `^` or `~` style and never downgrading. The npm
registry must contain that exact version; unavailable releases, incompatible
version spellings, unsupported ranges and workspace/file/git/alias references
are reported as skips, and so is every npm pin when the run leaves the Python
`simple_module_core` pin alone (an upper bound or a local source), so npm never
runs ahead of Python. `--include-pre` also applies to core target selection.

All manifests are parsed and changes planned before any writes. Dry runs write
nothing; JSON indentation and trailing-newline style are preserved. Writes to
multiple files are not atomic against filesystem errors. Lockfiles are left
alone: run the reported `uv sync` and `npm install` commands afterwards.

Each constraint keeps the operator you wrote — `==0.0.32` becomes `==0.0.33`,
`>=0.0.32` becomes `>=0.0.33` — so bumping versions never changes your pinning
Expand All @@ -157,7 +176,7 @@ Two consequences worth knowing:
- A dependency with no constraint at all has no pin style to preserve, so it
gets `>=<latest>`.

Pass `--loosen` to rewrite every constraint to `>=<latest>` instead.
Pass `--loosen` to rewrite every Python constraint to `>=<latest>` instead.

## Troubleshooting

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/make-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Both cache the scratch host under `.smpy/verify-host`; pass `--fresh` to rebuild

| Command | What |
|---|---|
| `smpy package-update` | Bump every `simple_module_*` dependency in `pyproject.toml` to the latest PyPI version, keeping each constraint's operator (`==` stays `==`). `--dry-run` previews the diff; `--loosen` rewrites everything to `>=`. |
| `smpy package-update` | Bump `simple_module_*` dependencies, optional extras and dependency groups across the uv workspace, keeping operators. Update root/member and `client_app/package.json` `@simple-module-py/*` pins to the selected core version when published on npm. `--dry-run` writes nothing; `--loosen` rewrites Python constraints to `>=`. |
| `smpy skills add\|list\|update` | Install or update the bundled agent skills under `.claude/skills/` for use with Claude Code. |

### Module-contributed plugins
Expand Down
209 changes: 68 additions & 141 deletions framework/cli/simple_module_cli/package_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Walks the project's ``pyproject.toml`` (and any ``[tool.uv.workspace]`` members),
finds every dependency whose distribution name starts with ``simple_module_`` /
``simple-module-``, queries PyPI for the latest non-yanked release, and points
the constraint at it.
the constraint at it. Includes optional dependencies and dependency groups.
Matching npm packages use the selected core release when published on npm.

The rewrite preserves the pin style you wrote: ``==0.0.32`` becomes
``==0.0.33``, ``>=0.0.32`` becomes ``>=0.0.33``, and an upper bound that
Expand All @@ -18,150 +19,48 @@

from __future__ import annotations

import re
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Any
from typing import Annotated

import tomlkit
import typer
from tomlkit.items import Array, Table

from simple_module_cli.package_update_deps import (
Change,
Skip,
collect_sm_deps,
local_sources,
normalize,
process_file,
source_map,
workspace_member_dirs,
)
from simple_module_cli.package_update_npm import load_manifests, plan_npm
from simple_module_cli.pypi import Fetcher, default_fetcher, fetch_latest
from simple_module_cli.requirements import parse_requirement, rewrite_requirement

__all__ = ["package_update", "run_update"]

_SM_PREFIX_RE = re.compile(r"^simple[_-]module[_-]", re.IGNORECASE)

_CORE = "simple-module-core"

@dataclass(frozen=True)
class Change:
file: Path
package: str
old: str
new: str

def _npm_target(cache: dict[str, str | None], skips: list[Skip]) -> tuple[str | None, str | None]:
"""The core version npm pins follow, or why there is none.

@dataclass(frozen=True)
class Skip:
file: Path
package: str
reason: str


def _is_sm_package(name: str) -> bool:
return bool(_SM_PREFIX_RE.match(name))


def _dep_name(spec: str) -> str | None:
"""Extract the distribution name from a PEP 508 requirement string."""
parsed = parse_requirement(spec)
return parsed.name if parsed else None


def _is_local_source(source: Any) -> bool:
if not isinstance(source, dict):
return False
if source.get("workspace") is True:
return True
return any(key in source for key in ("path", "git", "url"))


def _get_uv_section(doc: tomlkit.TOMLDocument, key: str) -> dict[str, Any] | None:
tool = doc.get("tool")
if not isinstance(tool, dict):
return None
uv = tool.get("uv")
if not isinstance(uv, dict):
return None
section = uv.get(key)
return section if isinstance(section, dict) else None


def _workspace_member_dirs(root_pyproject: Path, doc: tomlkit.TOMLDocument) -> list[Path]:
workspace = _get_uv_section(doc, "workspace")
if workspace is None:
return []
members = workspace.get("members") or []
base = root_pyproject.parent
out: list[Path] = []
for pattern in members:
for match in sorted(base.glob(str(pattern))):
if (match / "pyproject.toml").is_file():
out.append(match / "pyproject.toml")
return out


def _local_sources(doc: tomlkit.TOMLDocument) -> set[str]:
sources = _get_uv_section(doc, "sources")
if sources is None:
return set()
return {name for name, src in sources.items() if _is_local_source(src)}


def _collect_sm_deps(doc: tomlkit.TOMLDocument) -> list[str]:
"""Return distribution names of simple_module_* deps in this doc that aren't local-sourced."""
project = doc.get("project")
if not isinstance(project, (dict, Table)):
return []
deps = project.get("dependencies")
if not isinstance(deps, (list, Array)):
return []
local = _local_sources(doc)
out: list[str] = []
for raw in deps:
name = _dep_name(str(raw))
if name and _is_sm_package(name) and name not in local:
out.append(name)
return out


def _process_file(
path: Path,
doc: tomlkit.TOMLDocument,
*,
cache: dict[str, str | None],
loosen: bool = False,
) -> tuple[list[Change], list[Skip], tomlkit.TOMLDocument | None]:
project = doc.get("project")
if not isinstance(project, (dict, Table)):
return [], [], None
deps = project.get("dependencies")
if not isinstance(deps, (list, Array)):
return [], [], None

local = _local_sources(doc)
changes: list[Change] = []
skips: list[Skip] = []

for idx, raw in enumerate(deps):
dep_str = str(raw)
name = _dep_name(dep_str)
if not name or not _is_sm_package(name):
continue
if name in local:
skips.append(Skip(path, name, "workspace/local source"))
continue
latest = cache.get(name)
if latest is None:
skips.append(Skip(path, name, "not found on PyPI"))
continue
result = rewrite_requirement(dep_str, latest, loosen=loosen)
if result.spec is None:
if result.reason:
skips.append(Skip(path, name, result.reason))
continue
deps[idx] = result.spec
changes.append(Change(path, name, dep_str.strip(), result.spec))

return changes, skips, doc if changes else None
npm pins move in lockstep with the Python core pin, so when this run left
that pin alone (an upper bound, a local source) the npm pins stay too.
"""
for skip in skips:
if normalize(skip.package) == _CORE:
return None, f"simple_module_core not updated ({skip.reason})"
latest = next((v for n, v in cache.items() if normalize(n) == _CORE), None)
return latest, None if latest else "simple_module_core not found on PyPI"


def _print_summary(changes: list[Change], skips: list[Skip], dry_run: bool) -> None:
if not changes and not skips:
typer.echo("No simple_module_* dependencies found.")
typer.echo("No simple_module_* or @simple-module-py/* dependencies found.")
return
by_file: dict[Path, list[str]] = {}
for c in changes:
Expand All @@ -174,9 +73,15 @@ def _print_summary(changes: list[Change], skips: list[Skip], dry_run: bool) -> N
typer.echo(line)
if changes:
verb = "Would update" if dry_run else "Updated"
typer.echo(f"\n{verb} {len(changes)} dependency(ies) across {len(by_file)} file(s).")
file_count = len({c.file for c in changes})
typer.echo(f"\n{verb} {len(changes)} dependency(ies) across {file_count} file(s).")
if not dry_run:
typer.echo("Run `uv sync` to apply.")
if any(c.file.suffix == ".toml" for c in changes):
typer.echo("Run `uv sync` to apply Python changes.")
for directory in sorted(
{c.file.parent for c in changes if c.file.name == "package.json"}
):
typer.echo(f"Run `npm install` in {directory} to apply npm changes.")
elif skips:
typer.echo("\nNo updates applied.")

Expand All @@ -190,19 +95,31 @@ def run_update(
fetcher: Fetcher | None = None,
) -> None:
"""Programmatic entry point — separated from the Typer command for testing."""
path = path.resolve()
root = path if path.name == "pyproject.toml" else path / "pyproject.toml"
if not root.is_file():
typer.echo(f"ERROR: {root} not found.", err=True)
raise typer.Exit(code=1)

fetch = fetcher or default_fetcher
root_doc = tomlkit.parse(root.read_text(encoding="utf-8"))
root_doc = tomlkit.parse(root.read_bytes().decode("utf-8"))
files: list[tuple[Path, tomlkit.TOMLDocument]] = [(root, root_doc)]
for member in _workspace_member_dirs(root, root_doc):
files.append((member, tomlkit.parse(member.read_text(encoding="utf-8"))))
for member in workspace_member_dirs(root, root_doc):
if member == root:
continue
files.append((member, tomlkit.parse(member.read_bytes().decode("utf-8"))))

npm_manifests = load_manifests([file.parent for file, _ in files])
inherited = source_map(root_doc)
locals_by_file = {file: local_sources(doc, inherited) for file, doc in files}

# Pre-fetch all unique sm packages in parallel — PyPI calls dominate runtime.
unique_names = sorted({n for _, doc in files for n in _collect_sm_deps(doc)})
names = {n for file, doc in files for n in collect_sm_deps(doc, locals_by_file[file])}
if any(manifest.packages for manifest in npm_manifests) and not any(
normalize(n) == _CORE for n in names
):
names.add("simple_module_core")
unique_names = sorted(names)
cache: dict[str, str | None] = {}
if unique_names:
with ThreadPoolExecutor(max_workers=min(8, len(unique_names))) as pool:
Expand All @@ -214,18 +131,27 @@ def run_update(

all_changes: list[Change] = []
all_skips: list[Skip] = []
pending: list[tuple[Path, tomlkit.TOMLDocument]] = []
pending: list[tuple[Path, str]] = []

for file, doc in files:
changes, skips, new_doc = _process_file(file, doc, cache=cache, loosen=loosen)
changes, skips, new_doc = process_file(
file, doc, cache=cache, loosen=loosen, local=locals_by_file[file]
)
all_changes.extend(changes)
all_skips.extend(skips)
if new_doc is not None:
pending.append((file, new_doc))
pending.append((file, tomlkit.dumps(new_doc)))

target, blocked = _npm_target(cache, all_skips)
changes, skips, npm_pending = plan_npm(npm_manifests, target, fetch, blocked=blocked)
all_changes.extend(changes)
all_skips.extend(skips)
pending.extend(npm_pending)

if not dry_run:
for file, doc in pending:
file.write_text(tomlkit.dumps(doc), encoding="utf-8")
for file, content in pending:
# newline="" keeps each file's own line endings (CRLF must not become CRCRLF).
file.write_text(content, encoding="utf-8", newline="")

_print_summary(all_changes, all_skips, dry_run)

Expand All @@ -247,16 +173,17 @@ def package_update(
bool,
typer.Option(
"--loosen",
help="Rewrite every constraint to `>=<latest>` instead of keeping its operator.",
help="Rewrite Python constraints to `>=<latest>` instead of keeping their operator.",
),
] = False,
) -> None:
"""Update all simple_module_* dependencies to the latest PyPI versions.
"""Update simple_module_* dependencies and lockstep @simple-module-py/* pins.

Each constraint keeps the operator it already has — `==0.0.32` becomes
`==0.0.33`, `>=0.0.32` becomes `>=0.0.33` — so bumping versions never
silently changes a project's pinning policy. A dependency whose upper
bound excludes the latest release is reported and left alone. Pass
`--loosen` to rewrite everything to `>=<latest>` instead.
`--loosen` to rewrite Python constraints to `>=<latest>` instead.
npm pins keep exact, caret or tilde style and skip unpublished core versions.
"""
run_update(path, dry_run=dry_run, include_pre=include_pre, loosen=loosen)
Loading
Loading