summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDavid Robillard <d@drobilla.net>2019-10-20 22:13:43 +0200
committerDavid Robillard <d@drobilla.net>2019-10-21 00:46:39 +0200
commita3600d61f63899e28f5208336b2c8371d8b331eb (patch)
tree28a58a1085d913a300bd7b796cfb9f1a454370d0
parent66304cc4e1e10e03630e6a823f2bc9e0ec2c669a (diff)
downloadautowaf-a3600d61f63899e28f5208336b2c8371d8b331eb.tar.gz
autowaf-a3600d61f63899e28f5208336b2c8371d8b331eb.tar.bz2
autowaf-a3600d61f63899e28f5208336b2c8371d8b331eb.zip
Factor out release utilities
-rwxr-xr-xextras/autoship.py593
-rw-r--r--extras/autowaf.py267
2 files changed, 593 insertions, 267 deletions
diff --git a/extras/autoship.py b/extras/autoship.py
new file mode 100755
index 0000000..1b4f403
--- /dev/null
+++ b/extras/autoship.py
@@ -0,0 +1,593 @@
+#!/usr/bin/env python
+
+import sys
+import os
+
+
+def report(msg):
+ sys.stderr.write(msg + "\n")
+
+
+def warn(msg):
+ sys.stderr.write("warning: %s\n" % msg)
+
+
+def error_exit(msg):
+ sys.stderr.write("error: %s\n" % msg)
+ sys.exit(1)
+
+
+def ensure(condition, message):
+ if not condition:
+ error_exit(message)
+
+
+def get_project_info(top=None):
+ """Load wscript to get project information (name, version, and so on)"""
+
+ import importlib
+
+ project_dir = top or os.getcwd()
+ wscript_path = os.path.join(project_dir, "wscript")
+ sys.path.insert(0, os.path.dirname(wscript_path))
+
+ loader = importlib.machinery.SourceFileLoader("wscript", wscript_path)
+ spec = importlib.util.spec_from_loader("wscript", loader)
+ wscript = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(wscript)
+
+ return {
+ "name": wscript.APPNAME,
+ "version": wscript.VERSION,
+ "uri": getattr(wscript, "uri", None),
+ "title": getattr(wscript, "title", wscript.APPNAME.title()),
+ "dist_pattern": wscript.dist_pattern,
+ "post_tags": wscript.post_tags,
+ }
+
+
+def parse_version(revision):
+ """Convert semver string `revision` to a tuple of integers"""
+ return tuple(map(int, revision.split(".")))
+
+
+def is_release_version(version):
+ """Return true if `version` is a stable version number"""
+ if isinstance(version, tuple):
+ return version[len(version) - 1] % 2 == 0
+
+ return is_release_version(parse_version(version))
+
+
+def get_blurb(in_file):
+ """Get the first paragraph of a Markdown file"""
+ with open(in_file, "r") as f:
+ f.readline() # Title
+ f.readline() # Title underline
+ f.readline() # Blank
+
+ out = ""
+ line = f.readline()
+ while len(line) > 0 and line != "\n":
+ out += line.replace("\n", " ")
+ line = f.readline()
+
+ return out.strip()
+
+
+def get_items_markdown(items, indent=""):
+ """Return a list of NEWS entries as a Markdown list"""
+ return "".join([indent + "* %s\n" % item for item in items])
+
+
+def get_release_json(entry):
+ """Return a release description in Gitlab JSON format"""
+ import json
+
+ version = entry["revision"]
+ desc = {
+ "name": "Serd %s" % version,
+ "tag_name": "v%s" % version,
+ "description": get_items_markdown(entry["items"]),
+ "released_at": entry["date"].isoformat(),
+ }
+
+ return json.dumps(desc)
+
+
+def read_text_news(in_file, preserve_timezones=False, dist_pattern=None):
+ """Read NEWS entries"""
+
+ import datetime
+ import email.utils
+ import re
+
+ entries = {}
+ with open(in_file, "r") as f:
+ while True:
+ # Read header line
+ head = f.readline()
+ matches = re.match(r"([^ ]*) \((.*)\) ([a-zA-z]*)", head)
+ if matches is None:
+ break
+
+ e = {
+ "name": matches.group(1),
+ "revision": matches.group(2),
+ "status": matches.group(3),
+ "items": [],
+ }
+
+ semver = parse_version(e["revision"])
+ if is_release_version(semver) and dist_pattern is not None:
+ e["dist"] = dist_pattern % semver
+
+ # Read blank line after header
+ if f.readline() != "\n":
+ raise SyntaxError("expected blank line after NEWS header")
+
+ def add_item(item):
+ if len(item) > 0:
+ e["items"] += [item.replace("\n", " ").strip()]
+
+ # Read entries for this revision
+ item = ""
+ line = f.readline()
+ while line:
+ if line.startswith(" * "):
+ add_item(item)
+ item = line[3:].lstrip()
+ elif line == "\n":
+ add_item(item)
+ break
+ else:
+ item += line.lstrip()
+
+ line = f.readline()
+
+ matches = re.match(r" -- (.*) <(.*)> (.*)", f.readline())
+ date = email.utils.parsedate_to_datetime(matches.group(3))
+ if not preserve_timezones:
+ date = date.astimezone(datetime.timezone.utc)
+
+ e.update(
+ {
+ "date": date,
+ "blamee_name": matches.group(1),
+ "blamee_mbox": matches.group(2),
+ }
+ )
+
+ entries[semver] = e
+
+ # Skip trailing blank line before next entry
+ space = f.readline()
+ if space != "\n" and space != "":
+ raise SyntaxError("expected blank line, not '%s'" % space)
+
+ return entries
+
+
+def write_text_news(entries, news):
+ """Write NEWS in standard Debian changelog format"""
+ import textwrap
+
+ revisions = sorted(entries.keys(), reverse=True)
+ for r in revisions:
+ e = entries[r]
+ summary = "%s (%s) %s" % (e["name"], e["revision"], e["status"])
+ news.write("\n" if r != revisions[0] else "")
+ news.write("%s;\n" % summary)
+
+ for item in e["items"]:
+ wrapped = textwrap.wrap(item, width=74)
+ news.write("\n * " + "\n ".join(wrapped))
+
+ email = e["blamee_mbox"].replace("mailto:", "")
+ author = "%s <%s>" % (e["blamee_name"], email)
+ date = e["date"].strftime("%a, %d %b %Y %H:%M:%S %z")
+ news.write("\n\n -- %s %s\n" % (author, date))
+
+
+def read_ttl_news(name, in_files, top_entries=None, dist_pattern=None):
+ """Read news entries from Turtle"""
+
+ import datetime
+ 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#")
+ g = rdflib.ConjunctiveGraph()
+
+ # Parse input files
+ for i in in_files:
+ g.parse(i, format="turtle")
+
+ proj = g.value(None, rdf.type, doap.Project)
+ for f in g.triples([proj, rdfs.seeAlso, None]):
+ if f[2].endswith(".ttl"):
+ g.parse(f[2], format="turtle")
+
+ entries = {}
+ for r in g.triples([proj, doap.release, None]):
+ release = r[2]
+ revision = g.value(release, doap.revision, None)
+ date = g.value(release, doap.created, None)
+ blamee = g.value(release, dcs.blame, None)
+ changeset = g.value(release, dcs.changeset, None)
+ dist = g.value(release, doap["file-release"], None)
+
+ semver = parse_version(revision)
+ if not dist:
+ if dist_pattern is not None:
+ dist = dist_pattern % semver
+ else:
+ warn("No file release for %s %s" % (proj, revision))
+
+ if revision and date and blamee and changeset:
+ status = "stable" if is_release_version(revision) else "unstable"
+ iso_date = datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z")
+ e = {
+ "name": name,
+ "revision": str(revision),
+ "date": iso_date,
+ "status": status,
+ "items": [],
+ }
+
+ if dist is not None:
+ e["dist"] = dist
+
+ for i in g.triples([changeset, dcs.item, None]):
+ item = str(g.value(i[2], rdfs.label, None))
+ e["items"] += [item]
+ if dist and top_entries is not None:
+ if dist not in top_entries:
+ top_entries[dist] = {"items": []}
+ top_entries[dist]["items"] += ["%s: %s" % (name, item)]
+
+ e["blamee_name"] = str(g.value(blamee, foaf.name, None))
+ e["blamee_mbox"] = str(g.value(blamee, foaf.mbox, None))
+
+ entries[semver] = e
+ else:
+ warn("Ignored incomplete %s release description" % name)
+
+ return entries
+
+
+def write_ttl_news(entries, out_file, template=None, subject_uri=None):
+ """Write NEWS in Turtle format"""
+ import rdflib
+ import rdflib.namespace
+ import rdflib.resource
+ import datetime
+
+ # Set up namespaces and make a graph for the output
+ 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#")
+ rdf = rdflib.Namespace("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
+ xsd = rdflib.Namespace("http://www.w3.org/2001/XMLSchema#")
+ g = rdflib.ConjunctiveGraph()
+ ns = rdflib.namespace.NamespaceManager(g)
+ ns.bind("doap", doap)
+ ns.bind("dcs", dcs)
+
+ # Load given template file
+ if template is not None:
+ g.load(template, format="turtle")
+
+ if subject_uri is not None:
+ # Use given subject uri
+ subject = rdflib.URIRef(subject_uri)
+ g.add((subject, rdf.type, doap.Project))
+ else:
+ # Find project URI to use as subject, and optionally the maintainer
+ subject = g.value(None, rdf.type, doap.Project)
+ ensure(subject is not None, "Unable to find project URI for subject")
+
+ maintainer = g.value(subject, doap.maintainer, None)
+
+ for r, e in entries.items():
+ semver = parse_version(e["revision"])
+ ver_string = "%03d%03d%03d" % semver
+
+ release = rdflib.BNode("r%s" % ver_string)
+ g.add((subject, doap.release, release))
+ g.add((release, doap.revision, rdflib.Literal(e["revision"])))
+
+ if "dist" in e:
+ g.add((release, doap["file-release"], rdflib.URIRef(e["dist"])))
+
+ utc_date = e["date"].astimezone(datetime.timezone.utc)
+ date_str = utc_date.strftime("%Y-%m-%dT%H:%M:%S") + "Z"
+ time = rdflib.Literal(date_str, datatype=xsd.dateTime, normalize=False)
+ g.add((release, doap.created, time))
+
+ if maintainer is not None:
+ g.add((release, dcs.blame, maintainer))
+
+ changeset = rdflib.BNode("c%s" % ver_string)
+ g.add((release, dcs.changeset, changeset))
+ for index, item in enumerate(e["items"]):
+ item_node = rdflib.BNode("i%s%08d" % (ver_string, index))
+ g.add((changeset, dcs.item, item_node))
+ g.add((item_node, rdfs.label, rdflib.Literal(item)))
+
+ g.serialize(out_file, format="turtle")
+
+
+def read_news(path=None, format="NEWS", unsorted=False, utc=True, top=None):
+ """Read news in either text changelog or Turtle format"""
+
+ if format == "NEWS" and path is None:
+ path = os.path.join(top or "", "NEWS")
+
+ top = top or os.path.dirname(path)
+ info = get_project_info(top)
+ dist_pattern = info.get("dist_pattern", None)
+
+ if format == "NEWS":
+ entries = read_text_news(path, not utc, dist_pattern)
+ else:
+ ensure(path is not None, "Input path must be given for Turtle input")
+ entries = read_ttl_news(info["name"], [path])
+
+ if not unsorted:
+ for r, e in entries.items():
+ e["items"] = list(sorted(e["items"]))
+
+ return entries
+
+
+def write_news_file(entries, news, format, template, subject):
+ """Write news entries to a file object"""
+ if format == "NEWS":
+ write_text_news(entries, news)
+ else:
+ write_ttl_news(entries, news, template, subject)
+
+
+def write_news(entries, news, format="NEWS", template=None, subject=None):
+ """Write news entries to a file object or path"""
+ if isinstance(news, str):
+ with open(news, "w" if format == "NEWS" else "wb") as f:
+ write_news_file(entries, f, format, template, subject)
+ else:
+ write_news_file(entries, news, format, template, subject)
+
+
+def news_command():
+ ap = argparse.ArgumentParser(description="Generate NEWS file")
+ ap.add_argument("out_path", help="news output file")
+ ap.add_argument("--in-path", help="input file")
+ ap.add_argument("--unsorted", action="store_true", help="don't sort items")
+ ap.add_argument("--in-format", default="NEWS", choices=["NEWS", "turtle"])
+ ap.add_argument("--timezones", action="store_true", help="keep timezones")
+
+ args = ap.parse_args(sys.argv[2:])
+ entries = read_news(
+ args.in_path, args.in_format, args.unsorted, args.timezones
+ )
+
+ with open(args.out_path, "w") as news:
+ write_news(entries, news)
+
+
+def ttl_news_command():
+ ap = argparse.ArgumentParser(description="Generate Turtle changeset")
+ ap.add_argument("--in-path", help="news input file")
+ ap.add_argument("out_path", help="news output file")
+ ap.add_argument("--template")
+ ap.add_argument("--unsorted", action="store_true", help="don't sort items")
+ ap.add_argument("--in-format", default="NEWS", choices=["NEWS", "turtle"])
+
+ args = ap.parse_args(sys.argv[2:])
+ info = get_project_info()
+ entries = read_news(args.in_path, args.in_format, info["dist_pattern"])
+
+ write_ttl_news(
+ entries, args.out_path, template=args.template, subject_uri=info["uri"]
+ )
+
+
+def write_posts(entries, out_dir, meta={}):
+ """Write news posts in Pelican Markdown format"""
+ import datetime
+
+ report("Writing posts to %s" % out_dir)
+
+ info = get_project_info()
+ description = get_blurb("README.md")
+ title = info["title"]
+ meta["Tags"] = ", ".join(info["post_tags"])
+ meta["Author"] = meta.get("Author", os.getenv("USER"))
+
+ try:
+ os.mkdir(out_dir)
+ except Exception:
+ pass
+
+ for r, e in entries.items():
+ name = e["name"]
+ revision = e["revision"]
+ if "dist" not in e:
+ warn("No file release for %s %s" % (name, revision))
+ continue
+
+ date = e["date"].astimezone(datetime.timezone.utc)
+ date_str = date.strftime("%Y-%m-%d")
+ datetime_str = date.strftime("%Y-%m-%d %H:%M")
+ slug_version = revision.replace(".", "-")
+ filename = "%s-%s-%s.md" % (date_str, name, slug_version)
+
+ with open(os.path.join(out_dir, filename), "w") as post:
+ slug = "%s-%s" % (name, slug_version)
+ post.write("Title: %s %s\n" % (title, revision))
+ post.write("Date: %s\n" % datetime_str)
+ post.write("Slug: %s\n" % slug)
+ for k in sorted(meta.keys()):
+ post.write("%s: %s\n" % (k, meta[k]))
+
+ url = e["dist"]
+ link = "[%s %s](%s)" % (title, revision, url)
+ post.write("\n%s has been released." % link)
+ post.write(" " + description + "\n")
+
+ if e["items"] != ["Initial release"]:
+ post.write("\nChanges:\n\n")
+ post.write(get_items_markdown(e["items"], indent=" "))
+
+
+def posts_command():
+ ap = argparse.ArgumentParser(description="Generate Pelican posts")
+ ap.add_argument("out_dir", help="output directory")
+ ap.add_argument("--author", help="post author")
+ ap.add_argument("--in-path", help="input file")
+ ap.add_argument("--in-format", default="NEWS", choices=["NEWS", "turtle"])
+ ap.add_argument("--title", help="Title for posts")
+
+ args = ap.parse_args(sys.argv[2:])
+ info = get_project_info()
+ entries = read_news(args.in_path, args.in_format, info["dist_pattern"])
+ meta = {"Author": args.author} if args.author else {}
+
+ write_posts(entries, args.out_dir, meta)
+
+
+def release(args, posts_dir=None, remote_dist_dir=None, dist_name=None):
+ import json
+ import os
+ import shlex
+ import subprocess
+
+ def run_cmd(cmd):
+ if args.dry_run:
+ print(" ".join([shlex.quote(i) for i in cmd]))
+
+ info = get_project_info()
+ name = info["name"]
+ title = info["title"]
+ version = info["version"]
+ semver = parse_version(version)
+ dry_run = args.dry_run
+
+ # Check that this is a release version first of all
+ ensure(is_release_version(semver), "%s is an unstable version" % version)
+ report("Releasing %s %s" % (name, version))
+
+ # Check that NEWS is up to date
+ entries = read_news()
+ ensure(semver in entries, "%s has no NEWS entries" % version)
+
+ # Check that working copy is up to date
+ fetch_cmd = ["git", "fetch", "--dry-run"]
+ fetch_status = subprocess.check_output(fetch_cmd).decode("utf-8")
+ ensure(len(fetch_status) == 0, "Local copy is out of date")
+
+ # Remove distribution if one was already built
+ dist = "%s-%s.tar.bz2" % (dist_name or name.lower(), version)
+ sig = dist + ".sig"
+ try:
+ os.remove(dist)
+ os.remove(sig)
+ except Exception:
+ pass
+
+ # Check that working copy is clean
+ branch_cmd = ["git", "rev-parse", "--abbrev-ref", "HEAD"]
+ branch = subprocess.check_output(branch_cmd).decode('ascii').strip()
+ status_cmd = ["git", "status", "--porcelain", "-b", "--ignore-submodules"]
+ status = subprocess.check_output(status_cmd).decode("utf-8")
+ sys.stdout.write(status)
+ expected_status = "## %s...origin/%s\n" % (branch, branch)
+ ensure(status == expected_status, "Working copy is dirty")
+
+ # Fetch project description and ensure it matches
+ url = "https://%s/api/v4/projects/%s%%2F%s" % (args.lab, args.group, name)
+ desc_cmd = ["curl", "-HPRIVATE-TOKEN: " + args.token, url]
+ desc = json.loads(subprocess.check_output(desc_cmd))
+ proj_name = desc["name"]
+ ensure(proj_name == name, "Project name '%s' != '%s'" % (proj_name, name))
+
+ # Build distribution
+ run_cmd(["./waf", "configure", "--docs"])
+ run_cmd(["./waf", "build"])
+ run_cmd(["./waf", "distcheck"])
+ ensure(dry_run or os.path.exists(dist), "%s was not created" % dist)
+
+ # Sign distribution
+ run_cmd(["gpg", "-b", dist])
+ ensure(dry_run or os.path.exists(sig), "%s.sig was not created" % dist)
+ run_cmd(["gpg", "--verify", sig])
+
+ # Tag release
+ tag = "v" + version
+ run_cmd(["git", "tag", "-s", tag, "-m", "%s %s" % (title, version)])
+ run_cmd(["git", "push", "--tags"])
+
+ # Generate posts
+ if posts_dir is not None:
+ write_posts(entries, posts_dir)
+
+ # Upload distribution and signature
+ if remote_dist_dir is not None:
+ run_cmd(["scp", dist, os.path.join(remote_dist_dir, dist)])
+ run_cmd(["scp", sig, os.path.join(remote_dist_dir, sig)])
+
+ # Post Gitlab release
+ post_cmd = [
+ "curl",
+ "-XPOST",
+ "-HContent-Type: application/json",
+ "-HPRIVATE-TOKEN: " + args.token,
+ "-d" + get_release_json(entries[semver]),
+ "https://gitlab.com/api/v4/projects/drobilla%2Fserd/releases",
+ ]
+ run_cmd(post_cmd)
+
+ report("Released %s %s" % (name, version))
+ report("Remember to upload posts and push to other remotes!")
+
+def release_command():
+ ap = argparse.ArgumentParser(description="Release project")
+ ap.add_argument("group", help="Gitlab user or group for project")
+ ap.add_argument("token", help="Gitlab access token")
+ ap.add_argument("--lab", default="gitlab.com", help="Gitlab instance")
+ ap.add_argument("--dry-run", action="store_true", help="do nothing")
+ ap.add_argument("--posts", help="Pelican posts directory")
+ ap.add_argument("--scp", help="SSH path to distribution directory")
+ args = ap.parse_args(sys.argv[2:])
+
+ release(args, posts_dir=args.posts, remote_dist_dir=args.scp)
+
+
+if __name__ == "__main__":
+ import argparse
+
+ # Get list of command names from handler functions for help text
+ global_names = list(globals().keys())
+ handlers = [k[0:-8] for k in global_names if k.endswith("_command")]
+
+ # Run simple top level argument parser to get command name
+ ap = argparse.ArgumentParser(
+ description="Automatic release building",
+ epilog="commands: " + " ".join(handlers),
+ )
+ ap.add_argument("command", help="Subcommand to run")
+ args = ap.parse_args(sys.argv[1:2])
+
+ # Check that a handler is defined for the given command
+ function_name = args.command + "_command"
+ if function_name not in globals():
+ sys.stderr.write("error: Unknown command '%s'\n" % args.command)
+ ap.print_help()
+ sys.exit(1)
+
+ # Dispatch to command handler
+ globals()[function_name]()
+ sys.exit(0)
diff --git a/extras/autowaf.py b/extras/autowaf.py
index fc1d488..2f2469b 100644
--- a/extras/autowaf.py
+++ b/extras/autowaf.py
@@ -1164,273 +1164,6 @@ def run_ldconfig(ctx):
except Exception:
pass
-def get_rdf_news(name,
- in_files,
- top_entries=None,
- extra_entries=None,
- dev_dist=None):
- import rdflib
- from time import strptime
-
- 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#')
- m = rdflib.ConjunctiveGraph()
-
- try:
- for i in in_files:
- m.parse(i, format='n3')
- except Exception:
- Logs.warn('Error parsing data, unable to generate NEWS')
- return
-
- proj = m.value(None, rdf.type, doap.Project)
- for f in m.triples([proj, rdfs.seeAlso, None]):
- if f[2].endswith('.ttl'):
- m.parse(f[2], format='n3')
-
- entries = {}
- for r in m.triples([proj, doap.release, None]):
- release = r[2]
- revision = m.value(release, doap.revision, None)
- date = m.value(release, doap.created, None)
- blamee = m.value(release, dcs.blame, None)
- changeset = m.value(release, dcs.changeset, None)
- dist = m.value(release, doap['file-release'], None)
-
- if not dist:
- Logs.warn('No file release for %s %s' % (proj, revision))
- dist = dev_dist
-
- if revision and date and blamee and changeset:
- entry = {}
- entry['name'] = str(name)
- entry['revision'] = str(revision)
- entry['date'] = strptime(str(date), '%Y-%m-%d')
- entry['status'] = 'stable' if dist != dev_dist else 'unstable'
- entry['dist'] = str(dist)
- entry['items'] = []
-
- for i in m.triples([changeset, dcs.item, None]):
- item = str(m.value(i[2], rdfs.label, None))
- entry['items'] += [item]
- if dist and top_entries is not None:
- if not str(dist) in top_entries:
- top_entries[str(dist)] = {'items': []}
- top_entries[str(dist)]['items'] += [
- '%s: %s' % (name, item)]
-
- if extra_entries and dist:
- for i in extra_entries[str(dist)]:
- entry['items'] += extra_entries[str(dist)]['items']
-
- entry['blamee_name'] = str(m.value(blamee, foaf.name, None))
- entry['blamee_mbox'] = str(m.value(blamee, foaf.mbox, None))
-
- entries[(str(date), str(revision))] = entry
- else:
- Logs.warn('Ignored incomplete %s release description' % name)
-
- return entries
-
-def write_news(entries, out_file):
- import textwrap
- from time import strftime
-
- if len(entries) == 0:
- return
-
- revisions = sorted(entries.keys(), reverse=True)
- news = open(out_file, 'w')
- for r in revisions:
- entry = entries[r]
- if r != revisions[0]:
- news.write('\n')
-
- news.write('%s (%s) %s;\n' % (entry['name'], entry['revision'], entry['status']))
- for item in entry['items']:
- wrapped = textwrap.wrap(item, width=74)
- news.write('\n * ' + '\n '.join(wrapped))
-
- news.write('\n\n --')
- news.write(' %s <%s>' % (entry['blamee_name'],
- entry['blamee_mbox'].replace('mailto:', '')))
-
- news.write(' %s\n' % (
- entry['date'].strftime('%a, %d %b %Y %H:%M:%S %z')))
-
- news.close()
-
-def get_items_markdown(items, indent=""):
- return ''.join([indent + '* %s\n' % item for item in items])
-
-def write_posts(entries, meta, out_dir, status='stable'):
- "Write news posts in Pelican Markdown format"
- import datetime
- try:
- os.mkdir(out_dir)
- except Exception:
- pass
-
- for r, entry in entries.items():
- revision = entry['revision']
- if entry['status'] != status:
- continue
-
- date = entry['date'].astimezone(datetime.timezone.utc)
- date_str = date.strftime('%Y-%m-%d')
- datetime_str = date.strftime('%Y-%m-%d %H:%M')
-
- path = os.path.join(out_dir, '%s-%s-%s.md' % (
- date_str, entry['name'], revision.replace('.', '-')))
- post = open(path, 'w')
- title = entry['title'] if 'title' in entry else entry['name']
- post.write('Title: %s %s\n' % (title, revision))
- post.write('Date: %s\n' % datetime_str)
- post.write('Slug: %s-%s\n' % (entry['name'], revision.replace('.', '-')))
- for k in meta:
- post.write('%s: %s\n' % (k, meta[k]))
- post.write('\n')
-
- url = entry['dist']
- if entry['status'] == status:
- post.write('[%s %s](%s) has been released.' % (
- (entry['name'], revision, url)))
-
- if 'description' in entry:
- post.write(' ' + entry['description'])
-
- post.write('\n')
- if (len(entry['items']) > 0 and
- not (len(entry['items']) == 1 and
- entry['items'][0] == 'Initial release')):
- post.write('\nChanges:\n\n')
- post.write(get_items_markdown(entry['items'], indent=" "))
-
- post.close()
-
-def get_blurb(in_file):
- "Get the first paragraph of a Markdown formatted file, skipping the title"
- f = open(in_file, 'r')
- f.readline() # Title
- f.readline() # Title underline
- f.readline() # Blank
- out = ''
- line = f.readline()
- while len(line) > 0 and line != '\n':
- out += line.replace('\n', ' ')
- line = f.readline()
- return out.strip()
-
-def get_news(in_file, entry_props={}):
- """Get NEWS entries in the format expected by write_posts().
-
- Properties that should be set on every entry can be passed in
- `entry_props`. If `entry_props` has a 'dist_pattern' value, it is used to
- set the 'dist' entry of entries by substituting the version number.
- """
-
- import re
- import email.utils
-
- f = open(in_file, 'r')
- entries = {}
- while True:
- # Read header line
- head = f.readline()
- matches = re.compile(r'([^ ]*) \((.*)\) ([a-zA-z]*);').match(head)
- if matches is None:
- break
-
- entry = {}
- entry['name'] = matches.group(1)
- entry['revision'] = matches.group(2)
- entry['status'] = matches.group(3)
- entry['items'] = []
- if 'dist_pattern' in entry_props:
- entry['dist'] = entry_props['dist_pattern'] % entry['revision']
-
- # Read blank line after header
- if f.readline() != '\n':
- raise SyntaxError('expected blank line after NEWS header')
-
- def add_item(item):
- if len(item) > 0:
- entry['items'] += [item.replace('\n', ' ').strip()]
-
- # Read entries for this revision
- item = ''
- line = ''
- while line != '\n':
- line = f.readline()
- if line.startswith(' * '):
- add_item(item)
- item = line[3:].lstrip()
- else:
- item += line.lstrip()
- add_item(item)
-
- # Read footer line
- foot = f.readline()
- matches = re.compile(' -- (.*) <(.*)> (.*)').match(foot)
- entry['date'] = email.utils.parsedate_to_datetime(matches.group(3))
- entry['blamee_name'] = matches.group(1)
- entry['blamee_mbox'] = matches.group(2)
- entry.update(entry_props)
-
- key = tuple(map(int, entry['revision'].split('.')))
- entries[key] = entry
-
- # Skip trailing blank line before next entry
- f.readline()
-
- f.close()
-
- return entries
-
-def news_to_posts(news_file, entry_props, post_meta, default_post_dir):
- post_dir = os.getenv('POST_DIR')
- if not post_dir:
- post_dir = default_post_dir
- sys.stderr.write('POST_DIR not set in environment, writing to %s\n' % post_dir)
- else:
- sys.stderr.write('writing posts to %s\n' % post_dir)
-
- entries = get_news(news_file, entry_props)
- write_posts(entries, post_meta, post_dir)
-
def run_script(cmds):
for cmd in cmds:
subprocess.check_call(cmd, shell=True)
-
-def release(name, version, dist_name=None):
- if dist_name is None:
- dist_name = name.lower()
-
- dist = '%s-%s.tar.bz2' % (dist_name or name.lower(), version)
- try:
- os.remove(dist)
- os.remove(dist + '.sig')
- except Exception:
- pass
-
- status = subprocess.check_output('git status --porcelain', shell=True)
- if status:
- Logs.error('error: git working copy is dirty\n' + status)
- raise Exception('git working copy is dirty')
-
- head = subprocess.check_output('git show -s --oneline', shell=True)
- head_summary = head[8:].strip().lower()
- expected_summary = '%s %s' % (name.lower(), version)
- if head_summary != expected_summary:
- raise Exception('latest commit "%s" does not match "%s"' % (
- head_summary, expected_summary))
-
- run_script(['./waf configure --docs',
- './waf',
- './waf distcheck',
- './waf posts',
- 'gpg -b %s' % dist,
- 'git tag -s v%s -m "%s %s"' % (version, name, version)])