#!/usr/bin/env python3 import argparse import json import os import sys import tempfile import time from dataclasses import asdict, dataclass, field from pathlib import Path ITEM_WIDTH = 100 TAIL = 5 @dataclass class TodoItem: text: str done: bool = False created: int = field(default_factory=lambda: int(time.time())) @classmethod def from_json(cls, obj: object) -> "TodoItem": if not isinstance(obj, dict): return cls(text="") return cls( text=str(obj.get("text", "")), done=bool(obj["done"]), created=int(obj["created"]), ) def _stat_fields(pid: int) -> tuple[str, int, int]: with open(f"/proc/{pid}/stat", "rb") as fh: data = fh.read() # comm sits in parens and may contain spaces; split after the final ')'. head, _, rest = data.rpartition(b") ") comm = head.split(b"(", 1)[1].decode("utf-8", "replace") fields = rest.split() return comm, int(fields[1]), int(fields[19]) # comm, ppid, starttime def harness_pid() -> int | None: pid = os.getpid() for _ in range(64): try: _comm, ppid, _start = _stat_fields(pid) except (OSError, IndexError, ValueError): return None if ppid <= 1 or ppid == pid: return None try: comm, _, _ = _stat_fields(ppid) except (OSError, IndexError, ValueError): return None if comm == "hax": return ppid pid = ppid return None def state_path() -> Path: pid = harness_pid() if pid is not None: try: _comm, _ppid, start = _stat_fields(pid) key = f"{pid}-{start}" except (OSError, IndexError, ValueError): key = str(pid) else: key = "manual" root = Path(tempfile.gettempdir()) / f"hax-todo-{os.getuid()}" return root / f"{key}.json" def truncate(text: str) -> str: text = " ".join(text.split()) if len(text) > ITEM_WIDTH: text = text[: ITEM_WIDTH - 1] + "…" return text class Todo: def __init__(self, path: Path) -> None: self.path = path self.items: dict[int, TodoItem] self.next_id: int self.items, self.next_id = self._read() @classmethod def open(cls) -> "Todo": return cls(state_path()) def _read(self) -> tuple[dict[int, TodoItem], int]: try: data = json.loads(self.path.read_text()) except (OSError, ValueError): return {}, 0 if not isinstance(data, dict) or not isinstance(data.get("items"), dict): return {}, 0 items = {} for key, obj in data["items"].items(): try: items[int(key)] = TodoItem.from_json(obj) except ValueError: continue try: next_id = int(data["next_id"]) except (KeyError, TypeError, ValueError): next_id = max(items, default=-1) + 1 return items, next_id def _write(self) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) tmp = self.path.with_name(self.path.name + ".tmp") payload = { "next_id": self.next_id, "items": {str(id): asdict(item) for id, item in self.items.items()}, } tmp.write_text(json.dumps(payload, indent=1)) os.replace(tmp, self.path) def _line(self, id: int, *, full: bool) -> str: item = self.items[id] mark = "x" if item.done else " " text = item.text if full else truncate(item.text) return f"{id:3d}. [{mark}] {text}" def render(self, ids: list[int]) -> str: if not self.items: return "(no todos)" if ids: lines = [self._line(id, full=True) for id in ids] else: lines = [self._line(id, full=False) for id in sorted(self.items)] return "\n".join(lines) if lines else "(no todos)" def tail_view(self) -> str: lines = [self._line(id, full=False) for id in sorted(self.items)[-TAIL:]] return "\n".join(lines) if lines else "(no todos)" def checked(self, ids: list[int]) -> list[int]: checked = [] for id in ids: if id not in self.items: sys.exit(f"error: no todo with id {id} (have {len(self.items)})") checked.append(id) return checked def add(self, text: str) -> tuple[int, TodoItem]: item = TodoItem(text=text) id = self.next_id self.next_id += 1 self.items[id] = item self._write() return id, item def mark_done(self, ids: list[int]) -> list[tuple[int, TodoItem]]: marked = [(id, self.items[id]) for id in self.checked(ids)] for _id, item in marked: item.done = True self._write() return marked def remove(self, ids: list[int]) -> list[tuple[int, TodoItem]]: removed = [(id, self.items[id]) for id in self.checked(ids)] for id in set(ids): del self.items[id] self._write() return removed def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="todo", description="Per-session todo list for the hax harness." ) sub = parser.add_subparsers(dest="command", required=True) sub_list = sub.add_parser("list", help="print every item, or specific ones in full") sub_list.add_argument("id", nargs="*", type=int, metavar="ID") sub_add = sub.add_parser("add", help="append an item") sub_add.add_argument("text", nargs="+", metavar="TEXT") sub_done = sub.add_parser("done", help="mark items done") sub_done.add_argument("id", nargs="+", type=int, metavar="ID") sub_remove = sub.add_parser("remove", help="delete items") sub_remove.add_argument("id", nargs="+", type=int, metavar="ID") return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) todo = Todo.open() match args.command: case "list": print(todo.render(todo.checked(args.id))) case "add": id, item = todo.add(" ".join(args.text)) print(f"added {id}: {truncate(item.text)}") print(todo.tail_view()) case "done": for id, item in todo.mark_done(args.id): print(f"done {id}: {item.text}") print(todo.tail_view()) case "remove": for id, item in todo.remove(args.id): print(f"removed {id}: {item.text}") print(todo.tail_view()) return 0 if __name__ == "__main__": sys.exit(main())