diff --git a/tools/accept.py b/tools/accept.py new file mode 100644 index 00000000..aa9e6f89 --- /dev/null +++ b/tools/accept.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +import pathlib +import re +import shutil +import sys + +import xml.etree.ElementTree as etree + +from datetime import datetime, timedelta + +from xeplib import load_xepinfos, Status, choose + + +DEFAULT_XEPLIST_PATH = "./build/xeplist.xml" +XEP_FILENAME_RE = re.compile(r"xep-(\d+)\.xml") + + +def get_deferred(accepted): + now = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + threshold = now.replace(year=now.year - 1) + + for number, info in sorted(accepted.items()): + if info["status"] == Status.EXPERIMENTAL and "last_revision" in info: + last_update = info["last_revision"]["date"] + if last_update <= threshold: + yield info + + +BLANK_NUMBER = re.compile("[xX]{4}") +PROTOXEP_STATUS = "ProtoXEP" +EXPERIMENTAL_STATUS = "Experimental" +REVISION_RE = re.compile(r"\s+") +REVISION_TEMPLATE = """ + + {version} + {now:%Y-%m-%d} + XEP Editor ({initials}) + Accepted by vote of {approving_body} on {date}. + """ + + +def accept_xep(number, last_version, + initials, + approving_body, + votedate): + filename = "xep-{:04d}.xml".format(number) + with open(filename, "r") as f: + xep_text = f.read() + + if PROTOXEP_STATUS not in xep_text: + raise ValueError("cannot find experimental status in XEP text") + + # this is so incredibly evil ... + xep_text = xep_text.replace(PROTOXEP_STATUS, EXPERIMENTAL_STATUS, 1) + xep_text, n = BLANK_NUMBER.subn("{:04d}".format(number), + xep_text, + 1) + if n == 0: + raise ValueError("cannot find number placeholder in XEP text") + revision_match = REVISION_RE.search(xep_text) + + version = last_version.split(".") + if len(version) == 1: + version.append("1") + else: + version[1] = str(int(version[1]) + 1) + del version[2:] + + xep_text = ( + xep_text[:revision_match.start()] + + REVISION_TEMPLATE.format( + now=datetime.utcnow(), + version=".".join(version), + initials=initials, + date=votedate, + approving_body=approving_body, + ) + xep_text[revision_match.start():] + ) + + with open(filename, "w") as f: + f.write(xep_text) + f.flush() + + +def isodate(s): + return datetime.strptime(s, "%Y-%m-%d") + + +def get_next_xep_number(accepted): + return max(accepted.keys()) + 1 + + +def main(): + import argparse + + parser = argparse.ArgumentParser( + description="Accept an inbox XEP." + ) + + parser.add_argument( + "-l", "--xeplist", + type=argparse.FileType("rb"), + default=None, + help="XEP list to use (defaults to {})".format(DEFAULT_XEPLIST_PATH) + ) + + parser.add_argument( + "-y", "--yes", + dest="ask", + action="store_false", + help="Assume default answer to all questions.", + default=True, + ) + + parser.add_argument( + "-f", "--force", + dest="force", + action="store_true", + default=False, + help="Force acceptance even if suspicious.", + ) + + parser.add_argument( + "item", + help="Inbox name" + ) + + parser.add_argument( + "votedate", + type=isodate, + help="The date of the vote, in ISO format (%Y-%m-%d)." + ) + + parser.add_argument( + "initials", + help="Your editor initials" + ) + + args = parser.parse_args() + + if args.item.endswith(".xml"): + # strip the path + p = pathlib.Path(args.item) + args.item = p.parts[-1].rsplit(".")[0] + + if args.xeplist is None: + args.xeplist = open(DEFAULT_XEPLIST_PATH, "rb") + + if args.xeplist is not None: + with args.xeplist as f: + tree = etree.parse(f) + accepted, inbox = load_xepinfos(tree) + + try: + xepinfo = inbox[args.item] + except KeyError: + print("no such inbox xep: {!r}".format(args.item), file=sys.stderr) + print("maybe run make build/xeplist.xml first?", file=sys.stderr) + sys.exit(1) + + new_number = get_next_xep_number(accepted) + + new_filename = pathlib.Path(".") / "xep-{:04d}.xml".format(new_number) + inbox_path = pathlib.Path("inbox") / "{}.xml".format(args.item) + if new_filename.exists(): + raise FileExistsError( + "Internal error: XEP file does already exist! ({})".format( + new_filename + ) + ) + if not inbox_path.exists(): + print("inbox file does not exist or is not readable: {}".format( + inbox_path + )) + + if args.ask: + print("I am going to accept:") + print() + print(" Title: {!r}".format(xepinfo["title"])) + print(" Abstract: {!r}".format(xepinfo["abstract"])) + print(" Last Revision: {} ({})".format( + xepinfo["last_revision"]["date"].date(), + xepinfo["last_revision"]["version"], + )) + print() + print("as new XEP-{:04d}.".format(new_number)) + print() + choice = choose("Is this correct? [y]es, [n]o: ", "yn", eof="n") + if choice != "y": + print("aborted at user request") + sys.exit(2) + + shutil.copy(str(inbox_path), str(new_filename)) + + accept_xep(new_number, + xepinfo["last_revision"]["version"], + args.initials, + xepinfo["approver"], + args.votedate.date()) + + +if __name__ == "__main__": + main() diff --git a/tools/send-updates.py b/tools/send-updates.py index 63ead127..bcacbf4f 100755 --- a/tools/send-updates.py +++ b/tools/send-updates.py @@ -12,7 +12,7 @@ from datetime import datetime import xml.etree.ElementTree as etree -from xeplib import Status, Action, load_xepinfos +from xeplib import Status, Action, load_xepinfos, choose DESCRIPTION = """\ @@ -279,30 +279,6 @@ def interactively_extend_smtp_config(config): config.set("smtp", "from", from_) -def choose(prompt, options, *, - eof=EOFError, - keyboard_interrupt=KeyboardInterrupt): - while True: - try: - choice = input(prompt).strip() - except EOFError: - if eof is EOFError: - raise - return eof - except KeyboardInterrupt: - if keyboard_interrupt is KeyboardInterrupt: - raise - return keyboard_interrupt - - if choice not in options: - print("invalid choice. please enter one of: {}".format( - ", ".join(map(str, options)) - )) - continue - - return choice - - def make_smtpconn(config): host = config.get("smtp", "host") port = config.getint("smtp", "port") diff --git a/tools/xeplib.py b/tools/xeplib.py index b7c11244..dc1612ac 100644 --- a/tools/xeplib.py +++ b/tools/xeplib.py @@ -37,6 +37,7 @@ class Action(enum.Enum): UPDATE = "UPDATED" DEPRECATE = "DEPRECATED" LAST_CALL = "LAST CALL" + REJECT = "REJECT" @classmethod def fromstatus(cls, status): @@ -50,6 +51,7 @@ class Action(enum.Enum): Status.DEPRECATED: cls.DEPRECATE, Status.DEFERRED: cls.DEFER, Status.PROPOSED: cls.LAST_CALL, + Status.REJECTED: cls.REJECT, }[status] @@ -143,3 +145,27 @@ def minidom_children(elem): child for child in elem.childNodes if isinstance(child, (xml.dom.minidom.Element)) ] + + +def choose(prompt, options, *, + eof=EOFError, + keyboard_interrupt=KeyboardInterrupt): + while True: + try: + choice = input(prompt).strip() + except EOFError: + if eof is EOFError: + raise + return eof + except KeyboardInterrupt: + if keyboard_interrupt is KeyboardInterrupt: + raise + return keyboard_interrupt + + if choice not in options: + print("invalid choice. please enter one of: {}".format( + ", ".join(map(str, options)) + )) + continue + + return choice