diff --git a/docker/appstream-gen.py b/docker/appstream-gen.py new file mode 100644 index 0000000..57645a3 --- /dev/null +++ b/docker/appstream-gen.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +""" +Erzeugt einen AppStream-Katalog (Collection-XML) aus packages.yaml. + +Der Katalog referenziert die gespiegelten RPMs direkt ueber . +Es werden keine Wrapper-Pakete gebaut. Das Ergebnis wird per +`modifyrepo_c --mdtype=appstream` in die Repo-Metadaten eingehaengt, +damit Discover / GNOME Software die Pakete findet. + +Aufruf: + appstream-gen.py packages.yaml -o appstream.xml.gz \ + [--available-packages vorhanden.txt] [--origin firmen-repo] +""" + +import argparse +import gzip +import re +import sys +import xml.etree.ElementTree as ET +from xml.dom import minidom + +import yaml + +# AppStream-Spezifikationsversion des Katalogformats. +CATALOG_VERSION = "0.14" + +DEFAULT_TYPE = "console-application" +DEFAULT_ICON = "application-x-executable" +DEFAULT_NOTE = "Über Unternehmens-Repo bereitgestellt." + + +def die(msg): + print(f"[appstream-gen] FEHLER: {msg}", file=sys.stderr) + sys.exit(1) + + +def warn(msg): + print(f"[appstream-gen] WARNUNG: {msg}", file=sys.stderr) + + +def info(msg): + print(f"[appstream-gen] {msg}") + + +def sanitize_id_part(value): + """Macht aus einem Paketnamen ein gueltiges AppStream-ID-Segment.""" + part = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-") + if not part: + die(f"Paketname ergibt kein gueltiges ID-Segment: {value!r}") + if part[0].isdigit(): + part = "pkg-" + part + return part + + +def prettify_name(pkgname): + """Fallback-Anzeigename, wenn in der YAML keiner gesetzt ist. + + dotnet-sdk-9.0 -> Dotnet Sdk 9.0 + """ + words = re.split(r"[-_]", pkgname) + return " ".join(w[:1].upper() + w[1:] if w else w for w in words) + + +def as_list(value): + if value is None: + return [] + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + +def normalize_entry(entry, source_name): + """Akzeptiert sowohl 'paketname' als auch ein Mapping mit Metadaten.""" + if isinstance(entry, str): + return {"name": entry, "_source": source_name} + if isinstance(entry, dict): + if "name" not in entry: + die(f"Paketeintrag in Quelle {source_name!r} ohne 'name': {entry!r}") + data = dict(entry) + data["_source"] = source_name + return data + die(f"Unerwarteter Paketeintrag in Quelle {source_name!r}: {entry!r}") + + +def pick(entry, defaults, key, fallback=None): + """Wert aus dem Paketeintrag, sonst aus defaults, sonst fallback.""" + if key in entry and entry[key] is not None: + return entry[key] + if key in defaults and defaults[key] is not None: + return defaults[key] + return fallback + + +def build_component(entry, defaults, id_prefix): + pkgname = entry["name"] + + comp_type = pick(entry, defaults, "type", DEFAULT_TYPE) + component = ET.Element("component", {"type": comp_type}) + + comp_id = entry.get("id") or f"{id_prefix}.{sanitize_id_part(pkgname)}" + ET.SubElement(component, "id").text = comp_id + ET.SubElement(component, "pkgname").text = pkgname + + display_name = entry.get("display_name") or entry.get("name_display") + if not display_name: + display_name = prettify_name(pkgname) + ET.SubElement(component, "name").text = display_name + + note = pick(entry, defaults, "note", DEFAULT_NOTE) + summary = entry.get("summary") or note + ET.SubElement(component, "summary").text = summary + + # Beschreibung: eigener Text plus Hinweis auf die Herkunft. + description = ET.SubElement(component, "description") + body = entry.get("description") + if body: + for paragraph in [p.strip() for p in str(body).split("\n\n") if p.strip()]: + ET.SubElement(description, "p").text = paragraph + if note and note != summary: + ET.SubElement(description, "p").text = note + elif not body and note == summary: + ET.SubElement(description, "p").text = note + + icon = pick(entry, defaults, "icon", DEFAULT_ICON) + if icon: + ET.SubElement(component, "icon", {"type": "stock"}).text = icon + + categories = as_list(pick(entry, defaults, "categories")) + if categories: + cat_el = ET.SubElement(component, "categories") + for category in categories: + ET.SubElement(cat_el, "category").text = str(category) + + keywords = as_list(entry.get("keywords")) + if keywords: + kw_el = ET.SubElement(component, "keywords") + for keyword in keywords: + ET.SubElement(kw_el, "keyword").text = str(keyword) + + binaries = as_list(entry.get("binary") or entry.get("binaries")) + if binaries: + provides = ET.SubElement(component, "provides") + for binary in binaries: + ET.SubElement(provides, "binary").text = str(binary) + + homepage = entry.get("homepage") or defaults.get("homepage") + if homepage: + ET.SubElement(component, "url", {"type": "homepage"}).text = str(homepage) + + help_url = entry.get("help_url") or defaults.get("help_url") + if help_url: + ET.SubElement(component, "url", {"type": "help"}).text = str(help_url) + + developer = entry.get("developer") or defaults.get("developer") + if developer: + dev_el = ET.SubElement(component, "developer") + ET.SubElement(dev_el, "name").text = str(developer) + + project_license = entry.get("project_license") or defaults.get("project_license") + if project_license: + ET.SubElement(component, "project_license").text = str(project_license) + + # desktop-application braucht einen Launchable-Eintrag, sonst ignoriert + # Discover die Komponente beim Starten der installierten App. + if comp_type == "desktop-application": + desktop_id = entry.get("desktop_id") + if desktop_id: + ET.SubElement( + component, "launchable", {"type": "desktop-id"} + ).text = str(desktop_id) + else: + warn( + f"{pkgname}: type=desktop-application ohne 'desktop_id'. " + "Discover kann die Anwendung dann nicht starten." + ) + + return comp_id, component + + +def load_available(path): + if not path: + return None + try: + with open(path, "r", encoding="utf-8") as handle: + names = {line.strip() for line in handle if line.strip()} + except OSError as exc: + die(f"Konnte Paketliste {path!r} nicht lesen: {exc}") + info(f"{len(names)} tatsaechlich vorhandene Pakete eingelesen") + return names + + +def main(): + parser = argparse.ArgumentParser( + description="Erzeugt einen AppStream-Katalog aus packages.yaml" + ) + parser.add_argument("packages_yaml", help="Pfad zur packages.yaml") + parser.add_argument( + "-o", "--output", default="appstream.xml.gz", + help="Zieldatei (.xml.gz oder .xml), Standard: appstream.xml.gz", + ) + parser.add_argument( + "--origin", + help="Origin des Katalogs. Ueberschreibt 'appstream.origin' aus der YAML.", + ) + parser.add_argument( + "--available-packages", + help="Datei mit den tatsaechlich gespiegelten Paketnamen (einer pro Zeile). " + "Nicht vorhandene Pakete werden dann uebersprungen.", + ) + parser.add_argument( + "--strict", action="store_true", + help="Mit Fehler abbrechen, wenn ein gelistetes Paket nicht vorhanden ist.", + ) + args = parser.parse_args() + + try: + with open(args.packages_yaml, "r", encoding="utf-8") as handle: + config = yaml.safe_load(handle) or {} + except OSError as exc: + die(f"Konnte {args.packages_yaml!r} nicht lesen: {exc}") + except yaml.YAMLError as exc: + die(f"YAML-Fehler in {args.packages_yaml!r}: {exc}") + + appstream_cfg = config.get("appstream") or {} + defaults = appstream_cfg.get("defaults") or {} + origin = args.origin or appstream_cfg.get("origin") or "firmen-repo" + id_prefix = appstream_cfg.get("id_prefix") or "de.firma.repo" + + available = load_available(args.available_packages) + + sources = config.get("sources") or [] + if not sources: + die("Keine 'sources' in der packages.yaml gefunden.") + + root = ET.Element("components", {"version": CATALOG_VERSION, "origin": origin}) + + seen_ids = {} + seen_pkgs = set() + skipped = [] + count = 0 + + for source in sources: + source_name = source.get("name", "") + for raw_entry in source.get("packages") or []: + entry = normalize_entry(raw_entry, source_name) + pkgname = entry["name"] + + if entry.get("appstream") is False or entry.get("hidden") is True: + info(f"{pkgname}: per Konfiguration von AppStream ausgenommen") + continue + + if available is not None and pkgname not in available: + skipped.append(pkgname) + continue + + if pkgname in seen_pkgs: + warn(f"{pkgname}: mehrfach gelistet, spaetere Eintraege ignoriert") + continue + seen_pkgs.add(pkgname) + + comp_id, component = build_component(entry, defaults, id_prefix) + if comp_id in seen_ids: + die( + f"Doppelte AppStream-ID {comp_id!r} " + f"({seen_ids[comp_id]} und {pkgname}). " + "Setze 'id:' fuer eines der beiden Pakete." + ) + seen_ids[comp_id] = pkgname + root.append(component) + count += 1 + + if skipped: + message = ( + f"{len(skipped)} gelistete Pakete sind nicht im Repo vorhanden " + f"und wurden uebersprungen: {', '.join(sorted(skipped))}" + ) + if args.strict: + die(message) + warn(message) + + raw = ET.tostring(root, encoding="utf-8") + pretty = minidom.parseString(raw).toprettyxml(indent=" ", encoding="UTF-8") + + try: + if args.output.endswith(".gz"): + with gzip.open(args.output, "wb") as handle: + handle.write(pretty) + else: + with open(args.output, "wb") as handle: + handle.write(pretty) + except OSError as exc: + die(f"Konnte {args.output!r} nicht schreiben: {exc}") + + info(f"{count} Komponente(n) nach {args.output} geschrieben (origin={origin})") + if count == 0: + warn("Katalog ist leer. Discover wird nichts anzeigen.") + + +if __name__ == "__main__": + main() \ No newline at end of file