Menu
BifrOSt-Apps
publicLatest change a5b8f241ab7e08ba2740e980a5d90870a34b32fd - Add first-party Flatpak app template 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]
TEMPLATE = ROOT / "template"
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>",
)
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 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),
"@@VERSION@@": arguments.version,
"@@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)
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())