288 lines
11 KiB
Python
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))
|