docker/syncer/appstream-gen.py gelöscht
This commit is contained in:
@@ -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()
|
|
||||||
Reference in New Issue
Block a user