26a65c8679a0d603e5d11c47f95e5e1b2704e017 jcasper Wed Aug 19 04:12:46 2026 -0700 Adding a script for processing MethBase2 hub files to convert them to local native tracks, refs #36320 diff --git src/hg/utils/otto/methbase2/processMethbaseHub src/hg/utils/otto/methbase2/processMethbaseHub new file mode 100755 index 00000000000..30318f3f06f --- /dev/null +++ src/hg/utils/otto/methbase2/processMethbaseHub @@ -0,0 +1,623 @@ +#!/usr/bin/env python3 +""" +Convert a MethBase2 track hub delivery from the Smith Lab (USC) into trackDb .ra +files suitable for inclusion in our own trackDb file set. + +The hub arrives as a directory of per-assembly subdirectories, each holding a +trackDb.txt, an mb2__metadata.tsv, and an mb2__colors.json. Three edits +are needed before those stanzas can live in our tree: + + 1. bigDataUrl points at http://smithlab.usc.edu; we serve a mirror under + hgdownload. + 2. metaDataUrl and colorSettingsUrl are bare relative filenames. hgTrackUi + whitelists exactly those two settings and compares a fetch request against + the literal setting value (see fileUrlMatchesTrackSetting() in + hg/hgTrackUi/hgTrackUi.c), so they have to name something resolvable. We + point them at /gbdb//methBase2/, which copyMethbaseFiles.sh populates + with symlinks back to this directory. + 3. The composite stanzas have no parent. We hang them off a dnaMethylation + supertrack that whoever installs the files defines by hand -- that is also + where the "group" setting lives. + +hg38 and mm39 are additionally too large to commit as a single .ra, so any +output over --max-ra-size is split on stanza boundaries into a chain of +methbase2.ra, methbase2_2.ra, ... files tied together with include lines. + +The .tsv and .json files are never modified -- they are copied here byte for +byte, and the /gbdb symlinks point at these copies. That makes the destination +directory a permanent artifact: deleting or moving it breaks every track. Name +it accordingly (a dated directory you keep alongside past versions), rather than +treating it as scratch. + +A copyMethbaseFiles.sh script is generated into the destination directory to put +everything in its final place, along with a gbdb_manifest listing every /gbdb +path that script sets up, for pushing those files to production. Nothing here +edits any file outside the destination directory. + +Usage: + processMethbaseHub [--max-ra-size MB] +""" + +import argparse +import os +import re +import shutil +import stat +import sys + +# Where we mirror the contributor's bigData files. +HUB_MIRROR_URL = "http://hgdownload.soe.ucsc.edu/hubs/methbase/v2" +SOURCE_HOST = "smithlab.usc.edu" + +# Where the facet metadata and color files are reached from. Keep GBDB_SUBDIR in +# step with copyMethbaseFiles.sh, which creates the symlinks that live there. +GBDB_ROOT = "/gbdb" +GBDB_SUBDIR = "methBase2" + +RA_NAME = "methbase2.ra" + +# List of the /gbdb paths this delivery sets up, for pushing to production. The +# set of metadata and color files changes from one delivery to the next, so a +# fixed list of filenames the way most otto tracks do it will not work here. +MANIFEST_NAME = "gbdb_manifest" + +# Settings whose value is a bare filename in the hub and must become a /gbdb path. +FETCHED_SETTINGS = ("metaDataUrl", "colorSettingsUrl") + +BIG_DATA_URL_RE = re.compile( + r"^(\s*bigDataUrl\s+)https?://" + re.escape(SOURCE_HOST) + r"(?=[/\s]|$)") +ANY_BIG_DATA_URL_RE = re.compile(r"^\s*bigDataUrl\s") + +# Subdirectory names become assembly names, and an assembly name ends up in +# /gbdb paths, in trackDb settings, and -- unquoted -- in the generated shell +# script's assembly list. Anything outside this pattern is not an assembly we +# recognize, so reject it here instead of letting it reach a shell. +DB_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._]*$") + + +def gbdbDir(db): + """The /gbdb directory holding this assembly's metadata and color symlinks.""" + return "%s/%s/%s" % (GBDB_ROOT, db, GBDB_SUBDIR) + + +def raPartName(partNum): + """Filename for part partNum of the split .ra chain. Part 1 is methbase2.ra.""" + if partNum == 1: + return RA_NAME + return "methbase2_%d.ra" % partNum + + +def splitStanzas(text): + """Split trackDb text into stanzas on blank lines. + + Returns a list of stanzas, each a list of lines with no trailing blank. Any + leading comment/blank preamble comes back as the first stanza so it stays at + the top of the file. + """ + stanzas = [] + current = [] + for line in text.splitlines(): + if line.strip() == "": + if current: + stanzas.append(current) + current = [] + else: + current.append(line) + if current: + stanzas.append(current) + return stanzas + + +def transformStanza(lines, db, counts): + """Apply the three trackDb edits to one stanza. Returns the new line list. + + Everything not named here -- track names, shortLabel, longLabel, colors, + priorities -- passes through untouched. + """ + out = [] + isComposite = False + hasParent = False + lastRelationshipIdx = None + + for line in lines: + newLine = line + + if ANY_BIG_DATA_URL_RE.match(line): + newLine, n = BIG_DATA_URL_RE.subn( + r"\1" + HUB_MIRROR_URL + "/" + SOURCE_HOST, line) + if n: + counts["bigDataUrl"] += 1 + else: + counts["unmatchedHost"] += 1 + if len(counts["unmatchedSamples"]) < 5: + counts["unmatchedSamples"].append(line.strip()) + else: + stripped = line.lstrip() + for setting in FETCHED_SETTINGS: + if stripped.startswith(setting) and stripped[len(setting):len(setting) + 1].isspace(): + indent = line[:len(line) - len(stripped)] + value = stripped[len(setting):].strip() + newLine = "%s%s %s/%s" % ( + indent, setting, gbdbDir(db), os.path.basename(value)) + counts[setting] += 1 + break + + words = newLine.split(None, 1) + firstWord = words[0] if words else "" + if firstWord == "compositeTrack": + isComposite = True + if firstWord == "parent": + hasParent = True + # Keep the added parent line next to the other relationship settings + # rather than dangling at the end of the stanza. + if firstWord in ("track", "compositeTrack", "superTrack", "view", "parent"): + lastRelationshipIdx = len(out) + + out.append(newLine) + + if isComposite and not hasParent: + insertAt = lastRelationshipIdx + 1 if lastRelationshipIdx is not None else len(out) + out.insert(insertAt, "parent dnaMethylation") + counts["parentAdded"] += 1 + + return out + + +def writeRaFiles(stanzas, destDbDir, maxBytes): + """Write stanzas out as one .ra, or a chain of them if over maxBytes. + + Splits only on stanza boundaries. Every part but the last ends with an + include line pointing at the next part, and room for that line is reserved + when deciding where to cut. Returns the list of filenames written. + """ + # Each block carries its own trailing blank line so that a part's size is + # just the sum of its blocks -- forgetting the separators is how you end up + # writing files a few KB over the limit. + blocks = ["\n".join(lines) + "\n\n" for lines in stanzas] + + # Longest include line we might have to append. Overestimating just makes + # the cut marginally conservative, which is harmless. + maxIncludeLen = len("include %s\n" % raPartName(len(blocks) + 1)) + + for i, block in enumerate(blocks): + if len(block) + maxIncludeLen > maxBytes: + raise SystemExit( + "ERROR: %s: stanza %d is %d bytes, which does not fit in the %d byte " + "limit even by itself. Raise --max-ra-size." + % (destDbDir, i + 1, len(block), maxBytes)) + + # Group blocks into parts. + parts = [] + current = [] + currentLen = 0 + for block in blocks: + # Assume for now this part will need a trailing include; if it turns out + # to be the last part we just end up slightly under the limit. + if current and currentLen + len(block) + maxIncludeLen > maxBytes: + parts.append(current) + current = [] + currentLen = 0 + current.append(block) + currentLen += len(block) + if current: + parts.append(current) + + # The composite stanza has to be in part 1 so the parent of every subtrack is + # defined before the include chain pulls the rest in. It is first in the + # source, so this should be automatic -- check rather than assume. + def hasComposite(part): + return any(b.startswith("compositeTrack") or "\ncompositeTrack" in b + for b in part) + + if any(hasComposite(p) for p in parts) and not hasComposite(parts[0]): + raise SystemExit( + "ERROR: %s: the composite stanza did not land in part 1. Refusing to " + "write a chain whose parent stanza is not first." % destDbDir) + + written = [] + for i, part in enumerate(parts): + partNum = i + 1 + name = raPartName(partNum) + path = os.path.join(destDbDir, name) + with open(path, "w") as fh: + if partNum < len(parts): + fh.write("".join(part)) + fh.write("include %s\n" % raPartName(partNum + 1)) + else: + fh.write("".join(part).rstrip("\n") + "\n") + written.append(name) + return written + + +def referencedSupportFiles(trackDbPath): + """Filenames named by metaDataUrl/colorSettingsUrl, as {setting: filename}.""" + referenced = {} + with open(trackDbPath) as fh: + for line in fh: + stripped = line.strip() + for setting in FETCHED_SETTINGS: + if stripped.startswith(setting) and \ + stripped[len(setting):len(setting) + 1].isspace(): + value = stripped[len(setting):].strip() + referenced.setdefault(setting, os.path.basename(value)) + return referenced + + +def validateSource(sourceDir, dbs): + """Check every assembly before anything gets written. + + Two ways a delivery can be unusable: it annotates an assembly this machine + does not carry, or it names a metadata/color file it did not ship. Collect + every such problem and report them together -- the file problems get passed + back to the data provider, so a complete list beats one error at a time. + """ + problems = [] + for db in dbs: + srcDbDir = os.path.join(sourceDir, db) + + if not os.path.isdir(os.path.join(GBDB_ROOT, db)): + problems.append( + "%s: no such directory %s/%s on this machine. The hub carries data " + "for an assembly we do not have." % (db, GBDB_ROOT, db)) + + trackDbPath = os.path.join(srcDbDir, "trackDb.txt") + if not os.path.isfile(trackDbPath): + problems.append("%s: %s has no trackDb.txt" % (db, srcDbDir)) + continue + + for setting, filename in sorted(referencedSupportFiles(trackDbPath).items()): + if not os.path.isfile(os.path.join(srcDbDir, filename)): + problems.append( + "%s: trackDb.txt has '%s %s', but %s/%s does not exist. The data " + "provider needs to either include that file in the %s directory or " + "drop the %s setting from %s/trackDb.txt." + % (db, setting, filename, srcDbDir, filename, db, setting, db)) + + if problems: + sys.stderr.write( + "ERROR: %d problem(s) with the hub in %s; nothing was written.\n\n" + % (len(problems), sourceDir)) + for problem in problems: + sys.stderr.write(" %s\n\n" % problem) + sys.exit(1) + + +def processDb(srcDbDir, destDbDir, db, maxBytes): + """Convert one assembly subdirectory. Returns a summary dict.""" + os.mkdir(destDbDir) + + trackDbPath = os.path.join(srcDbDir, "trackDb.txt") + + support = [] + for name in sorted(os.listdir(srcDbDir)): + path = os.path.join(srcDbDir, name) + if not os.path.isfile(path): + continue + if name == "trackDb.txt": + continue + if name.endswith(".tsv") or name.endswith(".json"): + shutil.copy2(path, os.path.join(destDbDir, name)) + support.append(name) + else: + sys.stderr.write( + "WARNING: %s/%s is not a .tsv or .json and was not copied\n" % (db, name)) + + with open(trackDbPath) as fh: + stanzas = splitStanzas(fh.read()) + + counts = {"bigDataUrl": 0, "unmatchedHost": 0, "unmatchedSamples": [], + "parentAdded": 0} + for setting in FETCHED_SETTINGS: + counts[setting] = 0 + + transformed = [transformStanza(lines, db, counts) for lines in stanzas] + written = writeRaFiles(transformed, destDbDir, maxBytes) + + nbytes = sum(os.path.getsize(os.path.join(destDbDir, n)) for n in written) + return {"db": db, "stanzas": len(stanzas), "files": written, "bytes": nbytes, + "support": support, "counts": counts} + + +COPY_SCRIPT_TEMPLATE = r'''#!/bin/bash +# Put the converted MethBase2 files in place. +# +# Generated by processMethbaseHub -- edit the source script, not this copy. +# +# Copies the methbase2*.ra files plus the facet metadata and color files into +# each assembly's trackDb directory, then points /gbdb//%(gbdbSubdir)s/ at the +# metadata and color files sitting next to this script -- that is where the +# tracks' metaDataUrl and colorSettingsUrl settings look for them. +# +# Because those are symlinks rather than copies, the directory holding this +# script has to stay put. Deleting or moving it breaks every track. +# +# This deliberately makes no other edits. Adding "include methbase2.ra" to an +# assembly's trackDb.ra, and defining the dnaMethylation supertrack the +# composites hang off of (which is where "group" gets set), are done by hand. + +set -o errexit -o nounset -o pipefail + +# An assembly may legitimately ship without a .tsv or a .json. Without this, a +# glob matching nothing would come through as its own literal text and be handed +# to cp as a filename. +shopt -s nullglob + +GBDB_ROOT="%(gbdbRoot)s" +GBDB_SUBDIR="%(gbdbSubdir)s" + +DBS="%(dbList)s" + +# These are substituted in when this script is generated, and are used to build +# paths for mkdir, rm, and ln below. Empty values would point those at the wrong +# directory, so refuse to start rather than find out later. +if [ -z "$GBDB_ROOT" ] || [ -z "$GBDB_SUBDIR" ]; then + echo "ERROR: GBDB_ROOT and GBDB_SUBDIR must both be set" >&2 + exit 1 +fi +if [ -z "${DBS// /}" ]; then + echo "ERROR: no assemblies were baked into this script" >&2 + exit 1 +fi + +dryRun="" +trackDbDir="" + +usage() { + cat < + + base of the trackDb source tree to install into; the + per-assembly directories are located beneath it + + --dry-run report what would happen without copying, linking, or + sending mail +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dryRun="yes"; shift ;; + -h|--help) usage; exit 0 ;; + -*) echo "unknown option: $1" >&2; usage >&2; exit 1 ;; + *) + if [ -n "$trackDbDir" ]; then + echo "too many arguments" >&2; usage >&2; exit 1 + fi + trackDbDir="$1"; shift ;; + esac +done + +if [ -z "$trackDbDir" ]; then + usage >&2 + exit 1 +fi +if [ ! -d "$trackDbDir" ]; then + echo "ERROR: no such directory: $trackDbDir" >&2 + exit 1 +fi + +here=$(cd "$(dirname "$0")" && pwd) + +# Index every directory in the trackDb tree by name, down to depth 3. No -L, so +# symlinked directories are skipped. +declare -A dbPath +declare -A dbDupe +while IFS= read -r dir; do + name=$(basename "$dir") + if [ -n "${dbPath[$name]:-}" ]; then + dbDupe[$name]="${dbDupe[$name]:-${dbPath[$name]}} $dir" + else + dbPath[$name]="$dir" + fi +done < <(find "$trackDbDir" -maxdepth 3 -type d) + +# Validate every assembly before copying anything, so a bad tree cannot leave a +# half-finished install behind. processMethbaseHub already checked $GBDB_ROOT +# when it built these files, but this script may well be run later or on another +# machine, so check again rather than trust that. +errors=0 +for db in $DBS; do + if [ -z "${dbPath[$db]:-}" ]; then + echo "ERROR: no directory named $db found under $trackDbDir" >&2 + errors=$((errors + 1)) + elif [ -n "${dbDupe[$db]:-}" ]; then + echo "ERROR: $db is ambiguous, found at: ${dbDupe[$db]}" >&2 + errors=$((errors + 1)) + fi + if [ ! -d "$GBDB_ROOT/$db" ]; then + echo "ERROR: $GBDB_ROOT/$db does not exist; this machine does not carry $db" >&2 + errors=$((errors + 1)) + fi +done +if [ "$errors" -gt 0 ]; then + echo "$errors problem(s) found; nothing was copied." >&2 + exit 1 +fi + +# Assemblies that do not have a methbase2.ra yet need an include line added by +# hand, so call them out in the mail. +newDbs="" +for db in $DBS; do + if [ ! -f "${dbPath[$db]}/methbase2.ra" ]; then + newDbs="$newDbs $db" + fi +done + +run() { + if [ -n "$dryRun" ]; then + echo " would run: $*" + else + "$@" + fi +} + +for db in $DBS; do + dest="${dbPath[$db]}" + gbdbDir="$GBDB_ROOT/$db/$GBDB_SUBDIR" + + # $db and $dest are about to be built into paths handed to rm and mkdir. + # Word splitting on $DBS cannot yield an empty $db and the validation above + # cannot leave $dest empty, so this should be unreachable -- which is the + # point. An empty value here would aim those commands at a parent directory. + if [ -z "$db" ] || [ -z "$dest" ]; then + echo "ERROR: internal error: empty db ('$db') or destination ('$dest')" >&2 + exit 1 + fi + + echo "$db -> $dest" + for f in "$here/$db/"methbase2*.ra; do + run cp "$f" "$dest/" + done + + # Copy the metadata and color files into the trackDb directory (a saved copy + # in git), and point /gbdb at the originals here. Each link is removed by + # name and remade, so a re-run from a new processing directory repoints it. + # Only the names we ship are touched: if the provider ever renames a file, + # the link under its old name stays behind for someone to clean up by hand. + run mkdir -p "$gbdbDir" + for f in "$here/$db/"*.tsv "$here/$db/"*.json; do + base=$(basename "$f") + run cp "$f" "$dest/" + run rm -f "$gbdbDir/$base" + run ln -s "$f" "$gbdbDir/$base" + done +done + +mailBody="The MethBase2 files in $here have been copied into $trackDbDir, and +$GBDB_ROOT//$GBDB_SUBDIR/ now links to the metadata and color files in +$here. That directory needs to stay where it is; removing it would +break the tracks. + +Those $GBDB_ROOT files still need to go out to production. They are listed one +per line in $here/%(manifestName)s. They are symlinks, so +whatever pushes them has to follow the link rather than copy it. +" +if [ -n "$newDbs" ]; then + mailBody="$mailBody +These assemblies had no methbase2.ra before this update:" + for db in $newDbs; do + mailBody="$mailBody + $db (${dbPath[$db]})" + done + mailBody="$mailBody + +Each of them needs an 'include methbase2.ra' line added to its trackDb.ra, and +a dnaMethylation supertrack for the composite to hang off of." +else + mailBody="$mailBody +Every assembly already had a methbase2.ra; no new include lines should be needed." +fi + +if [ -n "$dryRun" ]; then + echo + echo "would mail $USER@ucsc.edu, subject \"MethBase2 Update\":" + echo "$mailBody" | sed 's/^/ | /' +else + echo "$mailBody" | mail -s "MethBase2 Update" "$USER@ucsc.edu" + echo "mailed $USER@ucsc.edu" +fi +''' + + +def writeManifest(destDir, entries): + """Write the list of /gbdb paths copyMethbaseFiles.sh will create links at. + + One path per line, so it can be fed straight to whatever pushes files out to + production. Note these are symlinks into destDir, so the push has to follow + them rather than copy the links themselves. + """ + path = os.path.join(destDir, MANIFEST_NAME) + with open(path, "w") as fh: + for entry in entries: + fh.write("%s\n" % entry) + return path + + +def writeCopyScript(destDir, dbs): + path = os.path.join(destDir, "copyMethbaseFiles.sh") + with open(path, "w") as fh: + fh.write(COPY_SCRIPT_TEMPLATE % {"dbList": " ".join(dbs), + "gbdbRoot": GBDB_ROOT, + "gbdbSubdir": GBDB_SUBDIR, + "manifestName": MANIFEST_NAME}) + os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return path + + +def main(): + parser = argparse.ArgumentParser( + description="Convert a MethBase2 track hub into trackDb .ra files.") + parser.add_argument("sourceDir", help="hub directory to read (must exist)") + parser.add_argument("destDir", help="directory to create and write into (must not exist)") + parser.add_argument("--max-ra-size", type=float, default=3.0, metavar="MB", + help="split .ra files larger than this many MB (default: 3)") + args = parser.parse_args() + + if not os.path.isdir(args.sourceDir): + raise SystemExit("ERROR: source directory does not exist: %s" % args.sourceDir) + if os.path.exists(args.destDir): + raise SystemExit("ERROR: destination already exists: %s" % args.destDir) + if args.max_ra_size <= 0: + raise SystemExit("ERROR: --max-ra-size must be positive") + maxBytes = int(args.max_ra_size * 1024 * 1024) + + dbs = [] + for name in sorted(os.listdir(args.sourceDir)): + if not os.path.isdir(os.path.join(args.sourceDir, name)): + continue + if name.startswith("."): + # .git and friends; never an assembly. + sys.stderr.write("WARNING: skipping hidden directory %s\n" % name) + continue + if not DB_NAME_RE.match(name): + raise SystemExit( + "ERROR: %r is not a usable assembly name. Expected letters, digits, " + "dots and underscores. Refusing to treat it as a database." + % name) + dbs.append(name) + if not dbs: + raise SystemExit("ERROR: no assembly subdirectories in %s" % args.sourceDir) + + validateSource(args.sourceDir, dbs) + + os.makedirs(args.destDir) + + totalUnmatched = 0 + unmatchedSamples = [] + manifest = [] + for db in dbs: + summary = processDb(os.path.join(args.sourceDir, db), + os.path.join(args.destDir, db), db, maxBytes) + for name in summary["support"]: + manifest.append("%s/%s" % (gbdbDir(db), name)) + counts = summary["counts"] + totalUnmatched += counts["unmatchedHost"] + for sample in counts["unmatchedSamples"]: + if len(unmatchedSamples) < 5: + unmatchedSamples.append("%s: %s" % (db, sample)) + print("%-10s %6d stanzas %8.2f MB %d file(s): %s" + % (db, summary["stanzas"], summary["bytes"] / 1048576.0, + len(summary["files"]), " ".join(summary["files"]))) + + scriptPath = writeCopyScript(args.destDir, dbs) + manifestPath = writeManifest(args.destDir, manifest) + + print() + print("%d assemblies written to %s" % (len(dbs), os.path.abspath(args.destDir))) + print("copy script: %s" % os.path.abspath(scriptPath)) + print("gbdb manifest: %s (%d file(s) to push to production)" + % (os.path.abspath(manifestPath), len(manifest))) + print("Keep this directory: %s//%s/ will link to the .tsv and .json files" + % (GBDB_ROOT, GBDB_SUBDIR)) + print("in it, so moving or deleting it breaks the tracks.") + if totalUnmatched: + print() + print("WARNING: %d bigDataUrl line(s) did not point at %s and were left " + "unchanged." % (totalUnmatched, SOURCE_HOST)) + for sample in unmatchedSamples: + print(" %s" % sample) + print("Check whether the contributor changed hosts before installing these files.") + + +if __name__ == "__main__": + main()