Files
soleprint/soleprint/common/slice/runfile.py
2026-09-24 00:43:18 -03:00

288 lines
11 KiB
Python

"""
A run file: every slice (and plexus) one consumer takes from spr, and how.
python3 -m common.slice slice ui.toml # write what is stale
python3 -m common.slice slice ui.toml --list # resolved; writes nothing
python3 -m common.slice check ui.toml # exit 1 if anything is stale
This module only loads and validates. Same three rules as docgen's and theme's
run files, so spr has one convention for "rebuild these, with these settings":
- **Paths are relative to the run file**, not to where the command runs.
- **An entry's value replaces the default**, for every key.
- **Unknown keys are refused**, and every problem is reported in one pass.
## The shape
[defaults]
delivery = "dependency"
[[slice]]
name = "app-ui"
target = "vue" # or "plain"
dest = "../framework" # where the files land
scan = ["src"] # read the consumer's own imports / markup
use = ["Panel"] # and/or name what to take
delivery = "vendored" # needs `notice`
notice = "none" # or a path: copied into dest as-is
## Where it lives
Beside the consumer, never in spr: it names the consumer's paths. For a vendored
slice keep it — and the manifest it writes — outside the consumer's repository,
with `dest` pointing in. `ui.example.toml` beside this file is the committed one.
"""
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
tomllib = None
TOP_KEYS = {"defaults", "slice", "plexus"}
SLICE_KEYS = {"name", "target", "delivery", "layout", "dest", "use", "scan", "alias", "theme",
"tokens", "package", "notice", "header", "manifest", "rename"}
DEFAULT_KEYS = SLICE_KEYS - {"name", "dest", "manifest"}
TARGETS = ("vue", "plain")
DELIVERIES = ("dependency", "vendored")
LAYOUTS = ("package", "flat")
VUE_ONLY = {"layout", "alias", "package"}
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
TOKEN = re.compile(r"^--[A-Za-z0-9_-]+$")
class ConfigError(ValueError):
"""A run file that cannot be run. Carries every problem, not the first."""
def __init__(self, path, problems: list[str]):
self.path, self.problems = path, problems
super().__init__(f"{path}: {len(problems)} problem(s)\n " + "\n ".join(problems))
@dataclass
class Slice:
"""One slice, fully resolved — nothing relative, nothing defaulted later."""
name: str
target: str
delivery: str
dest: Path
manifest: Path
layout: str = "package"
use: tuple = ()
scan: tuple = ()
alias: tuple = ("soleprint-ui", "@framework")
theme: str | None = None
tokens: dict = field(default_factory=dict)
package: bool | str = False
notice: str | Path | None = None
header: str | None = None
rename: dict = field(default_factory=dict)
@property
def vendored(self) -> bool:
return self.delivery == "vendored"
def line(self) -> str:
return f"{self.name:<16} {self.target:<5} {self.delivery:<10} -> {self.dest}"
@dataclass
class RunFile:
path: Path
slices: list = field(default_factory=list)
plexuses: list = field(default_factory=list) # raw tables; plexus.py resolves them
def select(self, names) -> list[Slice]:
if not names:
return list(self.slices)
known = {s.name for s in self.slices}
unknown = [n for n in names if n not in known]
if unknown:
raise ConfigError(self.path, [f"no slice named {n!r} — have: "
f"{', '.join(sorted(known))}" for n in unknown])
return [s for s in self.slices if s.name in set(names)]
def _path(value, base: Path) -> Path:
p = Path(str(value)).expanduser()
return Path(os.path.normpath(p if p.is_absolute() else base / p))
def _strings(value) -> bool:
return isinstance(value, list) and all(isinstance(x, str) for x in value)
def _table_of_strings(value) -> bool:
return isinstance(value, dict) and all(isinstance(k, str) and isinstance(v, str)
for k, v in value.items())
def load(path, themes: list[str]) -> RunFile:
"""Read and resolve a run file. Raises ConfigError listing every problem.
`themes` is passed in so this stays a reader of files with nothing to know
about where themes live.
"""
path = Path(path)
if tomllib is None:
raise ConfigError(path, ["run files need Python 3.11+ (tomllib)"])
try:
data = tomllib.loads(path.read_text())
except OSError as e:
raise ConfigError(path, [f"cannot read: {e}"]) from None
except tomllib.TOMLDecodeError as e:
raise ConfigError(path, [f"not valid TOML: {e}"]) from None
base = path.resolve().parent
problems: list[str] = []
for key in sorted(set(data) - TOP_KEYS):
problems.append(f"unknown top-level key {key!r} — have: {', '.join(sorted(TOP_KEYS))}")
defaults = data.get("defaults") or {}
if not isinstance(defaults, dict):
problems.append("[defaults] must be a table")
defaults = {}
for key in sorted(set(defaults) - DEFAULT_KEYS):
problems.append(f"[defaults] has unknown key {key!r} — have: "
f"{', '.join(sorted(DEFAULT_KEYS))}")
raw_slices = data.get("slice") or []
plexuses = data.get("plexus") or []
if not isinstance(raw_slices, list) or not isinstance(plexuses, list):
problems.append("[[slice]] and [[plexus]] must be arrays of tables")
raw_slices, plexuses = [], []
if not raw_slices and not plexuses:
problems.append("nothing to do — add a [[slice]] (or a [[plexus]]) table")
slices: list[Slice] = []
for i, raw in enumerate(raw_slices):
where = f"slice[{i}]"
if not isinstance(raw, dict):
problems.append(f"{where} must be a table")
continue
name = raw.get("name")
if isinstance(name, str):
where = f"slice {name!r}"
if not isinstance(name, str) or not NAME.match(name):
problems.append(f"{where} needs a name of letters, digits, '.', '_' or '-'")
continue
for key in sorted(set(raw) - SLICE_KEYS):
problems.append(f"{where} has unknown key {key!r} — have: {', '.join(sorted(SLICE_KEYS))}")
def pick(key, default=None):
return raw[key] if key in raw else defaults.get(key, default)
target = pick("target")
if target not in TARGETS:
problems.append(f"{where}: target must be one of {', '.join(TARGETS)}")
continue
delivery = pick("delivery", "dependency")
if delivery not in DELIVERIES:
problems.append(f"{where}: delivery must be one of {', '.join(DELIVERIES)}")
if target == "plain":
for key in sorted(VUE_ONLY & set(raw)):
problems.append(f"{where}: {key!r} applies to target = \"vue\" only")
if not isinstance(raw.get("dest"), str):
problems.append(f"{where} needs `dest`, the directory the slice is written to")
continue
dest = _path(raw["dest"], base)
layout = pick("layout", "flat" if delivery == "vendored" else "package")
if layout not in LAYOUTS:
problems.append(f"{where}: layout must be one of {', '.join(LAYOUTS)}")
use = pick("use", [])
if not _strings(use):
problems.append(f"{where}: use must be a list of names")
use = []
scan = pick("scan", [])
if not _strings(scan):
problems.append(f"{where}: scan must be a list of paths")
scan = []
scan_paths = []
for s in scan:
sp = _path(s, base)
if not sp.exists():
problems.append(f"{where}: scan path {sp} does not exist")
scan_paths.append(sp)
if not use and not scan:
problems.append(f"{where}: nothing selected — give `use`, `scan`, or both")
alias = pick("alias", ["soleprint-ui", "@framework"])
if not _strings(alias) or not alias:
problems.append(f"{where}: alias must be a non-empty list of import specifiers")
alias = ["soleprint-ui", "@framework"]
theme = pick("theme")
if theme is not None and (not isinstance(theme, str) or
(theme != "library" and theme not in themes)):
problems.append(f"{where}: theme must be \"library\" or one of: {', '.join(themes)}")
theme = None
if target == "plain" and theme in (None, "library"):
# Parts are written against the theme contract, never the library's file.
theme = "soleprint"
tokens = pick("tokens", {})
if not _table_of_strings(tokens) or not all(TOKEN.match(k) for k in tokens):
problems.append(f"{where}: tokens must map --names to values")
tokens = {}
package = pick("package", False)
if not isinstance(package, (bool, str)):
problems.append(f"{where}: package must be true, false, or a package name")
package = False
notice = pick("notice")
if delivery == "vendored":
if notice is None:
problems.append(f"{where}: a vendored slice needs `notice` — \"none\", or a file to "
f"copy in. The tool never picks the licensing for you.")
elif notice != "none":
np = _path(notice, base)
if not np.is_file():
problems.append(f"{where}: notice {np} is not a file")
notice = np
elif notice is not None and notice != "none":
notice = _path(notice, base)
header = pick("header")
if header is not None and not isinstance(header, str):
problems.append(f"{where}: header must be a string")
header = None
rename = pick("rename", {})
if not _table_of_strings(rename):
problems.append(f"{where}: rename must map identifiers to identifiers")
rename = {}
manifest = _path(raw["manifest"], base) if isinstance(raw.get("manifest"), str) \
else base / f"{name}.slice.json"
slices.append(Slice(name=name, target=target, delivery=delivery, dest=dest,
manifest=manifest, layout=layout, use=tuple(use),
scan=tuple(scan_paths), alias=tuple(alias), theme=theme,
tokens=dict(tokens), package=package, notice=notice,
header=header, rename=dict(rename)))
seen_names, seen_dests, seen_manifests = set(), {}, {}
for s in slices:
if s.name in seen_names:
problems.append(f"slice {s.name!r} is listed twice")
seen_names.add(s.name)
for key, seen, what in ((s.dest, seen_dests, "dest"), (s.manifest, seen_manifests, "manifest")):
k = key.resolve()
if k in seen:
problems.append(f"slices {seen[k]!r} and {s.name!r} share one {what}: {key}")
seen[k] = s.name
if problems:
raise ConfigError(path, problems)
return RunFile(path=path, slices=slices, plexuses=list(plexuses))