Files
Das-OS/srcpkgs/publisher/files/python/publisher/config.py
T
dasandClaude Sonnet 5.5 60af93df74
build / build (push) Successful in 21m27s
publisher, deployer: same path logic as the originals
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>
2026-10-25 14:55:40 +02:00

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"