Skip to content
Draft
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
16 changes: 16 additions & 0 deletions docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,22 @@ smpy package-update

Pass `--dry-run` first to preview the diff.

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
policy. That matters because the published module wheels pin their framework
deps exactly: a host loosened to `>=` hands the effective version to whichever
wheel pins hardest, rather than deciding it itself.

Two consequences worth knowing:

- A dependency whose upper bound excludes the latest release (`>=0.1,<1.0`
when 2.0.0 is out) is reported and left alone, rather than having its ceiling
silently dropped.
- 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.

## Troubleshooting

**`smpy: command not found`** after `uv tool install`.
Expand Down
20 changes: 17 additions & 3 deletions docs/modules/branding.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

White-labels the application. An administrator sets the **app name**, **logo** (plus an optional dark-background variant), **favicon**, **primary colour**, **[design pack](/framework-conventions#design-packs-site-wide-look)**, and a site-wide **announcement banner** — from an admin page, with no code change or redeploy.

Values persist in the shared [settings](/modules/settings) store (there is no branding table) and reach **every** Inertia page — authenticated *and* guest — through a registered shared-props provider, so the frontend can render the name, swap the logo/favicon, apply the brand colour, and show the banner everywhere. Footer content is owned by the framework site layouts, not branding.
Values persist in the shared [settings](/modules/settings) store (there is no branding table) and reach **every** Inertia page — authenticated *and* guest — through a registered shared-props provider, so the frontend can render the name, swap the logo/favicon, apply the brand colour, show the banner and set the footer links everywhere.

## ModuleMeta

Expand Down Expand Up @@ -104,6 +104,7 @@ DB-backed via `register_module_settings`; pydantic defaults seed at boot. Edited
| `favicon_file_id` | `""` | UUID of the favicon; `""` ⇒ no custom favicon. |
| `banner_message` | `""` | Site-wide announcement text (≤ 500 chars); `""` ⇒ no banner. |
| `banner_severity` | `"info"` | One of `info`, `warning`, `danger`. Unknown values normalise to `info`. |
| `footer_links` | `[]` | Up to 6 `{label, href}` links shown in the site footer; `[]` ⇒ show the framework's own links. |

`app_name` rejects control characters, not just blanks: the name is used in HTML titles and — critically — email `Subject` headers, where an embedded CR/LF would survive a bare `strip()` and then raise, breaking every transactional email.

Expand Down Expand Up @@ -147,6 +148,18 @@ A message plus a severity, rendered above every shell — app, public and auth

Severity colours are semantic, not brand-tinted: a warning wearing the deployment's accent colour stops reading as a warning.

## Footer links

The footer renders on every page — the app shell and the public site — so its links are outward-facing attribution on a deployed product. Setting `footer_links` replaces the framework's own *Docs / Changelog / GitHub* row, which points at `antosubash/simple_module_python`.

Leaving the list empty keeps those framework links, so a deployment that never touches this looks exactly as it did. Clearing the list back to empty is how you return to them.

Each `href` must be `http://`, `https://`, `mailto:` or a site-relative path beginning with a single `/`. That is an allow-list rather than tidiness: the value is rendered straight into an `<a href>` on every page, signed-in or not, so `javascript:` and `data:` would make the branding screen a stored-XSS sink for anyone holding `branding.manage`. Scheme-relative `//host` is rejected too — it reads as a relative path but navigates off-site.

Labels are trimmed, non-blank, ≤ 40 characters and reject control characters, on the same reasoning as `app_name`.

On the frontend, `BrandingFooter` takes an optional `links` prop and falls back to `BRAND_FOOTER_LINKS` when it is absent, `null` or empty; `SidebarLayout` and `PublicLayout` pass the shared prop through, so a host gets the override without forking either layout.

## Dark-background logo

The sidebar and mobile bar sit on a near-black surface in every theme, while the sign-in card and public page are light — so a single logo cannot read on both. Uploading a *Logo (dark backgrounds)* variant swaps it in on those surfaces only.
Expand All @@ -166,12 +179,13 @@ On startup the module registers a shared-props provider (`register_inertia_share
"logoUrl": "/api/branding/logo?v=<file id>",
"logoDarkUrl": "/api/branding/logo-dark?v=<file id>",
"faviconUrl": "/api/branding/favicon?v=<file id>",
"banner": { "message": "Maintenance at 22:00 UTC", "severity": "warning" }
"banner": { "message": "Maintenance at 22:00 UTC", "severity": "warning" },
"footerLinks": [{ "label": "Handbook", "href": "https://acme.example.org/handbook" }]
}
}
```

`primaryColor` and `designPack` are `null` when unset; the three image URLs are `null` when no file is configured. `banner` is `null` when no message is set, so the frontend renders nothing at all rather than an empty bar.
`primaryColor` and `designPack` are `null` when unset; the three image URLs are `null` when no file is configured. `banner` is `null` when no message is set, so the frontend renders nothing at all rather than an empty bar. `footerLinks` is `null` when none are configured, which is what tells the frontend to keep the framework's own links rather than render an empty row.

The provider is defensive — it returns `{}` if branding state isn't mounted yet, so a half-booted app never errors a render. Because changes go through the settings store, a save hot-reloads `app.state.branding.settings`; the next render reflects the new values without a restart.

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. `--dry-run` previews the diff. |
| `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 skills add\|list\|update` | Install or update the bundled agent skills under `.claude/skills/` for use with Claude Code. |

### Module-contributed plugins
Expand Down
105 changes: 40 additions & 65 deletions framework/cli/simple_module_cli/package_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

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 rewrites
the constraint to ``name>=<latest>``.
``simple-module-``, queries PyPI for the latest non-yanked release, and points
the constraint at it.

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
excludes the latest release skips the dependency instead of being dropped.
``--loosen`` restores the older behaviour of rewriting every constraint to
``name>=<latest>``.

Dependencies whose ``[tool.uv.sources]`` entry points at a workspace member, a
local path, a git ref, or a URL are left untouched — those aren't installed
Expand All @@ -12,11 +18,7 @@

from __future__ import annotations

import json
import re
import urllib.error
import urllib.request
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -26,17 +28,12 @@
import typer
from tomlkit.items import Array, Table

__all__ = ["package_update", "run_update"]
from simple_module_cli.pypi import Fetcher, default_fetcher, fetch_latest
from simple_module_cli.requirements import parse_requirement, rewrite_requirement

Fetcher = Callable[[str], dict[str, Any]]
__all__ = ["package_update", "run_update"]

_PYPI_URL = "https://pypi.org/pypi/{name}/json"
_SM_PREFIX_RE = re.compile(r"^simple[_-]module[_-]", re.IGNORECASE)
# PEP 440 release segments contain only digits + dots; any letter signals
# a pre/post/dev release (a, b, rc, post, dev). Coarser than packaging.version
# but `packaging` isn't a CLI dep (see test_no_framework_deps.py).
_PRE_RELEASE_RE = re.compile(r"[a-zA-Z]")
_REQ_OPS = ("===", "==", ">=", "<=", "!=", "~=", ">", "<")


@dataclass(frozen=True)
Expand All @@ -60,14 +57,8 @@ def _is_sm_package(name: str) -> bool:

def _dep_name(spec: str) -> str | None:
"""Extract the distribution name from a PEP 508 requirement string."""
base = spec.split(";", 1)[0].strip()
base = base.split("[", 1)[0]
for op in _REQ_OPS:
if op in base:
base = base.split(op, 1)[0]
break
name = base.strip()
return name or None
parsed = parse_requirement(spec)
return parsed.name if parsed else None


def _is_local_source(source: Any) -> bool:
Expand All @@ -89,40 +80,6 @@ def _get_uv_section(doc: tomlkit.TOMLDocument, key: str) -> dict[str, Any] | Non
return section if isinstance(section, dict) else None


def _fetch_latest(name: str, *, include_pre: bool, fetcher: Fetcher) -> str | None:
try:
data = fetcher(_PYPI_URL.format(name=name))
except (urllib.error.HTTPError, urllib.error.URLError):
return None
releases = data.get("releases") or {}
candidates: list[str] = []
for version, files in releases.items():
if not files:
continue
if any(f.get("yanked") for f in files):
continue
if not include_pre and _PRE_RELEASE_RE.search(version):
continue
candidates.append(version)
if candidates:
return max(candidates, key=_version_key)
info = data.get("info") or {}
return info.get("version")


def _version_key(v: str) -> tuple[int, ...]:
parts: list[int] = []
for part in v.split("."):
digits = re.match(r"\d+", part)
parts.append(int(digits.group()) if digits else 0)
return tuple(parts)


def _default_fetcher(url: str) -> dict[str, Any]:
with urllib.request.urlopen(url, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))


def _workspace_member_dirs(root_pyproject: Path, doc: tomlkit.TOMLDocument) -> list[Path]:
workspace = _get_uv_section(doc, "workspace")
if workspace is None:
Expand Down Expand Up @@ -166,6 +123,7 @@ def _process_file(
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)):
Expand All @@ -190,11 +148,13 @@ def _process_file(
if latest is None:
skips.append(Skip(path, name, "not found on PyPI"))
continue
new_dep = f"{name}>={latest}"
if new_dep == dep_str.strip():
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] = new_dep
changes.append(Change(path, name, dep_str.strip(), new_dep))
deps[idx] = result.spec
changes.append(Change(path, name, dep_str.strip(), result.spec))

