Menu
BifrOSt-Apps
publicLatest change f35619cfe0419c596645328bf0e1b682ad3a4473 - Harden RÚV for the 0.1.1 candidate by Ólafur Búi Ólafsson
#!/usr/bin/env python3
"""Generate a complete BifrOSt Flatpak application from the repository template."""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import json
from pathlib import Path
import re
import shutil
import sys
APP_ID = re.compile(r"^org\.bifrost\.[A-Z][A-Za-z0-9]*$")
TOKEN = re.compile(r"@@[A-Z_]+@@")
ROOT = Path(__file__).resolve().parents[1]
TEMPLATES = {
"gtk": ROOT / "template",
"dioxus": ROOT / "template-dioxus",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("app_id", help="reverse-DNS identifier, for example org.bifrost.Notes")
parser.add_argument("name", help="English display name")
parser.add_argument("--name-is", required=True, help="Icelandic display name")
parser.add_argument("--summary", required=True, help="short English AppStream summary")
parser.add_argument("--summary-is", required=True, help="short Icelandic AppStream summary")
parser.add_argument("--description", required=True, help="English AppStream description")
parser.add_argument("--description-is", required=True, help="Icelandic AppStream description")
parser.add_argument("--version", default="0.1.0", help="initial application version")
parser.add_argument(
"--output",
type=Path,
help="destination directory; defaults to apps/<derived-name>",
)
parser.add_argument(
"--stack",
choices=sorted(TEMPLATES),
default="gtk",
help="application template to generate from (default: gtk)",
)
return parser.parse_args()
def slug_for(app_id: str) -> str:
component = app_id.rsplit(".", 1)[1]
words = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", component)
return words.lower()
def xml_escape(value: str) -> str:
return (
value.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
.replace("'", "'")
)
def po_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def rust_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def desktop_escape(value: str, *, list_value: bool = False) -> str:
"""Escape a Desktop Entry string value per the freedesktop.org spec.
Backslash, tab, newline, and carriage return become their escape
sequences; other control characters are rejected because the spec
forbids them in values. Leading and trailing ASCII spaces are escaped
as ``\\s`` so parsers cannot strip them. In list contexts (``list_value``)
the ``;`` separator is escaped as well.
"""
sequences = {"\\": "\\\\", "\n": "\\n", "\t": "\\t", "\r": "\\r"}
escaped = []
for character in value:
if character in sequences:
escaped.append(sequences[character])
elif list_value and character == ";":
escaped.append("\\;")
elif ord(character) < 0x20 or character == "\x7f":
raise ValueError("desktop entry values must not contain control characters")
else:
escaped.append(character)
if escaped and escaped[0] == " ":
escaped[0] = "\\s"
if escaped and escaped[-1] == " ":
escaped[-1] = "\\s"
return "".join(escaped)
def validate(arguments: argparse.Namespace) -> None:
if not APP_ID.fullmatch(arguments.app_id):
raise ValueError("application ID must match org.bifrost.<PascalCaseName>")
for field in ("name", "name_is", "summary", "summary_is", "description", "description_is", "version"):
value = getattr(arguments, field)
if not value.strip() or "\n" in value or "\r" in value:
raise ValueError(f"{field.replace('_', '-')} must be non-empty and single-line")
for field in ("summary", "summary_is"):
if len(getattr(arguments, field)) > 100:
raise ValueError(f"{field.replace('_', '-')} must not exceed 100 characters")
for field in ("description", "description_is"):
if len(getattr(arguments, field)) < 80:
raise ValueError(f"{field.replace('_', '-')} must contain at least 80 characters")
if not re.fullmatch(r"[0-9]+(?:\.[0-9]+){1,2}(?:[-+][A-Za-z0-9.-]+)?", arguments.version):
raise ValueError("version must be a release version such as 1.0.0 or 1.0.0-beta.1")
def substitutions(arguments: argparse.Namespace) -> dict[str, str]:
return {
"@@APP_ID@@": arguments.app_id,
"@@APP_NAME@@": arguments.name,
"@@APP_NAME_IS@@": arguments.name_is,
"@@SUMMARY@@": arguments.summary,
"@@SUMMARY_IS@@": arguments.summary_is,
"@@DESCRIPTION@@": arguments.description,
"@@DESCRIPTION_IS@@": arguments.description_is,
"@@PY_APP_ID@@": json.dumps(arguments.app_id),
"@@PY_APP_NAME@@": json.dumps(arguments.name),
"@@XML_APP_NAME@@": xml_escape(arguments.name),
"@@XML_APP_NAME_IS@@": xml_escape(arguments.name_is),
"@@XML_SUMMARY@@": xml_escape(arguments.summary),
"@@XML_SUMMARY_IS@@": xml_escape(arguments.summary_is),
"@@XML_DESCRIPTION@@": xml_escape(arguments.description),
"@@XML_DESCRIPTION_IS@@": xml_escape(arguments.description_is),
"@@DESKTOP_APP_NAME@@": desktop_escape(arguments.name),
"@@DESKTOP_APP_NAME_IS@@": desktop_escape(arguments.name_is),
"@@DESKTOP_SUMMARY@@": desktop_escape(arguments.summary),
"@@DESKTOP_SUMMARY_IS@@": desktop_escape(arguments.summary_is),
"@@VERSION@@": arguments.version,
"@@CRATE_NAME@@": f"bifrost-{slug_for(arguments.app_id)}",
"@@RS_APP_NAME@@": rust_escape(arguments.name),
"@@RS_SUMMARY@@": rust_escape(arguments.summary),
"@@RS_DESCRIPTION@@": rust_escape(arguments.description),
"@@RS_DESCRIPTION_IS@@": rust_escape(arguments.description_is),
"@@RELEASE_DATE@@": datetime.now(timezone.utc).date().isoformat(),
"@@PO_READY@@": po_escape("Tilbúið til notkunar"),
"@@PO_DESCRIPTION@@": po_escape("Öruggur grunnur fyrir BifrOSt-forrit."),
}
def destination_name(relative: Path, app_id: str) -> Path:
name = relative.name
if name.endswith(".in"):
name = name[:-3]
name = name.replace("app-id", app_id)
return relative.with_name(name)
def render_file(source: Path, destination: Path, values: dict[str, str]) -> None:
text = source.read_text(encoding="utf-8")
for token, value in values.items():
text = text.replace(token, value)
remaining = sorted(set(TOKEN.findall(text)))
if remaining:
raise ValueError(f"unresolved template tokens in {source}: {', '.join(remaining)}")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(text, encoding="utf-8")
if source.name == "main.py.in":
destination.chmod(0o755)
def generate(arguments: argparse.Namespace) -> Path:
validate(arguments)
destination = (arguments.output or ROOT / "apps" / slug_for(arguments.app_id)).resolve()
if destination.exists():
raise FileExistsError(f"destination already exists: {destination}")
destination.mkdir(parents=True)
try:
values = substitutions(arguments)
template = TEMPLATES[arguments.stack]
for source in sorted(template.rglob("*")):
if not source.is_file():
continue
relative = destination_name(source.relative_to(template), arguments.app_id)
render_file(source, destination / relative, values)
return destination
except Exception:
shutil.rmtree(destination, ignore_errors=True)
raise
def main() -> int:
try:
destination = generate(parse_args())
except (OSError, ValueError) as error:
print(f"new-app.py: {error}", file=sys.stderr)
return 1
print(destination)
return 0
if __name__ == "__main__":
raise SystemExit(main())