appstream-gen.py gelöscht

This commit is contained in:
2026-08-26 15:18:06 +02:00
parent 872c1e509e
commit 8146c22092
-328
View File
@@ -1,328 +0,0 @@
#!/usr/bin/env python3
"""
Erzeugt einen AppStream-Katalog (Collection-XML) aus packages.yaml.
Der Katalog referenziert die gespiegelten RPMs direkt ueber <pkgname>.
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 = (
"Bereitgestellt über das Unternehmens-Repo der IT-Abteilung. "
"Fragen und Paketwünsche bitte an den IT-Service-Desk."
)
def die(msg):
sys.stdout.flush()
print(f"[appstream-gen] FEHLER: {msg}", file=sys.stderr, flush=True)
sys.exit(1)
def warn(msg):
sys.stdout.flush()
print(f"[appstream-gen] WARNUNG: {msg}", file=sys.stderr, flush=True)
def info(msg):
print(f"[appstream-gen] {msg}", flush=True)
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:
# Das id-Attribut ist seit AppStream 1.0 erwartet; ohne es meldet
# appstreamcli 'developer-id-missing'.
dev_id = entry.get("developer_id") or defaults.get("developer_id")
attrs = {"id": str(dev_id)} if dev_id else {}
dev_el = ET.SubElement(component, "developer", attrs)
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(
"--source",
help="Nur Pakete dieser einen Quelle beruecksichtigen. Fuer Setups "
"mit einem Repo-Verzeichnis pro Quelle.",
)
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"
# Falls kein developer_id gesetzt ist, die ersten beiden Segmente des
# id_prefix verwenden: de.firma.repo -> de.firma
if "developer_id" not in defaults or not defaults.get("developer_id"):
segments = id_prefix.split(".")
if len(segments) >= 2:
defaults = dict(defaults)
defaults["developer_id"] = ".".join(segments[:2])
available = load_available(args.available_packages)
sources = config.get("sources") or []
if not sources:
die("Keine 'sources' in der packages.yaml gefunden.")
if args.source:
sources = [s for s in sources if s.get("name") == args.source]
if not sources:
die(f"Keine Quelle mit dem Namen {args.source!r} in der YAML gefunden.")
info(f"Beschraenke auf Quelle {args.source!r}")
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", "<unbenannt>")
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()