#!/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()