diff options
author | Loek Le Blansch <loek@pipeframe.xyz> | 2024-09-17 15:13:31 +0200 |
---|---|---|
committer | Loek Le Blansch <loek@pipeframe.xyz> | 2024-09-17 15:13:31 +0200 |
commit | 5c0649ac31b030cbb3c8c5e0684ee9419fe50054 (patch) | |
tree | 8cc5f25c1de11d50ec8fab196626145ef0cefaae /scripts | |
parent | 69bb15bd19cd62b39e2af8a2ab890b1ce25f514c (diff) | |
parent | 5aba97da07413c498871c5da4ce8d0f2427ffc69 (diff) |
Merge branch 'loek/requirements'
Diffstat (limited to 'scripts')
-rw-r--r-- | scripts/.gitignore | 1 | ||||
-rwxr-xr-x | scripts/reqs2tex.py | 110 | ||||
-rw-r--r-- | scripts/tex.py | 52 | ||||
-rwxr-xr-x | scripts/time2tex.py | 213 |
4 files changed, 376 insertions, 0 deletions
diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/scripts/reqs2tex.py b/scripts/reqs2tex.py new file mode 100755 index 0000000..6b7b77a --- /dev/null +++ b/scripts/reqs2tex.py @@ -0,0 +1,110 @@ +#!/bin/python3 +import sys, tomllib, tex + +def flatten(data): + if 'description' in data: + return [ data ] + out = [] + for key, value in data.items(): + items = flatten(value) + for item in items: + if 'label' in item: + item['label'] = f"{key}.{item['label']}" + else: + item['label'] = f"{key}" + out += items + return out + +id_counter = 0 +def make_id(item): + global id_counter + id_counter += 1 + return "{type_short}#{counter:03d}".format( + type_short = item['type'][0].upper(), + counter = id_counter, + ) + +def sanitize(item, ids): + def die(msg): + print(f"[{item['label']}]: {msg}") + exit(1) + + # ensure properties + item['description'] = item.get('description') + item['done'] = item.get('done') + item['priority'] = item.get('priority') + item['type'] = item.get('type') + + # type checks + if item['type'] not in ['system', 'user']: + die(f"unknown or missing requirement type {repr(item['type'])}") + if item['priority'] not in ['must', 'should', 'could', 'will not']: + die(f"unknown or missing requirement priority {repr(item['type'])}") + + # conversions + if isinstance(item['done'], list): + # safety check + if not set(item['done']).issubset(ids): + die("definition of done includes unknown requirement(s)") + item['done'] = tex.cmd('Cref', tex.label2ref(*item['done'])) + +def convert(data): + reqs = flatten(data) + index = 0 + for item in reqs: + item['id'] = tex.esc(make_id(item)) + item['deleted'] = item.get('deleted', False) + if item['deleted']: continue + item['index'] = index + index += 1 + sanitize(item, [req['label'] for req in reqs]) + + # skip deleted requirements (but process for make_id) + reqs = [item for item in reqs if item['deleted'] == False] + + return reqs + +def fmt_aux(data): + out = [] + for req in data: + ref = tex.label2ref(req['label']) + out += [ + tex.cmd('newlabel', f"{ref}", tex.group(req['id'], req['id'], 'ggg', 'hhh', 'iii')), + tex.cmd('newlabel', f"{ref}@cref", tex.group(f"[requirement][aaa][bbb]{req['id']}", '[ccc][ddd][eee]fff')), + ] + return "\n".join(out) + +def fmt_tex(data): + out = [] + for req in data: + out.append( + tex.cmd('subsection', req['id']) + "\n\n" +\ + tex.env('description', + tex.cmd('item', ['Priority']) + req['priority'].title() +\ + tex.cmd('item', ['Requirement']) + req['description'] +\ + (tex.cmd('item', ['Definition of done']) + req['done'] if req['done'] is not None else "") + ) + ) + return "\n\n".join(out) + +def main(input_file): + data = {} + with open(input_file, "rb") as file: + data = tomllib.load(file) + + requirements = convert(data) + + output_aux = input_file.removesuffix(".toml") + ".aux" + with open(output_aux, "w+") as file: + file.write(fmt_aux(requirements)) + + output_tex = input_file.removesuffix(".toml") + ".tex" + with open(output_tex, "w+") as file: + file.write(fmt_tex(requirements)) + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: reqs2tex.py reqs.toml") + exit(1) + main(sys.argv[1]) + diff --git a/scripts/tex.py b/scripts/tex.py new file mode 100644 index 0000000..2509a87 --- /dev/null +++ b/scripts/tex.py @@ -0,0 +1,52 @@ +# utility function for converting latex code + +def group(*args): + out = "" + for arg in args: + if isinstance(arg, list): + out += "[" + arg[0] + "]" + if isinstance(arg, str): + out += "{" + arg + "}" + return out + +def string(content): + return r"\string" + content + +def cmd(*args): + name = args[0] + args = args[1:] + if len(args) == 0: args = [""] + return f"\\{name}" + group(*args) + +def csdef(*args): + return r"\def" + cmd(*args) + +def auxout(content): + return r"\write\@auxout" + group(content) + +def scmd(*args): + return string(cmd(*args)) + +def env(name, *args): + content = args[-1] + args = args[0:-1] + out = f"\\begin{{{name}}}" + if len(args) > 0: + out += group(*args) + out += content + out += f"\\end{{{name}}}" + return out + +def esc(plain): + plain = plain.replace("\\", "\\string\\") + plain = plain.replace("#", "\\#") + plain = plain.replace("$", "\\$") + plain = plain.replace("%", "\\%") + return plain + +def tabrule(*cells): + return "&".join(cells) + "\\\\" + +def label2ref(*labels): + return ",".join(["req:" + label.replace('.', ':') for label in labels]) + diff --git a/scripts/time2tex.py b/scripts/time2tex.py new file mode 100755 index 0000000..8c3dd9b --- /dev/null +++ b/scripts/time2tex.py @@ -0,0 +1,213 @@ +#!/bin/python3 +import sys, tex +from datetime import datetime, timedelta + +def fmt_duration(sec): + mins = (sec + 59) // 60 # integer divide, round up + out = [] + + if mins == 0: + return "--" + + hour = mins // 60 + if hour > 0: + out.append("%02dh" % (hour, )) + mins = mins % 60 + + out.append("%02dm" % (mins, )) + + return "\\,".join(out) + +def fmt_percentage(fac): + return tex.group( + tex.cmd('footnotesize') +\ + tex.cmd('itshape') +\ + tex.esc(f"({round(fac * 100)}%)") + ) + +def fmt_member_overview(times): + # calculations + tracked = {} + total_time = 0 + for time in times: + if not time["name"] in tracked: + tracked[time["name"]] = 0 + tracked[time["name"]] += time["duration"] + total_time += time["duration"] + + out = "" + + # header + out += tex.cmd('toprule') + out += tex.tabrule(tex.cmd('textbf', 'Member'), tex.cmd('textbf', 'Tracked'), '') + out += tex.cmd('midrule') + + # member overview + members = sorted(list(set(time["name"] for time in times))) + for name in members: + out += tex.tabrule(name, fmt_duration(tracked[name]), fmt_percentage(tracked[name] / total_time)) + out += tex.cmd('midrule') + + # sum + out += tex.tabrule('', fmt_duration(total_time), '') + out += tex.cmd('bottomrule') + + out = tex.env('tabular', 'lr@{~}l', out) + out = tex.cmd('centering') +\ + out +\ + tex.cmd('caption', 'Tracked time per group member') +\ + tex.cmd('label', 'tab:time-member') + out = tex.env('table', out) + + return out + +def fmt_weekly_overview(times): + # calculations + out = "" + weeks = [] + member_totals = {} + total_time = sum(time["duration"] for time in times) + members = sorted(list(set(time["name"] for time in times))) + time_start = min(time["date"] for time in times) + time_end = max(time["date"] for time in times) + week_start = time_start - timedelta(days=time_start.weekday()) # round down to nearest monday + week_end = time_end + timedelta(days=7-time_end.weekday()) + + week = week_start + week_num = 1 + while week < week_end: + week_times = [time for time in times if time["date"] >= week and time["date"] < (week + timedelta(days=7))] + + week_entry = { + "num": week_num, + "members": {}, + "total": sum(time["duration"] for time in week_times) + } + + for member in members: + week_entry["members"][member] = sum(time["duration"] for time in week_times if time["name"] == member) + + weeks.append(week_entry) + week_num += 1 + week += timedelta(days=7) + for member in members: + member_totals[member] = sum(time["duration"] for time in times if time["name"] == member) + + # TODO: refactor + # begin table + out += r"\begin{table}\centering" + out += r"\fitimg{" + out += f"\\begin{{tabular}}{{l{'r@{~}l' * len(members)}@{{\\qquad}}r}}\\toprule" + out += r"\textbf{\#}" + for member in members: + out += f"&\\textbf{{{member}}}&" + out += r"&\textbf{Subtotal}\\\midrule{}" + + for entry in weeks: + out += f"{entry['num']}" + for member in members: + out += f"&{fmt_duration(entry['members'][member])}&{fmt_percentage(entry['members'][member] / entry['total'])}" + out += f"&{fmt_duration(entry['total'])}\\\\" + + out += r"\midrule{}" + for member in members: + out += f"&{fmt_duration(member_totals[member])}&{fmt_percentage(member_totals[member] / total_time)}" + out += f"&{fmt_duration(total_time)}\\\\" + + # end table + out += r"\bottomrule\end{tabular}" + out += r"}" # \fitimg + out += r"\caption{Tracked time per week}\label{tab:time-weekly}" + out += r"\end{table}" + + return out + +def duration2secs(duration): + out = 0 # output (seconds) + cur = 0 # current figure (unknown) + for c in duration: + if c.isdigit(): + cur = cur * 10 + int(c) + continue + if c == "h": + out += cur * 3600 + cur = 0 + continue + if c == "m": + out += cur * 60 + cur = 0 + continue + if c == "s": + out += cur * 1 + cur = 0 + continue + + raise Exception("invalid duration format") + if cur != 0: raise Exception("invalid duration format") + return out + +def line2data(line): + # parse fields from input string + data = {} + next = line.find(':') + data["name"] = line[0:next].strip() + line = line[next+1:].strip() + next = line.find(' ') + data["date"] = line[0:next].strip() + line = line[next+1:].strip() + next = line.find(' ') + data["duration"] = line[0:next].strip() + line = line[next+1:].strip() + data["description"] = line + + # deserialize parsed fields + data["name"] = data["name"].title() + data["date"] = datetime.strptime(data["date"], '%Y-%m-%d') + data["duration"] = duration2secs(data["duration"]) + data["description"] = [el.strip() for el in data["description"].split("::")] + + return data + +def parse(content): + # split content at newlines + lines = content.split("\n") + out = [] + for i, line in enumerate(lines): + line = line.strip() + if line.startswith("#"): continue + if len(line) == 0: continue + + try: out.append(line2data(line)) + except Exception as e: raise Exception(f"line {i+1}: {e}") + return out + +def fmt(times): + return "\n\n".join([ + tex.cmd('section', 'Overviews'), + tex.cmd('subsection', 'Members'), + fmt_member_overview(times), + tex.cmd('subsection', 'Weekly'), + fmt_weekly_overview(times), + ]) + +def main(input_file): + content = "" + with open(input_file, "r") as file: + content = file.read() + + try: parsed = parse(content) + except Exception as e: + print(f"{input_file}: {e}") + exit(1) + output = fmt(parsed) + + output_file = input_file.removesuffix(".txt") + ".tex" + with open(output_file, "w+") as file: + file.write(output) + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: time2tex.py time.txt") + exit(1) + main(sys.argv[1]) + |