328 lines
11 KiB
Python
328 lines
11 KiB
Python
#!/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() |