From d4a970f6962dda28133290194832b726b566ddab Mon Sep 17 00:00:00 2001 From: David Robillard Date: Thu, 7 Jul 2022 18:59:06 -0400 Subject: Switch to meson build system --- scripts/lv2_write_news.py | 258 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100755 scripts/lv2_write_news.py (limited to 'scripts/lv2_write_news.py') diff --git a/scripts/lv2_write_news.py b/scripts/lv2_write_news.py new file mode 100755 index 0000000..6ce935c --- /dev/null +++ b/scripts/lv2_write_news.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 + +# Copyright 2020-2022 David Robillard +# SPDX-License-Identifier: ISC + +""" +Write a NEWS file from RDF data. + +The output is in Debian changelog format, which can be parsed by +dpkg-parsechangelog, among other things. +""" + +import argparse +import os +import sys +import datetime +import textwrap +import urllib +import re + +import rdflib + +doap = rdflib.Namespace("http://usefulinc.com/ns/doap#") +dcs = rdflib.Namespace("http://ontologi.es/doap-changeset#") +rdfs = rdflib.Namespace("http://www.w3.org/2000/01/rdf-schema#") +foaf = rdflib.Namespace("http://xmlns.com/foaf/0.1/") +rdf = rdflib.Namespace("http://www.w3.org/1999/02/22-rdf-syntax-ns#") + + +def _is_release_version(version): + "Return true if `version` is a stable version number." + + if len(version) not in [2, 3] or version[0] == 0: + return False + + minor = version[len(version) - 2] + micro = version[len(version) - 1] + + return micro % 2 == 0 and (len(version) == 2 or minor % 2 == 0) + + +def _parse_datetime(string): + "Parse string as either a datetime or a date." + + try: + return datetime.datetime.strptime(string, "%Y-%m-%dT%H:%M:%S%z") + except ValueError: + return datetime.datetime.strptime(string, "%Y-%m-%d") + + +def _release_entry(graph, release): + "Return a news entry for a release." + + revision = graph.value(release, doap.revision, None) + date = graph.value(release, doap.created, None) + blamee = graph.value(release, dcs.blame, None) + changeset = graph.value(release, dcs.changeset, None) + dist = graph.value(release, doap["file-release"], None) + + if not revision or not date or not blamee or not changeset: + return None + + version = tuple(map(int, revision.split("."))) + + entry = { + "version": version, + "revision": str(revision), + "date": _parse_datetime(date), + "status": "stable" if _is_release_version(version) else "unstable", + "items": [], + } + + if dist is not None: + entry["dist"] = dist + + for j in graph.triples([changeset, dcs.item, None]): + item = str(graph.value(j[2], rdfs.label, None)) + entry["items"] += [item] + + entry["blamee_name"] = str(graph.value(blamee, foaf.name, None)) + entry["blamee_mbox"] = str(graph.value(blamee, foaf.mbox, None)) + return entry + + +def _project_entries(graph, project): + "Return a map from version to news entries for a project" + + entries = {} + for link in graph.triples([project, doap.release, None]): + entry = _release_entry(graph, link[2]) + if entry is not None: + entries[entry["version"]] = entry + else: + sys.stderr.write(f"warning: Ignored partial {project} release\n") + + return entries + + +def _read_turtle_news(in_files): + "Read news entries from Turtle." + + graph = rdflib.Graph() + + # Parse input files + for i in in_files: + graph.parse(i) + + # Read news for every project in the data + projects = {t[0] for t in graph.triples([None, rdf.type, doap.Project])} + entries_by_project = {} + for project in projects: + # Load any associated files + for uri in graph.triples([project, rdfs.seeAlso, None]): + if uri[2].endswith(".ttl"): + graph.parse(uri[2]) + + # Use the symbol from the URI as a name, or failing that, the doap:name + name = os.path.basename(urllib.parse.urlparse(str(project)).path) + if not name: + name = graph.value(project, doap.name, None) + + entries = _project_entries(graph, project) + for _, entry in entries.items(): + entry["name"] = name + + entries_by_project[str(project)] = entries + + return entries_by_project + + +def _write_news_item(out, item): + "Write a single item (change) in NEWS format." + + out.write("\n * " + "\n ".join(textwrap.wrap(item, width=74))) + + +def _write_news_entry(out, entry): + "Write an entry (version) to out in NEWS format." + + # Summary header + summary = f'{entry["name"]} ({entry["revision"]}) {entry["status"]}' + out.write(f"{summary}; urgency=medium\n") + + # Individual change items + for item in sorted(entry["items"]): + _write_news_item(out, item) + + # Trailer line + mbox = entry["blamee_mbox"].replace("mailto:", "") + author = f'{entry["blamee_name"]} <{mbox}>' + date = entry["date"] + if date.tzinfo is None: # Assume UTC (dpkg-parsechangelog requires it) + date = date.strftime("%a, %d %b %Y %H:%M:%S +0000") + else: + date = date.strftime("%a, %d %b %Y %H:%M:%S %z") + + out.write(f"\n\n -- {author} {date}\n") + + +def _write_single_project_news(out, entries): + "Write a NEWS file for entries of a single project to out." + + revisions = sorted(entries.keys(), reverse=True) + for revision in revisions: + entry = entries[revision] + out.write("\n" if revision != revisions[0] else "") + _write_news_entry(out, entry) + + +def _write_meta_project_news(out, top_project, entries_by_project): + "Write a NEWS file for a meta-project that contains others." + + top_name = os.path.basename(urllib.parse.urlparse(str(top_project)).path) + release_pattern = rf".*/{top_name}-([0-9\.]*).tar.bz2" + + # Pop the entries for the top project + top_entries = entries_by_project.pop(top_project) + + # Add items from the other projects to the corresponding top entry + for _, entries in entries_by_project.items(): + for version, entry in entries.items(): + if "dist" in entry: + match = re.match(release_pattern, entry["dist"]) + if match: + version = tuple(map(int, match.group(1).split("."))) + for item in entry["items"]: + top_entries[version]["items"] += [ + f'{entry["name"]}: {item}' + ] + + for version in sorted(top_entries.keys(), reverse=True): + out.write("\n" if version != max(top_entries.keys()) else "") + _write_news_entry(out, top_entries[version]) + + +def _write_text_news(out, entries_by_project, top_project=None): + "Write NEWS in standard Debian changelog format." + + if len(entries_by_project) > 1: + if top_project is None: + sys.stderr.write("error: --top is required for multi-projects\n") + return 1 + + _write_meta_project_news(out, top_project, entries_by_project) + else: + project = next(iter(entries_by_project)) + _write_single_project_news(out, entries_by_project[project]) + + return 0 + + +if __name__ == "__main__": + ap = argparse.ArgumentParser( + usage="%(prog)s [OPTION]... DATA_FILE...", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + ap.add_argument( + "-o", + "--output", + metavar="OUTPUT_FILE", + help="output file path", + ) + + ap.add_argument( + "-t", + "--top-project", + metavar="OUTPUT_FILE", + help="URI of parent meta-project with file releases", + ) + + ap.add_argument( + "DATA_FILE", + nargs="+", + help="path to a Turtle file with release data", + ) + + args = ap.parse_args(sys.argv[1:]) + + if not args.output and "MESON_DIST_ROOT" in os.environ: + args.output = os.path.join(os.getenv("MESON_DIST_ROOT"), "NEWS") + + if not args.output: + sys.exit( + _write_text_news( + sys.stdout, _read_turtle_news(args.DATA_FILE), args.top_project + ) + ) + else: + with open(args.output, "w", encoding="utf-8") as output_file: + sys.exit( + _write_text_news( + output_file, + _read_turtle_news(args.DATA_FILE), + args.top_project, + ) + ) -- cgit v1.2.1