diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c1b1b33..df5ff91ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/guide/installation.md b/docs/guide/installation.md index f926ba92c..bc224e86e 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -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 @@ -157,7 +176,7 @@ Two consequences worth knowing: - A dependency with no constraint at all has no pin style to preserve, so it gets `>=`. -Pass `--loosen` to rewrite every constraint to `>=` instead. +Pass `--loosen` to rewrite every Python constraint to `>=` instead. ## Troubleshooting diff --git a/docs/reference/make-commands.md b/docs/reference/make-commands.md index d90b00f43..83a63d0a1 100644 --- a/docs/reference/make-commands.md +++ b/docs/reference/make-commands.md @@ -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 diff --git a/framework/cli/simple_module_cli/package_update.py b/framework/cli/simple_module_cli/package_update.py index e553803ca..f3ef0fcf4 100644 --- a/framework/cli/simple_module_cli/package_update.py +++ b/framework/cli/simple_module_cli/package_update.py @@ -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 @@ -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: @@ -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.") @@ -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: @@ -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) @@ -247,16 +173,17 @@ def package_update( bool, typer.Option( "--loosen", - help="Rewrite every constraint to `>=` instead of keeping its operator.", + help="Rewrite Python constraints to `>=` 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 `>=` instead. + `--loosen` to rewrite Python constraints to `>=` 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) diff --git a/framework/cli/simple_module_cli/package_update_deps.py b/framework/cli/simple_module_cli/package_update_deps.py new file mode 100644 index 000000000..9a6f2a12d --- /dev/null +++ b/framework/cli/simple_module_cli/package_update_deps.py @@ -0,0 +1,153 @@ +"""Python manifest discovery and dependency planning for package-update.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import tomlkit +from tomlkit.items import Array + +from simple_module_cli.requirements import parse_requirement, rewrite_requirement + + +@dataclass(frozen=True) +class Change: + file: Path + package: str + old: str + new: str + + +@dataclass(frozen=True) +class Skip: + file: Path + package: str + reason: str + + +def normalize(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def _is_sm_package(name: str) -> bool: + return normalize(name).startswith("simple-module-") + + +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 isinstance(source, (list, Array)): + return any(_is_local_source(item) for item in source) + 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 list(dict.fromkeys(p.resolve() for p in out)) + + +def source_map(doc: tomlkit.TOMLDocument) -> dict[str, Any]: + return {normalize(name): src for name, src in (_get_uv_section(doc, "sources") or {}).items()} + + +def local_sources(doc: tomlkit.TOMLDocument, inherited: dict[str, Any]) -> set[str]: + sources = {**inherited, **source_map(doc)} + return {name for name, source in sources.items() if _is_local_source(source)} + + +def _dependency_arrays(doc: tomlkit.TOMLDocument): + project = doc.get("project", {}) + if isinstance(project, dict): + deps = project.get("dependencies") + if isinstance(deps, (list, Array)): + yield deps + optional = project.get("optional-dependencies", {}) + if isinstance(optional, dict): + for deps in optional.values(): + if isinstance(deps, (list, Array)): + yield deps + groups = doc.get("dependency-groups", {}) + if isinstance(groups, dict): + for deps in groups.values(): + if isinstance(deps, (list, Array)): + yield deps + + +def collect_sm_deps(doc: tomlkit.TOMLDocument, local: set[str]) -> list[str]: + return [ + name + for deps in _dependency_arrays(doc) + for raw in deps + if isinstance(raw, str) + and (name := _dep_name(raw)) + and _is_sm_package(name) + and normalize(name) not in local + ] + + +def process_file( + path: Path, + doc: tomlkit.TOMLDocument, + *, + cache: dict[str, str | None], + loosen: bool = False, + local: set[str], +) -> tuple[list[Change], list[Skip], tomlkit.TOMLDocument | None]: + changes: list[Change] = [] + skips: list[Skip] = [] + + for deps in _dependency_arrays(doc): + for idx, raw in enumerate(deps): + if not isinstance(raw, str): + continue + dep_str = raw + name = _dep_name(dep_str) + if not name or not _is_sm_package(name): + continue + if normalize(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 diff --git a/framework/cli/simple_module_cli/package_update_npm.py b/framework/cli/simple_module_cli/package_update_npm.py new file mode 100644 index 000000000..414d774de --- /dev/null +++ b/framework/cli/simple_module_cli/package_update_npm.py @@ -0,0 +1,126 @@ +"""Plan lockstep npm updates without installing packages or writing files.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import quote + +from simple_module_cli.package_update_deps import Change, Skip +from simple_module_cli.pypi import Fetcher + +_SECTIONS = ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies") +_PREFIX = "@simple-module-py/" +_REGISTRY = "https://registry.npmjs.org/" +_SEMVER = re.compile( + r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" +) + + +def _version_key(version: str) -> tuple | None: + match = _SEMVER.fullmatch(version) + if not match: + return None + major, minor, patch, pre = match.groups() + identifiers = (pre or "").split(".") if pre else [] + if any(part.isdigit() and len(part) > 1 and part.startswith("0") for part in identifiers): + return None + suffix = tuple((0, int(p)) if p.isdigit() else (1, p) for p in identifiers) + return (int(major), int(minor), int(patch), pre is None, suffix) + + +@dataclass +class Manifest: + path: Path + original: str + doc: dict[str, Any] + + @property + def packages(self) -> set[str]: + return { + name + for section in _SECTIONS + for name in self.doc.get(section, {}) + if name.startswith(_PREFIX) + } + + +def load_manifests(directories: list[Path]) -> list[Manifest]: + manifests = [] + paths = dict.fromkeys( + path.resolve() + for directory in directories + for path in (directory / "package.json", directory / "client_app/package.json") + if path.is_file() + ) + for path in paths: + original = path.read_bytes().decode("utf-8") + doc = json.loads(original) + if not isinstance(doc, dict): + raise ValueError(f"{path}: expected a JSON object") + for section in _SECTIONS: + if section in doc and not isinstance(doc[section], dict): + raise ValueError(f"{path}: {section} must be an object") + manifests.append(Manifest(path, original, doc)) + return manifests + + +def _render(manifest: Manifest) -> str: + match = re.search(r"(?:\r?\n)([ \t]+)\S", manifest.original) + indent = match[1] if match else None + newline = "\r\n" if "\r\n" in manifest.original else "\n" + rendered = json.dumps(manifest.doc, indent=indent, ensure_ascii=False) + if newline != "\n": + rendered = rendered.replace("\n", newline) + return rendered + (newline if manifest.original.endswith("\n") else "") + + +def plan_npm( + manifests: list[Manifest], target: str | None, fetcher: Fetcher, *, blocked: str | None = None +) -> tuple[list[Change], list[Skip], list[tuple[Path, str]]]: + changes: list[Change] = [] + skips: list[Skip] = [] + pending: list[tuple[Path, str]] = [] + available: dict[str, bool] = {} + target_key = _version_key(target) if target else None + for manifest in manifests: + changed = False + for section in _SECTIONS: + for name, pin in manifest.doc.get(section, {}).items(): + if not name.startswith(_PREFIX): + continue + style = pin[:1] if isinstance(pin, str) and pin[:1] in ("^", "~") else "" + current = _version_key(pin[len(style) :]) if isinstance(pin, str) else None + reason = None + if current is None: + reason = f"unsupported npm reference {pin!r}" + elif target_key is None: + reason = blocked or f"no npm-compatible simple_module_core target ({target})" + elif current >= target_key: + continue + else: + if name not in available: + try: + data = fetcher(_REGISTRY + quote(name, safe="@")) + available[name] = isinstance(data, dict) and target in ( + data.get("versions") or {} + ) + except (OSError, ValueError): # HTTP/URL/timeout, bad JSON + available[name] = False + if not available[name]: + reason = f"npm release {target} unavailable" + if reason: + skips.append(Skip(manifest.path, name, reason)) + continue + new = style + str(target) + manifest.doc[section][name] = new + changes.append(Change(manifest.path, name, pin, new)) + changed = True + if changed: + pending.append((manifest.path, _render(manifest))) + return changes, skips, pending diff --git a/framework/cli/tests/test_cli_package_update_groups.py b/framework/cli/tests/test_cli_package_update_groups.py new file mode 100644 index 000000000..e62a3df4c --- /dev/null +++ b/framework/cli/tests/test_cli_package_update_groups.py @@ -0,0 +1,74 @@ +"""Dependency-array and workspace regressions for #715.""" + +import pytest +from simple_module_cli.package_update import run_update + + +@pytest.mark.parametrize("dry_run", [False, True]) +def test_group_only_root_and_optional_arrays(tmp_path, fake_pypi, dry_run, capsys): + path = tmp_path / "pyproject.toml" + original = """[dependency-groups] +dev = [ + "simple_module_test[extra]==0.1; python_version >= '3.12'", # keep me + {include-group = "tools"}, +] +tools = ["simple_module_cli==0.1"] +[project.optional-dependencies] +extra = ["simple_module_db>=0.1,<3", "other==1"] +""" + path.write_text(original) + run_update( + tmp_path, + dry_run=dry_run, + fetcher=fake_pypi( + {"simple_module_test": "1.0", "simple_module_cli": "1.0", "simple_module_db": "1.0"} + ), + ) + expected = original if dry_run else original.replace("==0.1", "==1.0").replace(">=0.1", ">=1.0") + assert path.read_text() == expected + assert "3 dependency(ies) across 1 file(s)" in capsys.readouterr().out + + +def test_workspace_sources_override_and_members_are_deduplicated(tmp_path, fake_pypi, capsys): + (tmp_path / "pyproject.toml").write_text( + """[dependency-groups] +dev = ["simple_module_cli==0.1"] +[tool.uv.workspace] +members = ["modules/*", "modules/a"] +[tool.uv.sources] +Simple.Module.Core = {path = "core"} +""".replace("Simple.Module.Core", '"Simple.Module.Core"') + ) + member = tmp_path / "modules/a/pyproject.toml" + member.parent.mkdir(parents=True) + member.write_text("""[dependency-groups] +dev = ["simple_module_core==0.1", "simple_module_test==0.1"] +[tool.uv.sources] +"simple-module-test" = [{path = "test", marker = "sys_platform == 'linux'"}] +""") + run_update( + tmp_path, + fetcher=fake_pypi( + {"simple_module_core": "2.0", "simple_module_cli": "2.0", "simple_module_test": "2.0"} + ), + ) + assert '"simple_module_core==0.1"' in member.read_text() + assert '"simple_module_test==0.1"' in member.read_text() + assert capsys.readouterr().out.count("workspace/local source") == 2 + + +def test_member_index_override_of_root_source(tmp_path, fake_pypi): + (tmp_path / "pyproject.toml").write_text("""[tool.uv.workspace] +members = ["a"] +[tool.uv.sources] +"simple-module-test" = {path = "test"} +""") + member = tmp_path / "a/pyproject.toml" + member.parent.mkdir() + member.write_text("""[dependency-groups] +dev = ["simple_module_test==0.1"] +[tool.uv.sources] +simple_module_test = {index = "pypi"} +""") + run_update(tmp_path, fetcher=fake_pypi({"simple_module_test": "1.0"})) + assert '"simple_module_test==1.0"' in member.read_text() diff --git a/framework/cli/tests/test_cli_package_update_npm.py b/framework/cli/tests/test_cli_package_update_npm.py new file mode 100644 index 000000000..5b7dedd87 --- /dev/null +++ b/framework/cli/tests/test_cli_package_update_npm.py @@ -0,0 +1,220 @@ +"""Offline npm manifest regressions for #715.""" + +import json +from urllib.error import HTTPError +from urllib.parse import unquote + +import pytest +from simple_module_cli.package_update import run_update + + +@pytest.fixture +def registry(): + def make(target="1.2.3", missing=()): + def fetch(url): + if url.startswith("https://pypi.org/"): + return {"releases": {target: [{"yanked": False}]}} + assert url.startswith("https://registry.npmjs.org/") + name = unquote(url.split("registry.npmjs.org/", 1)[1]) + if name in missing: + raise HTTPError(url, 404, "missing", {}, None) + return {"versions": {target: {}}} + + return fetch + + return make + + +def setup_project(root): + path = root / "pyproject.toml" + path.write_text('[project]\ndependencies = ["simple_module_core==0.1.0"]\n') + return path + + +@pytest.mark.parametrize("dry_run", [False, True]) +def test_npm_all_sections_and_locations(tmp_path, registry, dry_run, capsys): + (tmp_path / "pyproject.toml").write_text("""[tool.uv.workspace] +members = ["host", "host"] +""") + host = tmp_path / "host" + host.mkdir() + setup_project(host) + originals = {} + for directory in (tmp_path, tmp_path / "client_app", host, host / "client_app"): + directory.mkdir(exist_ok=True) + path = directory / "package.json" + originals[path] = ( + json.dumps( + { + "dependencies": {"@simple-module-py/ui": "^0.1.0", "react": "^19"}, + "devDependencies": {"@simple-module-py/tsconfig": "~0.1.0"}, + "optionalDependencies": {"@simple-module-py/i18n": "0.1.0"}, + "peerDependencies": {"@simple-module-py/ui": "^0.1.0"}, + }, + indent="\t", + ) + + "\n" + ) + path.write_text(originals[path]) + run_update(tmp_path, dry_run=dry_run, fetcher=registry()) + for path, original in originals.items(): + assert path.read_text() == (original if dry_run else original.replace("0.1.0", "1.2.3")) + output = capsys.readouterr().out + assert "17 dependency(ies) across 5 file(s)" in output + if not dry_run: + assert "uv sync" in output and "npm install" in output + + +@pytest.mark.parametrize( + "pin", ["workspace:*", "file:../ui", "git+https://example/ui", "npm:other@1.0.0", ">=0.1", "*"] +) +def test_npm_unsupported_refs_warn(tmp_path, registry, capsys, pin): + setup_project(tmp_path) + path = tmp_path / "package.json" + original = json.dumps({"dependencies": {"@simple-module-py/ui": pin}}) + path.write_text(original) + run_update(tmp_path, fetcher=registry()) + assert path.read_text() == original + output = capsys.readouterr().out + assert "@simple-module-py/ui: skipped" in output + assert pin in output + + +@pytest.mark.parametrize( + "pin,target,expected", + [ + ("^9.0.0", "1.2.3", "^9.0.0"), + ("1.2.3", "1.2.3-rc.1", "1.2.3"), + ("~1.2.3-rc.2", "1.2.3-rc.1", "~1.2.3-rc.2"), + ("1.2.3-rc.2", "1.2.3-rc.10", "1.2.3-rc.10"), + ("^1.2.3-rc.2", "1.2.3", "^1.2.3"), + ], +) +def test_npm_never_downgrades_including_prereleases(tmp_path, registry, pin, target, expected): + setup_project(tmp_path) + path = tmp_path / "package.json" + path.write_text(json.dumps({"dependencies": {"@simple-module-py/ui": pin}}, indent=4)) + run_update(tmp_path, include_pre=True, fetcher=registry(target)) + assert json.loads(path.read_text())["dependencies"]["@simple-module-py/ui"] == expected + assert not path.read_text().endswith("\n") + + +def test_npm_missing_publication_warns(tmp_path, registry, capsys): + setup_project(tmp_path) + path = tmp_path / "package.json" + original = '{"dependencies":{"@simple-module-py/ui":"0.1.0"}}\n' + path.write_text(original) + run_update(tmp_path, fetcher=registry(missing=("@simple-module-py/ui",))) + assert path.read_text() == original + assert "1.2.3" in capsys.readouterr().out + + +def test_malformed_json_prevents_all_writes(tmp_path, registry): + pyproject = setup_project(tmp_path) + before = pyproject.read_bytes() + (tmp_path / "package.json").write_text('{"dependencies":{"@simple-module-py/ui":"0.1.0"}}') + client = tmp_path / "client_app/package.json" + client.parent.mkdir() + client.write_text("{bad") + npm_before = (tmp_path / "package.json").read_bytes() + with pytest.raises(ValueError): + run_update(tmp_path, fetcher=registry()) + assert pyproject.read_bytes() == before + assert (tmp_path / "package.json").read_bytes() == npm_before + + +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_npm_only_project_uses_core_target_and_preserves_format(tmp_path, registry, newline): + (tmp_path / "pyproject.toml").write_text('[dependency-groups]\ndev = ["pytest"]\n') + path = tmp_path / "package.json" + original = json.dumps({"dependencies": {"@simple-module-py/ui": "0.1.0"}}, indent=3) + "\n" + original = original.replace("\n", newline) + path.write_bytes(original.encode()) + run_update(tmp_path, fetcher=registry()) + assert path.read_bytes() == original.replace("0.1.0", "1.2.3").encode() + + +@pytest.mark.parametrize("missing_core", [False, True]) +def test_missing_target_warns_without_npm_writes(tmp_path, capsys, missing_core): + setup_project(tmp_path) + path = tmp_path / "package.json" + original = '{"dependencies":{"@simple-module-py/ui":"0.1.0"}}' + path.write_text(original) + + def fetch(url): + if url.startswith("https://pypi.org/"): + if missing_core: + raise HTTPError(url, 404, "missing", {}, None) + return {"releases": {"1.2.3": [{"yanked": False}]}} + return {"versions": {"1.2.2": {}}} + + run_update(tmp_path, fetcher=fetch) + assert path.read_text() == original + output = capsys.readouterr().out + assert "@simple-module-py/ui: skipped" in output + assert ( + "simple_module_core not updated" if missing_core else "npm release 1.2.3 unavailable" + ) in output + if not missing_core: + assert "1 dependency(ies) across 1 file(s)" in output + + +def test_core_prerelease_selection_controls_npm_target(tmp_path, capsys): + setup_project(tmp_path) + path = tmp_path / "package.json" + path.write_text('{"dependencies":{"@simple-module-py/ui":"0.1.0"}}') + + def fetch(url): + if url.startswith("https://pypi.org/"): + return {"releases": {"1.2.3": [{"yanked": False}], "2.0.0-rc.1": [{"yanked": False}]}} + return {"versions": {"1.2.3": {}, "2.0.0-rc.1": {}}} + + run_update(tmp_path, fetcher=fetch) + assert json.loads(path.read_text())["dependencies"]["@simple-module-py/ui"] == "1.2.3" + run_update(tmp_path, include_pre=True, fetcher=fetch) + assert json.loads(path.read_text())["dependencies"]["@simple-module-py/ui"] == "2.0.0-rc.1" + + +def _client_pin(root, pin="^0.1.0"): + path = root / "client_app" / "package.json" + path.parent.mkdir(exist_ok=True) + path.write_text(json.dumps({"dependencies": {"@simple-module-py/ui": pin}}) + "\n") + return path + + +def test_npm_stays_when_python_core_pin_is_held_back(tmp_path, registry, capsys): + # An upper bound keeps Python core on 0.1.x; npm must not run ahead of it. + (tmp_path / "pyproject.toml").write_text( + '[project]\ndependencies = ["simple_module_core>=0.1.0,<0.2"]\n' + ) + pkg = _client_pin(tmp_path) + run_update(tmp_path, fetcher=registry()) + assert '"^0.1.0"' in pkg.read_text() + assert "simple_module_core not updated" in capsys.readouterr().out + + +def test_npm_follows_hyphenated_core_name(tmp_path, registry): + (tmp_path / "pyproject.toml").write_text( + '[project]\ndependencies = ["simple-module-core==0.1.0"]\n' + ) + pkg = _client_pin(tmp_path) + run_update(tmp_path, fetcher=registry()) + assert '"^1.2.3"' in pkg.read_text() + + +@pytest.mark.parametrize("error", [TimeoutError("slow"), ValueError("bad json")]) +def test_npm_registry_failure_skips_instead_of_aborting(tmp_path, registry, error, capsys): + setup_project(tmp_path) + pkg = _client_pin(tmp_path) + pypi = registry() + + def fetch(url): + if url.startswith("https://registry.npmjs.org/"): + raise error + return pypi(url) + + run_update(tmp_path, fetcher=fetch) + assert '"^0.1.0"' in pkg.read_text() + out = capsys.readouterr().out + assert "unavailable" in out + assert "simple_module_core==1.2.3" in (tmp_path / "pyproject.toml").read_text() diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index d6c8261b1..73c2d4dde 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -222,6 +222,14 @@ /* ── Base Styles ────────────────────────────────────────────── */ @layer base { + *, + ::before, + ::after, + ::backdrop, + ::file-selector-button { + border-color: var(--color-border); + } + html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/packages/ui/src/styles/ui-border-color.test.ts b/packages/ui/src/styles/ui-border-color.test.ts new file mode 100644 index 000000000..0937dc545 --- /dev/null +++ b/packages/ui/src/styles/ui-border-color.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { compile } from '@tailwindcss/node'; +import postcss, { type Rule } from 'postcss'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const base = resolve(import.meta.dirname); +let rules: Rule[]; + +beforeAll(async () => { + const compiler = await compile(readFileSync(resolve(base, 'globals.css'), 'utf8'), { + base, + onDependency() {}, + }); + const css = compiler.build([ + 'border', + 'border-b', + 'before:border', + 'after:border-b', + 'border-input', + 'border-transparent', + 'border-red-500', + 'border-current', + ]); + rules = []; + postcss.parse(css).walkRules((rule) => { + rules.push(rule); + }); +}); + +const colors = (rule: Rule) => + rule.nodes + .filter((node) => node.type === 'decl' && node.prop === 'border-color') + .map((node) => (node.type === 'decl' ? node.value : '')); + +function inLayer(rule: Rule, layer: string): boolean { + let parent = rule.parent; + while (parent) { + if (parent.type === 'atrule' && parent.name === 'layer' && parent.params === layer) { + return true; + } + parent = parent.parent; + } + return false; +} + +describe('theme border defaults (#714)', () => { + it('gives elements and pseudo-elements a theme border in the base layer', () => { + for (const selector of ['*', '::before', '::after']) { + expect( + rules.some( + (rule) => + inLayer(rule, 'base') && + rule.selector + .split(',') + .map((part) => part.trim()) + .includes(selector) && + colors(rule).includes('var(--color-border)'), + ), + ).toBe(true); + } + }); + + it('keeps bare border utilities from overriding the default color', () => { + for (const selector of ['.border', '.border-b']) { + const rule = rules.find((rule) => rule.selector === selector); + expect(rule && colors(rule)).toEqual([]); + } + }); + + it.each([ + ['.border-input', 'var(--input)'], + ['.border-transparent', 'transparent'], + ['.border-red-500', 'var(--color-red-500)'], + ['.border-current', 'currentcolor'], + ])('keeps %s in the higher-priority utilities layer', (selector, color) => { + expect( + rules.some( + (rule) => + rule.selector === selector && inLayer(rule, 'utilities') && colors(rule).includes(color), + ), + ).toBe(true); + }); + + it('resolves the default through the overridable light/dark border token', () => { + const theme = rules.find((rule) => rule.selector === ':root, :host'); + expect( + theme?.nodes.some( + (node) => + node.type === 'decl' && node.prop === '--color-border' && node.value === 'var(--border)', + ), + ).toBe(true); + for (const selector of [':root', '.dark']) { + expect( + rules.some( + (rule) => + rule.selector === selector && + rule.nodes.some((node) => node.type === 'decl' && node.prop === '--border'), + ), + ).toBe(true); + } + }); +}); diff --git a/skills/simple-module-cli/SKILL.md b/skills/simple-module-cli/SKILL.md index d53de6010..7280e0c46 100644 --- a/skills/simple-module-cli/SKILL.md +++ b/skills/simple-module-cli/SKILL.md @@ -16,7 +16,7 @@ The `smpy` command is provided by `simple_module_cli` (installed as a dep of `si | `smpy new ` | Greenfield: scaffold a complete app (host + selected modules) in one shot, with an interactive wizard for DB / tenancy / module preset | | `smpy create-host ` | You want just a bare host project; you'll add modules later by `pip install`-ing them | | `smpy create-module ` | You're authoring a publishable module package (separate repo, distributed via PyPI) | -| `smpy package-update` | Bump every `simple_module_*` / `simple-module-*` dependency in `pyproject.toml` to its latest non-yanked PyPI release (workspace/path/git/URL sources are left untouched) | +| `smpy package-update` | Bump every `simple_module_*` / `simple-module-*` dependency, optional extra and dependency group to its latest non-yanked PyPI release, and `@simple-module-py/*` npm pins to the same core version (workspace/path/git/URL sources are left untouched) | | `smpy skills …` | Install / update the bundled agent-skill packs into a project (`add`, `list`, `update`) | | `smpy host …` | Project-time helpers run from inside a host directory (page manifest, JS dep sync) | | `smpy settings …` | Settings-module admin — currently `import-from-env` | @@ -103,7 +103,7 @@ For the post-scaffold steps (entry point, Inertia namespace, etc.) see **simple- ## `smpy package-update` — bump `simple_module_*` deps -Walks the project's `pyproject.toml` (plus every `[tool.uv.workspace]` member), finds each dependency whose distribution name matches `simple_module_*` / `simple-module-*`, queries PyPI for the latest non-yanked release, and rewrites the constraint to `name>=`. PyPI lookups run in parallel, so it's quick even across a multi-package workspace. +Walks the project's `pyproject.toml` (plus every `[tool.uv.workspace]` member), finds each dependency whose distribution name matches `simple_module_*` / `simple-module-*`, queries PyPI for the latest non-yanked release, and rewrites the constraint to the latest release, keeping its operator (`==` stays `==`, `>=` stays `>=`; `--loosen` rewrites to `>=`). It covers `[project] dependencies`, optional-dependency extras and `[dependency-groups]`. It also bumps `@simple-module-py/*` pins in each root/member `package.json` and `client_app/package.json` to the selected `simple_module_core` version (keeping `^`/`~`, never downgrading, skipping versions not on npm or when the Python core pin was held back). PyPI lookups run in parallel, so it's quick even across a multi-package workspace. ```bash smpy package-update # rewrite constraints in ./pyproject.toml (+ workspace members) @@ -117,8 +117,9 @@ smpy package-update --path apps/web # point at another project root or a py | `--path ` | cwd | Project root or an explicit `pyproject.toml` | | `--dry-run` | off | Print planned changes only; don't write | | `--include-pre` | off | Allow pre-release versions as the "latest" | +| `--loosen` | off | Rewrite Python constraints to `>=` instead of keeping their operator | -**Deps it deliberately skips:** anything whose `[tool.uv.sources]` entry points at a workspace member, a local `path`, a `git` ref, or a `url` — those don't come from PyPI, so their version lives elsewhere. Skipped deps are listed with a reason (`workspace/local source`, `not found on PyPI`). The command only edits constraints; **run `uv sync` afterward** to actually install the new versions (it reminds you to). +**Deps it deliberately skips:** anything whose `[tool.uv.sources]` entry points at a workspace member, a local `path`, a `git` ref, or a `url` — those don't come from PyPI, so their version lives elsewhere. Skipped deps are listed with a reason (`workspace/local source`, `not found on PyPI`). The command only edits constraints; **run `uv sync` (and `npm install` for npm changes) afterward** to actually install the new versions (it reminds you to). ## `smpy skills` — install the bundled agent skills