diff options
author | Lars Wirzenius <liw@liw.fi> | 2024-01-11 16:28:30 +0200 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2024-01-11 19:47:20 +0200 |
commit | 0fdffd176a9d71b8c1593db2986958251f3d2779 (patch) | |
tree | a5e045bbf87fdc40bba109fbf2f650ef2d4385e2 | |
parent | d414455b1a74cbd6caeaaf2b1aa5b4899c24d864 (diff) | |
download | wumpus-hunter-0fdffd176a9d71b8c1593db2986958251f3d2779.tar.gz |
feat: rewrite the run log generation to produce HTML
Signed-off-by: Lars Wirzenius <liw@liw.fi>
-rwxr-xr-x | try.sh | 11 | ||||
-rwxr-xr-x | wumpus-hunter | 640 |
2 files changed, 531 insertions, 120 deletions
@@ -2,9 +2,12 @@ set -euo pipefail +web=/tmp/webroot + ./wumpus-hunter \ --keep \ - --dir /tmp/webroot/src \ - --log log.txt --run-log . \ - --stats stats.txt \ - --counts counts.html + --dir "$web/src" \ + --log "$web/log.html" \ + --run-log "$web" \ + --stats "$web/stats.txt" \ + --counts "$web/counts.html" diff --git a/wumpus-hunter b/wumpus-hunter index 23e5d22..50b1f05 100755 --- a/wumpus-hunter +++ b/wumpus-hunter @@ -1,12 +1,12 @@ #!/usr/bin/python3 import argparse -import logging import os import shutil import subprocess import tempfile import time +import sys HEARTWOOD_URL = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git" @@ -19,15 +19,17 @@ STATS_FILE = os.path.expanduser("~/stats.txt") COUNTS_FILE = os.path.expanduser("~/counts.txt") EXPLANATION_HTML = """ -<p>Results of running the Radicle <code>heartwood</code> tests repeatedly. +Results of running the Radicle heartwood tests repeatedly. Report number of successful and fail test runs per commit. Keep logs of each test run for each commit. -</p> - """ CSS = """ +h1 { font-size: 200%; } +h2 { font-size: 125%; } +h3 { font-size: 100%; } + table { width: 100%; text-align: left; @@ -40,6 +42,387 @@ table { """ +def escape(s): + s = "&".join(s.split("&")) + s = "<".join(s.split("<")) + s = ">".join(s.split(">")) + s = '"'.join(s.split(""")) + s = "'".join(s.split("'")) + return s + + +next_id = 0 + + +def idgen(prefix): + global next_id + next_id += 1 + return f"{prefix}{next_id}" + + +class Element: + def __init__(self, tag, pre=None): + self.tag = tag + self.attrs = {} + self.children = [] + self.pre = pre + + def attr(self, name, value): + self.attrs[name] = self.attrs.get(name, []) + [escape(value)] + + def child(self, child): + if isinstance(child, str): + child = Text(child) + self.children.append(child) + + def start_tag(self): + s = f"<{self.tag}" + if self.attrs: + for a in self.attrs: + value = " ".join(self.attrs[a]) + s += f' {a}="{value}"' + s += ">" + return s + + def end_tag(self): + return f"</{self.tag}>" + + def serialize(self): + s = Serialized() + if self.pre is not None: + s.push(self.pre) + s.push(self.start_tag()) + for child in self.children: + s.push(child.serialize()) + s.push(self.end_tag()) + s.newline() + return str(s) + + +class Html(Element): + def __init__(self): + super().__init__("html", pre="<!DOCTYPE html>\n") + + +class Head(Element): + def __init__(self): + super().__init__("head") + + +class Title(Element): + def __init__(self, text): + super().__init__("title") + self.child(text) + + +class Style(Element): + def __init__(self, text): + super().__init__("style") + self.child(text) + + +class Body(Element): + def __init__(self): + super().__init__("body") + + +class Div(Element): + def __init__(self): + super().__init__("div") + + +class H1(Element): + def __init__(self, text): + super().__init__("h1") + self.child(text) + + +class H2(Element): + def __init__(self, text, prefix): + super().__init__("h2") + self.toc_id = idgen(prefix) + self.attr("id", self.toc_id) + self.child(text) + self.text = text + + +class H3(Element): + def __init__(self, text, prefix): + super().__init__("h3") + self.toc_id = idgen(prefix) + self.attr("id", self.toc_id) + self.child(text) + self.text = text + + +class P(Element): + def __init__(self): + super().__init__("p") + + +class Pre(Element): + def __init__(self): + super().__init__("pre") + + +class Blockquote(Element): + def __init__(self): + super().__init__("blockquote") + + +class Ul(Element): + def __init__(self): + super().__init__("ul") + + +class Ol(Element): + def __init__(self): + super().__init__("ol") + + +class Li(Element): + def __init__(self): + super().__init__("li") + + +class Table(Element): + def __init__(self): + super().__init__("table") + + +class Tr(Element): + def __init__(self): + super().__init__("tr") + + +class Th(Element): + def __init__(self): + super().__init__("th") + + +class Td(Element): + def __init__(self): + super().__init__("td") + + +class A(Element): + def __init__(self, href): + super().__init__("a") + self.attr("href", href) + + +class Code(Element): + def __init__(self): + super().__init__("code") + + +class Text: + def __init__(self, text): + self.text = escape(text) + + def serialize(self): + return self.text + + +class Serialized: + def __init__(self): + self.snippets = [] + + def __str__(self): + return "".join(self.snippets) + + def push(self, snippet): + assert isinstance(snippet, str) + self.snippets.append(snippet) + + def newline(self): + self.snippets.append("\n") + + +class RunLog: + def __init__(self): + self.data = {} + + def serialize(self): + toc = Div() + self.toc = [] + + html = Html() + + head = Head() + head.child(Title("Wumpus log for one test run")) + head.child(Style(CSS)) + html.child(head) + + body = Body() + html.child(body) + + body.child(H1("Run log")) + self.metadata(body) + + body.child(H2("Table of contents", "toc")) + body.child(toc) + + for cmd, exit, stdout, stderr in self.data["commands"]: + self.report_command(body, cmd, exit, stdout, stderr) + + if "error" in self.data: + self.report_error(body, self.data["error"]) + + self.toc_build(toc) + + return html.serialize() + + def toc_append(self, h): + if isinstance(h, H2): + self.toc.append((2, h.text, h.toc_id)) + elif isinstance(h, H3): + self.toc.append((3, h.text, h.toc_id)) + else: + raise Exception("ToC logic error") + + def toc_build(self, toc): + prev = None + ul = Ul() + stack = [] + + for level, text, toc_id in self.toc: + link = A(f"#{toc_id}") + link.child(text) + li = Li() + li.child(link) + if prev is None or level == prev: + ul.child(li) + elif level > prev: + stack.append(ul) + ul = Ul() + ul.child(li) + else: + assert level < prev + parent = stack.pop() + parent.child(ul) + ul = parent + ul.child(li) + prev = level + + assert ul is not None + while stack: + parent = stack.pop() + parent.child(ul) + ul = parent + + assert ul is not None + toc.child(ul) + + def report_error(self, body, error): + h2 = H2("Error", "error") + body.child(h2) + self.toc_append(h2) + + p = P() + pre = Pre() + pre.child(error) + blockquote = Blockquote() + blockquote.child(pre) + p.child(blockquote) + body.child(p) + + def report_command(self, body, cmd, exit, stdout, stderr): + args = " ".join(cmd) + h2 = H2(f"Run: {args}", "run") + body.child(h2) + self.toc_append(h2) + + h3 = H3("Argv", "argv") + body.child(h3) + self.toc_append(h3) + + ol = Ol() + for arg in cmd: + code = Code() + code.child(arg) + li = Li() + li.child(code) + ol.child(li) + body.child(ol) + + h3 = H3("Exit code", "exit") + body.child(h3) + self.toc_append(h3) + + p = P() + p.child("Exit code ") + p.child(str(exit)) + body.child(p) + + if stdout: + self.output(body, "Stdout", stdout) + + if stderr: + self.output(body, "Stderr", stderr) + + def output(self, body, stream, output): + h3 = H3(stream, stream) + body.child(h3) + self.toc_append(h3) + + p = P() + pre = Pre() + pre.child(output) + blockquote = Blockquote() + blockquote.child(pre) + p.child(blockquote) + body.child(p) + + def metadata(self, body): + h2 = H2("Metadata", "metadata") + body.child(h2) + self.toc_append(h2) + + ul = Ul() + body.child(ul) + + code = Code() + code.child(self.data["url"]) + a = A(self.data["url"]) + a.child(code) + li = Li() + li.child("Repository: ") + li.child(a) + ul.child(li) + + code = Code() + code.child(self.data["ref"]) + li = Li() + li.child("Git ref: ") + li.child(code) + ul.child(li) + + code = Code() + code.child(self.data["commit"]) + li = Li() + li.child("Git ref: ") + li.child(code) + ul.child(li) + + def url(self, url): + self.data["url"] = url + + def ref(self, ref): + self.data["ref"] = ref + + def commit(self, commit): + self.data["commit"] = commit + + def runcmd(self, cmd, exit, stdout, stderr): + if "commands" not in self.data: + self.data["commands"] = [] + self.data["commands"].append((cmd, exit, stdout, stderr)) + + def error(self, error): + self.data["error"] = error + + def parse_args(): p = argparse.ArgumentParser() p.add_argument( @@ -75,106 +458,75 @@ def parse_args(): return p.parse_args() -def setup_logging(log): - logging.basicConfig( - filename=log, - datefmt="%Y-%m-%d %H:%M:%S", - format="%(asctime)s %(levelname)s %(message)s", - level=logging.DEBUG, - ) - - -def get_code(url, ref, dirname): +def get_code(run_log, url, ref, dirname): if not os.path.exists(dirname): - git_clone(url, dirname) - git_checkout(dirname, ref) + git_clone(run_log, url, dirname) + git_checkout(run_log, dirname, ref) else: - git_clean(dirname) - git_checkout(dirname, ref) - git_remote_update(url, dirname) - git_reset(dirname, f"origin/{ref}") - git_status(dirname) - git_show_head(dirname) - return get_commit(dirname) + git_clean(run_log, dirname) + git_checkout(run_log, dirname, ref) + git_remote_update(run_log, url, dirname) + git_reset(run_log, dirname, f"origin/{ref}") + git_status(run_log, dirname) + git_show_head(run_log, dirname) + return get_commit(run_log, dirname) -def git_clone(url, dirname): - logging.info(f"clone repository {url} to {dirname}") - git("clone", url, dirname) +def git_clone(run_log, url, dirname): + git(run_log, "clone", url, dirname) -def git_checkout(dirname, ref): - logging.info(f"checkout {ref} in {dirname}") - git("checkout", ref, cwd=dirname) +def git_checkout(run_log, dirname, ref): + git(run_log, "checkout", ref, cwd=dirname) -def git_remote_update(url, dirname): - logging.info(f"update clone of {url} in {dirname}") - git("remote", "update", cwd=dirname) +def git_remote_update(run_log, url, dirname): + git(run_log, "remote", "update", cwd=dirname) -def git_reset(dirname, ref): - logging.info(f"reset {dirname} to {ref}") - git("reset", "--hard", ref, cwd=dirname) +def git_reset(run_log, dirname, ref): + git(run_log, "reset", "--hard", ref, cwd=dirname) -def git_clean(dirname): - logging.info(f"remove files not in git in {dirname}") - git("clean", "-fdx", cwd=dirname) +def git_clean(run_log, dirname): + git(run_log, "clean", "-fdx", cwd=dirname) -def git_status(dirname): - logging.info(f"show git status in {dirname}") - git("status", "--ignored", cwd=dirname) +def git_status(run_log, dirname): + git(run_log, "status", "--ignored", cwd=dirname) -def git_show_head(dirname): - logging.info(f"show current commit in {dirname}") - git("--no-pager", "show", "HEAD", cwd=dirname) +def git_show_head(run_log, dirname): + git(run_log, "--no-pager", "show", "HEAD", cwd=dirname) -def get_commit(dirname): - logging.info(f"get HEAD commit in {dirname}") - commit = git("rev-parse", "HEAD", cwd=dirname).strip() - logging.info(f"HEAD is {commit}") +def get_commit(run_log, dirname): + commit = git(run_log, "rev-parse", "HEAD", cwd=dirname).strip() return commit -def git(*args, cwd=None): - return run(["git"] + list(args), cwd=cwd) +def git(run_log, *args, cwd=None): + return run(run_log, ["git"] + list(args), cwd=cwd) -def run(argv, cwd=None): - logging.debug(f"running command {argv} in {cwd}") +def run(run_log, argv, cwd=None): p = subprocess.run(argv, cwd=cwd, capture_output=True) - log_output("stdout", p.stdout) - log_output("stderr", p.stderr) - logging.info(f"exit code {p.returncode}") + run_log.runcmd(argv, p.returncode, p.stdout.decode(), p.stderr.decode()) if p.returncode != 0: raise Exception(f"command {argv} failed") return p.stdout.decode() -def log_output(stream, output): - if output: - logging.info(f"{stream}\n{output.decode()}") - else: - logging.info(f"{stream} is empty") - - -def build(dirname): - logging.info(f"build code in {dirname}") - run(["cargo", "build", "--workspace", "--all-targets"], cwd=dirname) - +def build(run_log, dirname): + run(run_log, ["cargo", "build", "--workspace", "--all-targets"], cwd=dirname) -def run_tests(dirname, cmd): - logging.info(f"run tests in {dirname}: {cmd}") +def run_tests(run_log, dirname, cmd): # Radicle heartwood tests sometimes leave temporary files. This can fill # the disk. Deal with this by explicitly removing them. tmp = tempfile.mkdtemp() - run(["env", f"TMPDIR={tmp}", "bash", "-c", cmd], cwd=dirname) + run(run_log, ["env", f"TMPDIR={tmp}", "bash", "-c", cmd], cwd=dirname) shutil.rmtree(tmp) @@ -191,11 +543,7 @@ def record(filename, commit, result): f.write(f"{commit} {result}\n") -def remove_log(log): - os.remove(log) - - -def rename_log(log, dirname, commit, succeeded): +def run_log_name(dirname, commit, succeeded): commit_dir = os.path.join(dirname, f"log-{commit}") if not os.path.exists(commit_dir): os.mkdir(commit_dir) @@ -204,20 +552,21 @@ def rename_log(log, dirname, commit, succeeded): suffix = "success" else: suffix = "fail" - run_log = os.path.join(commit_dir, f"log-{timestamp}.{suffix}.txt") - os.rename(log, run_log) + return os.path.join(commit_dir, f"log-{timestamp}.{suffix}.txt") -def git_commit_date(dirname, commit): +def git_commit_date(run_log, dirname, commit): try: out = git( + run_log, "show", "--pretty=fuller", "--date=iso", commit, cwd=dirname, ) - except Exception: + except Exception as e: + sys.stderr.write(f"{e}\n") return "(unknown commit)" prefix = "CommitDate:" date = [line.strip() for line in out.splitlines() if line.startswith(prefix)][0] @@ -226,7 +575,7 @@ def git_commit_date(dirname, commit): return date -def count(src, counts, stats): +def count(run_log, src, counts, stats): d = {} total = 0 with open(stats) as f: @@ -242,55 +591,114 @@ def count(src, counts, stats): fail += 1 d[commit] = (succ, fail) - commits = [(git_commit_date(src, commit), commit) for commit in d] + commits = [(git_commit_date(run_log, src, commit), commit) for commit in d] + + head = Head() + head.child(Title("Radicle Wumpus hunter")) + head.child(Style(CSS)) + + body = Body() + expl = P() + expl.child(EXPLANATION_HTML) + body.child(expl) + + date = Th() + date.child("date") + + commit = Th() + commit.child("commit") + + succ = Th() + succ.attr("class", "numeric") + succ.child("successes") + + fail = Th() + fail.attr("class", "numeric") + fail.child("failures") + + headings = Tr() + headings.child(date) + headings.child(commit) + headings.child(succ) + headings.child(fail) + + table = Table() + table.child(headings) + + for date, commit in reversed(sorted(commits)): + succ, fail = d[commit] + + tr = Tr() + + td = Td() + td.child(Text(date)) + tr.child(td) + + code = Code() + code.child(commit) + link = A(f"log-{commit}/") + link.child(commit) + td = Td() + td.child(link) + tr.child(td) + + td = Td() + td.attr("class", "numeric") + td.child(str(succ)) + tr.child(td) + + td = Td() + td.attr("class", "numeric") + td.child(str(fail)) + tr.child(td) + + table.child(tr) + + body.child(table) + + timestamp = time.strftime("%Y-%m-%d %H:%M:%S %z") + p = P() + p.child(f"Total of {total} test runs. Last updated {timestamp}") + body.child(p) + + html = Html() + html.child(head) + html.child(body) with open(counts, "w") as f: - f.write("<html>\n") - f.write("<head>\n") - f.write("<title>Radicle Wumpus hunter</title>\n") - f.write(f"<style>{CSS}</style>\n") - f.write("</head>\n") - f.write("<body>\n") - f.write(EXPLANATION_HTML) - f.write("<table>\n") - f.write( - "<tr><th>Date</th><th>commit</th><th class=numeric>successes</th><th class=numeric>failures</th></tr>\n" - ) - for date, commit in reversed(sorted(commits)): - (succ, fail) = d[commit] - link = f'<a href="log-{commit}/"><code>{commit}</code></a>' - f.write( - f"<tr><td>{date}</td><td>{link}</td><td class=numeric>{succ}</td><td class=numeric>{fail}</td></tr>\n" - ) - f.write("</table>\n") - timestamp = time.strftime("%Y-%m-%d %H:%M:%S %z") - f.write(f"<p>Total of {total} test runs. Last updated {timestamp}</p>\n") - f.write("</body>\n") - f.write("</html>\n") + f.write(html.serialize()) def main(): args = parse_args() - setup_logging(args.log) - logging.debug(f"url: {args.url}") - logging.debug(f"ref: {args.ref}") - logging.debug(f"dir: {args.dir}") + + run_log = RunLog() + run_log.url(args.url) + run_log.ref(args.ref) + + exit = 0 try: - commit = get_code(args.url, args.ref, args.dir) - build(args.dir) - run_tests(args.dir, args.test) + commit = get_code(run_log, args.url, args.ref, args.dir) + run_log.commit(commit) + build(run_log, args.dir) + run_tests(run_log, args.dir, args.test) record_success(args.stats, commit) - if args.keep: - rename_log(args.log, args.run_log, commit, True) - else: - remove_log(args.log) + filename = run_log_name(args.run_log, commit, True) except Exception as e: - logging.error(f"{e}", exc_info=True) + sys.stderr.write(f"{e}\n") + run_log.error(str(e)) record_failure(args.stats, commit) - rename_log(args.log, args.run_log, commit, False) + filename = run_log_name(args.run_log, commit, False) + exit = 1 if args.counts: - count(args.dir, args.counts, args.stats) + count(run_log, args.dir, args.counts, args.stats) + + if args.keep: + with open(filename, "w") as f: + f.write(run_log.serialize()) + + sys.exit(exit) main() |