3ea2ef2ddf64b7e366af1e8c0e500ea1fe88a4e6 braney Wed Aug 5 10:55:25 2026 -0700 gwEditPage: read and write genomewiki page text from the command line gwUploadFile next door handles images, but it needs python2 and mwclient, neither of which is installed for python3 on hgwdev, and nothing in the tree could edit page text at all. This talks to the MediaWiki API with the standard library alone. The trap it exists to document: two wikis run behind genomewiki.ucsc.edu, the public one at / and the internal Genecats one at /genecats, and they keep separate user databases. An account that works on one may not exist on the other, so the failure reads like a mistyped password when it is really the wrong wiki. Credentials keep gwUploadFile's ~/.gwLogin convention, one line and mode 600, with ~/.gwLogin.genecats for the internal wiki so the first line of ~/.gwLogin stays exactly what gwUploadFile expects to find. A bot password from Special:BotPasswords works in place of an account password and is preferable: scoped to the grants chosen, and revocable on its own. get prints wikitext and put replaces it, requiring a summary and refusing to write an empty page. --dry-run shows the diff and writes nothing, which is worth the habit: it caught two wiki-markup mistakes on its first real use, a blank line between list items ending the list, and text appended past the Category tags landing outside the section it belonged to. Reading needs no account on either wiki, so it does not ask for one. diff --git src/hg/utils/automation/gwEditPage src/hg/utils/automation/gwEditPage new file mode 100755 index 00000000000..2c79d1d4a82 --- /dev/null +++ src/hg/utils/automation/gwEditPage @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""gwEditPage -- read and write genomewiki page text from the command line. + +Companion to gwUploadFile, which does images. That one needs mwclient and +python2, neither of which is a safe bet any more, so this talks to the MediaWiki +API with nothing but the standard library. + +There are two wikis behind genomewiki.ucsc.edu and they have separate accounts: + + genomewiki.ucsc.edu/ the public wiki ("genomewiki") + genomewiki.ucsc.edu/genecats/ the internal wiki ("Genecats") + +A password for one is not a password for the other, which is worth knowing +before reading an authentication failure as a typo. Credentials come from +gwUploadFile's ~/.gwLogin convention: one line, "UserName passWord", mode 600. +The internal wiki reads ~/.gwLogin.genecats, so the first line of ~/.gwLogin +stays exactly what gwUploadFile expects to find there. + +A bot password from Special:BotPasswords works in place of an account password, +and is the better choice: it is scoped to the grants you pick and can be revoked +without touching your real one. Log in as "User@botname" with the generated +password. + +Examples: + gwEditPage get CGI_Build_Process --genecats > page.wiki + vi page.wiki + gwEditPage put CGI_Build_Process --genecats --file page.wiki \\ + --summary "note the nightly hgConfCatalog writer" --diff +""" + +import argparse +import difflib +import http.cookiejar +import json +import os +import stat +import sys +import urllib.parse +import urllib.request + +UA = "gwEditPage/1.0 (UCSC Genome Browser group)" + + +def credentials(path): + """(user, password) from a .gwLogin-style file, refusing loose permissions.""" + path = os.path.expanduser(path) + if not os.path.exists(path): + sys.exit("no credential file %s\n" + "Create it with one line, \"UserName passWord\", mode 600.\n" + "A bot password from Special:BotPasswords is preferred; log in\n" + "as \"User@botname\" with the password it generates." % path) + if stat.S_IMODE(os.lstat(path).st_mode) & 0o077: + sys.exit("%s must be mode 600: it holds a password" % path) + with open(path) as fh: + for line in fh: + if line.startswith("#") or not line.strip(): + continue + parts = line.rstrip("\r\n").split(None, 1) + if len(parts) != 2: + sys.exit("%s: expected \"UserName passWord\" on one line" % path) + return parts[0], parts[1] + sys.exit("%s has no credential line" % path) + + +class Wiki: + def __init__(self, site, scriptpath): + self.api = "https://%s%s/api.php" % (site, scriptpath.rstrip("/")) + jar = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(jar)) + + def call(self, **kw): + kw.setdefault("format", "json") + data = urllib.parse.urlencode(kw).encode() + req = urllib.request.Request(self.api, data=data, + headers={"User-Agent": UA}) + with self.opener.open(req) as resp: + out = json.load(resp) + if "error" in out: + sys.exit("API error: %s" % out["error"].get("info", out["error"])) + return out + + def login(self, user, password): + token = self.call(action="query", meta="tokens", + type="login")["query"]["tokens"]["logintoken"] + res = self.call(action="login", lgname=user, lgpassword=password, + lgtoken=token).get("login", {}) + if res.get("result") != "Success": + reason = res.get("reason") + if isinstance(reason, dict): + reason = reason.get("text", "") + sys.exit("login as %s failed on %s: %s\n" + "Remember the two wikis have separate accounts." + % (user, self.api, reason or res.get("result"))) + # Confirm rather than trust: a cookie problem shows up here, not later. + info = self.call(action="query", meta="userinfo", + uiprop="rights")["query"]["userinfo"] + if "edit" not in (info.get("rights") or []): + sys.exit("logged in as %s but that account has no edit right" + % info.get("name")) + return info.get("name") + + def text(self, title): + """Current wikitext, or None if the page does not exist.""" + res = self.call(action="query", prop="revisions", rvprop="content", + rvslots="main", titles=title, formatversion="2") + pages = res["query"]["pages"] + if not pages or pages[0].get("missing"): + return None + return pages[0]["revisions"][0]["slots"]["main"]["content"] + + def save(self, title, text, summary, minor=False): + token = self.call(action="query", meta="tokens")["query"]["tokens"]["csrftoken"] + args = dict(action="edit", title=title, text=text, summary=summary, + token=token, bot="1", formatversion="2") + if minor: + args["minor"] = "1" + res = self.call(**args).get("edit", {}) + if res.get("result") != "Success": + sys.exit("edit of %s failed: %s" % (title, res)) + return res + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("action", choices=["get", "put"]) + ap.add_argument("title", help="page title, e.g. CGI_Build_Process") + ap.add_argument("--genecats", action="store_true", + help="the internal wiki at /genecats rather than the " + "public one, with its own account") + ap.add_argument("--site", default="genomewiki.ucsc.edu") + ap.add_argument("--login", help="credential file; the default follows " + "--genecats") + ap.add_argument("--file", help="with put: read the new text from here " + "instead of stdin") + ap.add_argument("--summary", help="with put: the edit summary, required") + ap.add_argument("--minor", action="store_true", help="mark the edit minor") + ap.add_argument("--diff", action="store_true", + help="with put: show what would change") + ap.add_argument("--dry-run", dest="dryRun", action="store_true", + help="with put: show the diff and stop") + args = ap.parse_args() + + scriptpath = "/genecats" if args.genecats else "" + login = args.login or ("~/.gwLogin.genecats" if args.genecats + else "~/.gwLogin") + wiki = Wiki(args.site, scriptpath) + + if args.action == "get": + # Reading needs no account on either wiki, so do not ask for one. + text = wiki.text(args.title) + if text is None: + sys.exit("no such page: %s" % args.title) + sys.stdout.write(text) + return 0 + + if not args.summary and not args.dryRun: + sys.exit("put needs --summary") + new = open(args.file).read() if args.file else sys.stdin.read() + if not new.strip(): + sys.exit("refusing to write an empty page") + + old = wiki.text(args.title) + if old is None: + sys.exit("no such page: %s. This tool edits pages that exist; make a " + "new one in the browser first." % args.title) + if old == new: + print("no change", file=sys.stderr) + return 0 + if args.diff or args.dryRun: + sys.stderr.writelines(difflib.unified_diff( + old.splitlines(keepends=True), new.splitlines(keepends=True), + fromfile="%s (live)" % args.title, tofile="%s (new)" % args.title)) + if args.dryRun: + print("dry run, nothing written", file=sys.stderr) + return 0 + + user, password = credentials(login) + who = wiki.login(user, password) + res = wiki.save(args.title, new, args.summary, minor=args.minor) + print("saved %s as %s, revision %s" + % (args.title, who, res.get("newrevid")), file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main())