Dateien nach "/" hochladen
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
#!/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