#!/usr/bin/env python3 """ sync_helpers.py - small helper used by sync.sh Reliable YAML parsing and XML escaping in pure bash/sed is a losing game, so these two jobs live here: parse-yaml Reads the "sources:" list from the YAML file, NORMALISES it and prints it as JSON on stdout, so sync.sh can loop over it safely. Normalisation: every entry under "packages:" becomes an object with at least a "name" key, whether it was written as a plain string or as a mapping with extra AppStream metadata. sync.sh can therefore always do p["name"] without caring which form the YAML used. gen-appstream --origin O --yaml F --entries-dir D Builds the AppStream catalog for the combined repo. Metadata precedence, highest first: 1. what packages.yaml says about the package 2. the upstream RPM summary collected by sync.sh into the appstream-entries.tsv files under --entries-dir 3. a generated fallback Only packages that actually made it into the mirror (i.e. that appear in one of the TSV files) get a component - never pulled-in dependencies, and never packages whose download failed. NOTE ON DISCOVER/GNOME SOFTWARE: Components without an icon/screenshots/.desktop file make a package installable and findable via SEARCH, but they will not appear on the curated "Explore" front page or when browsing categories. That is a frontend behaviour for non-desktop-app components and cannot be fixed at the repo-metadata level. Only a real desktop-application component (type: desktop-application + desktop_id: in packages.yaml, which requires the package to ship a .desktop file) shows up there. """ import argparse import glob import json import os import re import sys from xml.sax.saxutils import escape, quoteattr CATALOG_VERSION = "0.14" 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() sys.stderr.write(f"ERROR: {msg}\n") sys.exit(1) def warn(msg): sys.stdout.flush() sys.stderr.write(f"WARNING: {msg}\n") def load_yaml(path): try: import yaml except ImportError: die( "python3-pyyaml (PyYAML) is not installed.\n" "Install it with: dnf install -y python3-pyyaml" ) try: with open(path, "r", encoding="utf-8") as fh: return yaml.safe_load(fh) or {} except OSError as exc: die(f"could not read {path!r}: {exc}") except Exception as exc: # yaml.YAMLError and friends die(f"could not parse {path!r}: {exc}") def normalise_package(entry, source_name, index): """Accept both 'pkgname' and {name: pkgname, display_name: ...}.""" if isinstance(entry, str): return {"name": entry} if isinstance(entry, dict): if not entry.get("name"): die( f"package #{index} in source {source_name!r} has no 'name' key: " f"{entry!r}" ) return dict(entry) die( f"package #{index} in source {source_name!r} must be a string or a " f"mapping, got {type(entry).__name__}" ) def normalise_sources(data): sources = data.get("sources") or [] if not isinstance(sources, list): die("'sources' in YAML is not a list") for i, src in enumerate(sources): if not isinstance(src, dict): die(f"source #{i} is not a mapping") for field in ("name", "baseurl", "packages"): if field not in src: die(f"source #{i} is missing required field '{field}'") if not isinstance(src["packages"], list) or not src["packages"]: die( f"source '{src.get('name')}' has an empty/invalid packages list" ) src["packages"] = [ normalise_package(p, src.get("name"), j) for j, p in enumerate(src["packages"]) ] return sources def cmd_parse_yaml(path): data = load_yaml(path) json.dump(normalise_sources(data), sys.stdout) # --------------------------------------------------------------------------- # AppStream catalog generation # --------------------------------------------------------------------------- def sanitize_id_part(value): part = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-") if not part: die(f"package name yields no usable AppStream id segment: {value!r}") if part[0].isdigit(): part = "pkg-" + part return part def as_list(value): if value is None: return [] if isinstance(value, (list, tuple)): return list(value) return [value] def pick(meta, defaults, key, fallback=None): if meta.get(key) is not None: return meta[key] if defaults.get(key) is not None: return defaults[key] return fallback def read_entries(entries_dir): """Merge every source's appstream-entries.tsv into {name: summary}.""" entries = {} pattern = os.path.join(entries_dir, "*", "appstream-entries.tsv") for path in sorted(glob.glob(pattern)): try: with open(path, encoding="utf-8") as fh: for line in fh: line = line.rstrip("\n") if not line: continue parts = line.split("\t", 1) name = parts[0].strip() summary = parts[1].strip() if len(parts) > 1 else "" if not name: continue if name in entries and entries[name] and summary \ and entries[name] != summary: warn( f"package '{name}' was contributed by more than one " "source with differing summaries - keeping the first" ) continue entries.setdefault(name, summary) except OSError: continue return entries def collect_metadata(yaml_path): """Return (defaults, {pkgname: metadata}, id_prefix, yaml_origin).""" data = load_yaml(yaml_path) appstream_cfg = data.get("appstream") or {} defaults = dict(appstream_cfg.get("defaults") or {}) id_prefix = appstream_cfg.get("id_prefix") or "de.firma.repo" yaml_origin = appstream_cfg.get("origin") if not defaults.get("developer_id"): segments = id_prefix.split(".") if len(segments) >= 2: defaults["developer_id"] = ".".join(segments[:2]) by_name = {} for src in normalise_sources(data): for pkg in src["packages"]: name = pkg["name"] if name in by_name: warn(f"package '{name}' listed more than once - keeping the first") continue by_name[name] = pkg return defaults, by_name, id_prefix, yaml_origin def emit(out, depth, text): out.append(" " * depth + text) def build_component(pkgname, meta, upstream_summary, defaults, id_prefix, out): if meta.get("appstream") is False or meta.get("hidden") is True: return False binaries = as_list(meta.get("binary") or meta.get("binaries")) # A console-application component without trips # appstreamcli's 'console-app-no-binary' check, so only claim that type # when a binary is actually declared. Runtimes and libraries stay # 'generic', which Discover still finds via search. comp_type = pick(meta, defaults, "type") if not comp_type: comp_type = "console-application" if binaries else "generic" comp_id = meta.get("id") or f"{id_prefix}.{sanitize_id_part(pkgname)}" display_name = meta.get("display_name") or pkgname note = pick(meta, defaults, "note", DEFAULT_NOTE) summary = meta.get("summary") or upstream_summary or note emit(out, 1, f"") emit(out, 2, f"{escape(comp_id)}") emit(out, 2, f"{escape(pkgname)}") emit(out, 2, f"{escape(display_name)}") emit(out, 2, f"{escape(summary)}") emit(out, 2, "") body = meta.get("description") wrote_paragraph = False if body: for para in [p.strip() for p in str(body).split("\n\n") if p.strip()]: emit(out, 3, f"

