build / build (push) Successful in 21m27s
The packaged copies use config.default_path() and pipeline._history_path(): files next to the sources in a checkout, ~/.config/<name> and ~/.local/state/<name> when installed. The copies now differ from the originals only in the neutral defaults. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
196 lines
6.8 KiB
Python
196 lines
6.8 KiB
Python
"""publish.toml: server settings and the registry of published apps."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import tomllib
|
|
from dataclasses import dataclass, field, fields
|
|
from pathlib import Path
|
|
|
|
# <AppName> as apps.jsp groups it: no "_<digit>", "-v<digit>", ... inside (that starts the version)
|
|
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.\-]{0,63}$")
|
|
# Version lines in build files: versionName = "1.2.0" (gradle), Version = "0.10.0" (Go), version = "0.1.0"
|
|
DEFAULT_VERSION_RE = r"""(?:versionName|Version|version)\s*[=:]?\s*["']([0-9][^"']*)["']"""
|
|
|
|
|
|
class ConfigError(ValueError):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class Server:
|
|
host: str = "localhost"
|
|
user: str = "user"
|
|
port: int = 22
|
|
# Package storage of apps.jsp: only download.jsp serves it
|
|
apps_dir: str = "/home/user/app/apps"
|
|
# Requested on the server after an upload; the file name is appended (URL-encoded)
|
|
check_url: str = "http://localhost:8010/download.jsp?f="
|
|
state_dir: str = "/home/user/app_deploy"
|
|
keep: int = 3 # versions of each package type per app; 0 = keep all
|
|
local_roots: list[str] = field(default_factory=lambda: ["~/Dev/Projects"])
|
|
|
|
@property
|
|
def target(self) -> str:
|
|
return f"{self.user}@{self.host}"
|
|
|
|
|
|
@dataclass
|
|
class App:
|
|
name: str
|
|
src: str
|
|
# globs relative to src; each one gives one package (the newest match)
|
|
packages: list[str] = field(default_factory=list)
|
|
# version for files without _v<version> in their name, and a staleness check for the others
|
|
version_file: str = ""
|
|
version_regex: str = ""
|
|
build: str = ""
|
|
build_env: dict[str, str] = field(default_factory=dict)
|
|
# local files uploaded as <name>.txt (title + description) and <name>.png|.webp (icon)
|
|
info: str = ""
|
|
icon: str = ""
|
|
keep: int = -1 # -1 = server keep
|
|
|
|
@property
|
|
def src_path(self) -> Path:
|
|
return Path(os.path.expanduser(self.src))
|
|
|
|
def local(self, rel: str) -> Path:
|
|
p = Path(os.path.expanduser(rel))
|
|
return p if p.is_absolute() else self.src_path / p
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
path: Path
|
|
server: Server
|
|
apps: list[App]
|
|
|
|
def app(self, name: str) -> App:
|
|
for a in self.apps:
|
|
if a.name.lower() == name.lower():
|
|
return a
|
|
raise ConfigError(f"unknown app: {name}")
|
|
|
|
def keep(self, app: App) -> int:
|
|
return self.server.keep if app.keep < 0 else app.keep
|
|
|
|
|
|
def _check_types(obj, data: dict, where: str):
|
|
known = {f.name: f for f in fields(obj)}
|
|
for k, v in data.items():
|
|
if k not in known:
|
|
raise ConfigError(f"{where}: unknown key '{k}'")
|
|
default = getattr(obj, k)
|
|
if isinstance(default, bool):
|
|
ok = isinstance(v, bool)
|
|
elif isinstance(default, int):
|
|
ok = isinstance(v, int) and not isinstance(v, bool)
|
|
elif isinstance(default, list):
|
|
ok = isinstance(v, list) and all(isinstance(x, str) for x in v)
|
|
elif isinstance(default, dict):
|
|
ok = isinstance(v, dict) and all(isinstance(x, str) for x in v.values())
|
|
else:
|
|
ok = isinstance(v, str)
|
|
if not ok:
|
|
raise ConfigError(f"{where}: '{k}' has wrong type")
|
|
setattr(obj, k, v)
|
|
|
|
|
|
def validate_server(s: Server) -> None:
|
|
d = s.apps_dir.rstrip("/")
|
|
if not d.startswith("/") or d.count("/") < 3 or ".." in Path(d).parts:
|
|
raise ConfigError(f"[server]: unsafe apps_dir '{s.apps_dir}'")
|
|
if s.keep < 0:
|
|
raise ConfigError("[server]: keep must be >= 0")
|
|
|
|
|
|
def validate_app(app: App) -> None:
|
|
w = f"app '{app.name}'"
|
|
if not NAME_RE.match(app.name) or re.search(r"[_-]v?\d", app.name):
|
|
raise ConfigError(f"{w}: bad name (letters, digits, '_.-'; no '_<digit>' or '_v<digit>')")
|
|
if not app.src:
|
|
raise ConfigError(f"{w}: src is required")
|
|
if not app.packages:
|
|
raise ConfigError(f"{w}: packages (globs relative to src) are required")
|
|
for g in app.packages:
|
|
if not g.strip() or ".." in Path(g).parts:
|
|
raise ConfigError(f"{w}: bad package glob '{g}'")
|
|
if app.version_regex:
|
|
try:
|
|
if re.compile(app.version_regex).groups < 1:
|
|
raise ConfigError(f"{w}: version_regex needs a (group)")
|
|
except re.error as e:
|
|
raise ConfigError(f"{w}: version_regex: {e}") from None
|
|
if app.icon and not app.icon.lower().endswith((".png", ".webp")):
|
|
raise ConfigError(f"{w}: icon must be .png or .webp")
|
|
if app.keep < -1:
|
|
raise ConfigError(f"{w}: keep must be >= 0")
|
|
|
|
|
|
def load(path: str | Path) -> Config:
|
|
path = Path(path)
|
|
with open(path, "rb") as f:
|
|
try:
|
|
data = tomllib.load(f)
|
|
except tomllib.TOMLDecodeError as e:
|
|
raise ConfigError(f"{path}: {e}") from None
|
|
server = Server()
|
|
_check_types(server, data.get("server", {}), "[server]")
|
|
validate_server(server)
|
|
apps, seen = [], set()
|
|
for i, raw in enumerate(data.get("app", [])):
|
|
raw = dict(raw)
|
|
try:
|
|
app = App(name=raw.pop("name"), src=raw.pop("src"))
|
|
except KeyError as e:
|
|
raise ConfigError(f"app #{i + 1}: missing {e}") from None
|
|
_check_types(app, raw, f"app '{app.name}'")
|
|
validate_app(app)
|
|
if app.name.lower() in seen:
|
|
raise ConfigError(f"duplicate app '{app.name}'")
|
|
seen.add(app.name.lower())
|
|
apps.append(app)
|
|
return Config(path, server, apps)
|
|
|
|
|
|
def _toml_value(v) -> str:
|
|
if isinstance(v, bool):
|
|
return "true" if v else "false"
|
|
if isinstance(v, int):
|
|
return str(v)
|
|
if isinstance(v, list):
|
|
return "[" + ", ".join(_toml_value(x) for x in v) + "]"
|
|
if isinstance(v, dict):
|
|
return "{ " + ", ".join(f"{k} = {_toml_value(x)}" for k, x in v.items()) + " }"
|
|
s = str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
return f'"{s}"'
|
|
|
|
|
|
def app_to_toml(app: App) -> str:
|
|
"""[[app]] block with the non-default keys only."""
|
|
blank = App(name="", src="")
|
|
lines = ["[[app]]"]
|
|
for f in fields(app):
|
|
v = getattr(app, f.name)
|
|
if f.name in ("name", "src") or v != getattr(blank, f.name):
|
|
lines.append(f"{f.name} = {_toml_value(v)}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def append_apps(path: Path, apps: list[App]) -> None:
|
|
"""Append blocks to the file: hand-written comments and order are kept."""
|
|
text = path.read_text() if path.exists() else ""
|
|
for a in apps:
|
|
validate_app(a)
|
|
text = text.rstrip("\n") + "\n\n" + app_to_toml(a)
|
|
path.write_text(text)
|
|
|
|
|
|
def default_path() -> Path:
|
|
"""publish.toml next to the sources (a checkout), else ~/.config/publisher/publish.toml (an installed package)."""
|
|
local = Path(__file__).resolve().parent.parent / "publish.toml"
|
|
if local.exists():
|
|
return local
|
|
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "publisher" / "publish.toml"
|