return changes, skips, doc if changes else None

Expand Down Expand Up @@ -226,6 +186,7 @@ def run_update(
*,
dry_run: bool = False,
include_pre: bool = False,
loosen: bool = False,
fetcher: Fetcher | None = None,
) -> None:
"""Programmatic entry point — separated from the Typer command for testing."""
Expand All @@ -234,7 +195,7 @@ def run_update(
typer.echo(f"ERROR: {root} not found.", err=True)
raise typer.Exit(code=1)

fetch = fetcher or _default_fetcher
fetch = fetcher or default_fetcher
root_doc = tomlkit.parse(root.read_text(encoding="utf-8"))
files: list[tuple[Path, tomlkit.TOMLDocument]] = [(root, root_doc)]
for member in _workspace_member_dirs(root, root_doc):
Expand All @@ -246,7 +207,7 @@ def run_update(
if unique_names:
with ThreadPoolExecutor(max_workers=min(8, len(unique_names))) as pool:
results = pool.map(
lambda n: (n, _fetch_latest(n, include_pre=include_pre, fetcher=fetch)),
lambda n: (n, fetch_latest(n, include_pre=include_pre, fetcher=fetch)),
unique_names,
)
cache = dict(results)
Expand All @@ -256,7 +217,7 @@ def run_update(
pending: list[tuple[Path, tomlkit.TOMLDocument]] = []

for file, doc in files:
changes, skips, new_doc = _process_file(file, doc, cache=cache)
changes, skips, new_doc = _process_file(file, doc, cache=cache, loosen=loosen)
all_changes.extend(changes)
all_skips.extend(skips)
if new_doc is not None:
Expand All @@ -282,6 +243,20 @@ def package_update(
bool,
typer.Option("--include-pre", help="Include pre-release versions."),
] = False,
loosen: Annotated[
bool,
typer.Option(
"--loosen",
help="Rewrite every constraint to `>=<latest>` instead of keeping its operator.",
),
] = False,
) -> None:
"""Update all simple_module_* dependencies to the latest PyPI versions."""
run_update(path, dry_run=dry_run, include_pre=include_pre)
"""Update all simple_module_* dependencies to the latest PyPI versions.

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.
"""
run_update(path, dry_run=dry_run, include_pre=include_pre, loosen=loosen)
55 changes: 55 additions & 0 deletions framework/cli/simple_module_cli/pypi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""PyPI release lookup for ``smpy package-update``.

Split out of ``package_update`` so the network half is separately testable and
neither file approaches the 300-line cap. The ``Fetcher`` indirection is what
lets the CLI tests run without touching the network.
"""

from __future__ import annotations

import json
import re
import urllib.error
import urllib.request
from collections.abc import Callable
from typing import Any

from simple_module_cli.requirements import version_key

__all__ = ["PYPI_URL", "Fetcher", "default_fetcher", "fetch_latest"]

Fetcher = Callable[[str], dict[str, Any]]

PYPI_URL = "https://pypi.org/pypi/{name}/json"

# PEP 440 release segments contain only digits + dots; any letter signals
# a pre/post/dev release (a, b, rc, post, dev). Coarser than packaging.version
# but `packaging` isn't a CLI dep (see test_no_framework_deps.py).
_PRE_RELEASE_RE = re.compile(r"[a-zA-Z]")


def fetch_latest(name: str, *, include_pre: bool, fetcher: Fetcher) -> str | None:
"""Latest non-yanked release of ``name``, or ``None`` if PyPI doesn't have it."""
try:
data = fetcher(PYPI_URL.format(name=name))
except (urllib.error.HTTPError, urllib.error.URLError):
return None
releases = data.get("releases") or {}
candidates: list[str] = []
for version, files in releases.items():
if not files:
continue
if any(f.get("yanked") for f in files):
continue
if not include_pre and _PRE_RELEASE_RE.search(version):
continue
candidates.append(version)
if candidates:
return max(candidates, key=version_key)
info = data.get("info") or {}
return info.get("version")


def default_fetcher(url: str) -> dict[str, Any]:
with urllib.request.urlopen(url, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
Loading
Loading