pebbles.py
python · 323 lines
1#!/usr/bin/env python32"""Gratitude Pebbles -- a quiet jar for small, good things."""34from __future__ import annotations56import argparse7import json8import os9import random10import sys11import tempfile12from collections.abc import Sequence13from dataclasses import asdict, dataclass14from datetime import datetime, timezone15from pathlib import Path16from typing import NoReturn1718# ---------------------------------------------------------------------------19# Configuration20# ---------------------------------------------------------------------------2122_ENV_VAR: str = "GRATITUDE_PEBBLES_FILE"23_DEFAULT_PATH: Path = Path.home() / ".gratitude_pebbles.json"24_SHAKE_MIN: int = 325_SHAKE_MAX: int = 52627# ---------------------------------------------------------------------------28# Data model29# ---------------------------------------------------------------------------303132@dataclass(frozen=True, slots=True)33class _Pebble:34 """A single moment of gratitude."""3536 text: str37 timestamp: str # ISO 8601, UTC383940# ---------------------------------------------------------------------------41# Pure helpers42# ---------------------------------------------------------------------------434445def _data_path() -> Path:46 """Returns the resolved path to the pebbles data file."""47 override = os.environ.get(_ENV_VAR)48 if override:49 return Path(override).expanduser().resolve()50 return _DEFAULT_PATH515253def _new_pebble(text: str) -> _Pebble:54 """Creates a pebble timestamped to the current UTC moment."""55 now = datetime.now(timezone.utc).isoformat(timespec="seconds")56 return _Pebble(text=text, timestamp=now)575859def _format_date(iso_timestamp: str) -> str:60 """Formats an ISO 8601 timestamp as a short, friendly date."""61 try:62 dt = datetime.fromisoformat(iso_timestamp)63 return dt.strftime("%b %d, %Y").lower()64 except ValueError:65 return iso_timestamp666768def _pluralize(count: int) -> str:69 """Returns 'pebble' or 'pebbles' based on count."""70 return "pebble" if count == 1 else "pebbles"717273# ---------------------------------------------------------------------------74# Output helpers75# ---------------------------------------------------------------------------767778def _die(message: str) -> NoReturn:79 """Prints a gentle error message to stderr and exits."""80 print(f" {message}", file=sys.stderr)81 raise SystemExit(1)828384def _put(message: str = "") -> None:85 """Prints a softly indented line, or an empty line if no message."""86 if message:87 print(f" {message}")88 else:89 print()909192# ---------------------------------------------------------------------------93# Persistence94# ---------------------------------------------------------------------------959697def _load(path: Path) -> list[_Pebble]:98 """Reads pebbles from the data file.99100 Returns an empty list when the file is missing or empty.101102 Raises:103 SystemExit: If the file contains corrupt or unrecognized data.104 """105 if not path.exists():106 return []107108 try:109 raw = path.read_text(encoding="utf-8").strip()110 except OSError as exc:111 _die(f"could not read {path}: {exc}")112113 if not raw:114 return []115116 try:117 data = json.loads(raw)118 except json.JSONDecodeError:119 _die(f"data file is corrupt: {path}")120121 if not isinstance(data, list):122 _die(f"unexpected format in {path}")123124 pebbles: list[_Pebble] = []125 for entry in data:126 if (127 isinstance(entry, dict)128 and isinstance(entry.get("text"), str)129 and isinstance(entry.get("timestamp"), str)130 ):131 pebbles.append(_Pebble(text=entry["text"], timestamp=entry["timestamp"]))132 return pebbles133134135def _save(path: Path, pebbles: list[_Pebble]) -> None:136 """Atomically writes pebbles to the data file.137138 Writes to a temporary file in the same directory, fsyncs, then139 atomically replaces the target. The data file is never left in a140 partial or corrupt state.141 """142 path.parent.mkdir(parents=True, exist_ok=True)143 payload = json.dumps(144 [asdict(p) for p in pebbles],145 indent=2,146 ensure_ascii=False,147 )148149 fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")150 succeeded = False151 try:152 with os.fdopen(fd, "w", encoding="utf-8") as f:153 f.write(payload)154 f.write("\n")155 f.flush()156 os.fsync(f.fileno())157 os.replace(tmp_path, str(path))158 succeeded = True159 finally:160 if not succeeded:161 try:162 os.unlink(tmp_path)163 except OSError:164 pass165166167# ---------------------------------------------------------------------------168# Commands169# ---------------------------------------------------------------------------170171172def _cmd_add(path: Path, text: str | None) -> int:173 """Adds a new pebble to the jar."""174 if text is None:175 try:176 text = input(" what's something good? ").strip()177 except (EOFError, KeyboardInterrupt):178 print()179 return 1180181 if not text:182 _put("a pebble needs a few words.")183 return 1184185 pebbles = _load(path)186 pebbles.append(_new_pebble(text))187 _save(path, pebbles)188 _put("noted.")189 return 0190191192def _cmd_shake(path: Path) -> int:193 """Randomly surfaces a handful of past pebbles."""194 pebbles = _load(path)195196 if not pebbles:197 _put("your jar is empty.")198 _put('try: pebbles add "something good"')199 return 0200201 count = min(len(pebbles), random.randint(_SHAKE_MIN, _SHAKE_MAX))202 chosen = random.sample(pebbles, count)203204 _put()205 for pebble in chosen:206 _put(f" \u00b7 {pebble.text}")207 _put()208 return 0209210211def _cmd_list(path: Path) -> int:212 """Lists all pebbles in chronological order."""213 pebbles = _load(path)214215 if not pebbles:216 _put("no pebbles yet.")217 return 0218219 _put()220 for pebble in pebbles:221 date = _format_date(pebble.timestamp)222 _put(f"{date} {pebble.text}")223 _put()224 return 0225226227def _cmd_clear(path: Path) -> int:228 """Removes all pebbles after user confirmation."""229 pebbles = _load(path)230231 if not pebbles:232 _put("already empty.")233 return 0234235 count = len(pebbles)236 noun = _pluralize(count)237238 try:239 answer = input(f" remove {count} {noun}? [y/N] ").strip().lower()240 except (EOFError, KeyboardInterrupt):241 print()242 return 1243244 if answer not in ("y", "yes"):245 _put("kept everything.")246 return 0247248 _save(path, [])249 _put("jar emptied.")250 return 0251252253def _cmd_stats(path: Path) -> int:254 """Shows a brief summary of the jar contents."""255 pebbles = _load(path)256257 if not pebbles:258 _put("no pebbles yet.")259 return 0260261 first = _format_date(pebbles[0].timestamp)262 latest = _format_date(pebbles[-1].timestamp)263 noun = _pluralize(len(pebbles))264265 _put()266 _put(f"{len(pebbles)} {noun}")267 _put(f"first: {first}")268 _put(f"latest: {latest}")269 _put()270 return 0271272273# ---------------------------------------------------------------------------274# CLI wiring275# ---------------------------------------------------------------------------276277278def _build_parser() -> argparse.ArgumentParser:279 """Constructs the argument parser with all subcommands."""280 parser = argparse.ArgumentParser(281 prog="pebbles",282 description="a quiet jar for small, good things.",283 )284 sub = parser.add_subparsers(dest="command")285286 add_parser = sub.add_parser("add", help="add a pebble")287 add_parser.add_argument("text", nargs="?", default=None, help="a few words")288289 sub.add_parser("shake", help="shake the jar")290 sub.add_parser("list", help="list all pebbles")291 sub.add_parser("clear", help="empty the jar")292 sub.add_parser("stats", help="jar summary")293294 return parser295296297def main(argv: Sequence[str] | None = None) -> int:298 """Entry point for the Gratitude Pebbles CLI."""299 parser = _build_parser()300 args = parser.parse_args(argv)301 path = _data_path()302303 if args.command is None:304 parser.print_help()305 return 0306 if args.command == "add":307 return _cmd_add(path, args.text)308 if args.command == "shake":309 return _cmd_shake(path)310 if args.command == "list":311 return _cmd_list(path)312 if args.command == "clear":313 return _cmd_clear(path)314 if args.command == "stats":315 return _cmd_stats(path)316317 parser.print_help()318 return 1319320321if __name__ == "__main__":322 raise SystemExit(main())323