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