{escape(para)}

") wrote_paragraph = True if note and (not wrote_paragraph or note != summary): emit(out, 3, f"

{escape(note)}

") elif not wrote_paragraph: emit(out, 3, f"

{escape(summary)}

") emit(out, 2, "
") icon = pick(meta, defaults, "icon", "application-x-executable") if icon: emit(out, 2, f'{escape(str(icon))}') categories = as_list(pick(meta, defaults, "categories")) if categories: emit(out, 2, "") for cat in categories: emit(out, 3, f"{escape(str(cat))}") emit(out, 2, "") keywords = as_list(meta.get("keywords")) if keywords: emit(out, 2, "") for kw in keywords: emit(out, 3, f"{escape(str(kw))}") emit(out, 2, "") if binaries: emit(out, 2, "") for binary in binaries: emit(out, 3, f"{escape(str(binary))}") emit(out, 2, "") homepage = meta.get("homepage") or defaults.get("homepage") if homepage: emit(out, 2, f'{escape(str(homepage))}') help_url = meta.get("help_url") or defaults.get("help_url") if help_url: emit(out, 2, f'{escape(str(help_url))}') developer = meta.get("developer") or defaults.get("developer") if developer: dev_id = meta.get("developer_id") or defaults.get("developer_id") attr = f" id={quoteattr(str(dev_id))}" if dev_id else "" emit(out, 2, f"") emit(out, 3, f"{escape(str(developer))}") emit(out, 2, "") project_license = meta.get("project_license") or defaults.get("project_license") if project_license: emit(out, 2, f"{escape(str(project_license))}") # A desktop-application component that Discover cannot launch is worse # than a generic one, so insist on the desktop file id. if comp_type == "desktop-application": desktop_id = meta.get("desktop_id") if desktop_id: emit( out, 2, f'{escape(str(desktop_id))}', ) else: warn( f"{pkgname}: type=desktop-application without 'desktop_id' - " "Discover will not be able to launch it after installation" ) emit(out, 1, "
") return True def cmd_gen_appstream(origin, yaml_path, entries_dir): defaults, by_name, id_prefix, yaml_origin = collect_metadata(yaml_path) entries = read_entries(entries_dir) if not entries: warn( f"no appstream-entries.tsv found under {entries_dir} - the catalog " "will be empty and nothing will show up in Discover" ) effective_origin = origin or yaml_origin or "rpm-mirror" out = [] out.append('') out.append( f'' ) written = 0 for pkgname in sorted(entries): meta = by_name.get(pkgname, {}) if build_component(pkgname, meta, entries[pkgname], defaults, id_prefix, out): written += 1 out.append("") sys.stdout.write("\n".join(out) + "\n") sys.stderr.write( f"gen-appstream: wrote {written} component(s), origin={effective_origin}\n" ) def main(): ap = argparse.ArgumentParser() sub = ap.add_subparsers(dest="cmd", required=True) p1 = sub.add_parser("parse-yaml") p1.add_argument("path") p2 = sub.add_parser("gen-appstream") p2.add_argument("--origin", default="") p2.add_argument("--yaml", required=True) p2.add_argument("--entries-dir", required=True) args = ap.parse_args() if args.cmd == "parse-yaml": cmd_parse_yaml(args.path) elif args.cmd == "gen-appstream": cmd_gen_appstream(args.origin, args.yaml, args.entries_dir) if __name__ == "__main__": main()