buddy.py
python · 538 lines
1#!/usr/bin/env python32"""buddy.py - A cozy ASCII pocket pet for your terminal.34A single-file, zero-dependency CLI tool that renders a gentle ASCII5companion with mood-driven animations. Runs for a bounded duration,6then exits cleanly.78Usage:9 python buddy.py10 python buddy.py --name Mochi --mood happy --duration 3011"""1213from __future__ import annotations1415import argparse16import enum17import os18import random19import sys20import time21from dataclasses import dataclass2223# ---------------------------------------------------------------------------24# Constants25# ---------------------------------------------------------------------------2627_VERSION = "1.0.0"2829_MIN_DURATION_SECONDS = 530_MAX_DURATION_SECONDS = 12031_DEFAULT_DURATION_SECONDS = 303233_FRAME_INTERVAL_SECONDS = 0.1534_ACTION_HOLD_FRAMES = 1235_BLINK_HOLD_FRAMES = 33637# ANSI escape helpers - safe on macOS, Linux, and Windows 10+ terminals.38_ESC_CLEAR = "\033[2J"39_ESC_HOME = "\033[H"40_ESC_HIDE_CURSOR = "\033[?25l"41_ESC_SHOW_CURSOR = "\033[?25h"42_ESC_CLEAR_LINE = "\033[K"434445# ---------------------------------------------------------------------------46# Domain types47# ---------------------------------------------------------------------------484950class Mood(enum.Enum):51 """Behavioral mood that governs which animations the pet performs."""5253 HAPPY = "happy"54 CHILL = "chill"55 SLEEPY = "sleepy"565758class Action(enum.Enum):59 """Discrete animation the pet can perform between idle frames."""6061 IDLE = "idle"62 BLINK = "blink"63 WIGGLE = "wiggle"64 BOUNCE = "bounce"65 DOZE = "doze"666768@dataclass(frozen=True)69class PetConfig:70 """Immutable configuration for a single run of the pet."""7172 name: str73 mood: Mood74 duration: int # seconds757677# ---------------------------------------------------------------------------78# Action weights per mood79# ---------------------------------------------------------------------------8081# Mapping from mood to (action, relative_weight) pairs. Weights are82# integers so the selection logic stays free of floating-point drift.83_MOOD_WEIGHTS: dict[Mood, list[tuple[Action, int]]] = {84 Mood.HAPPY: [85 (Action.IDLE, 3),86 (Action.BLINK, 3),87 (Action.WIGGLE, 3),88 (Action.BOUNCE, 2),89 ],90 Mood.CHILL: [91 (Action.IDLE, 5),92 (Action.BLINK, 3),93 (Action.WIGGLE, 1),94 ],95 Mood.SLEEPY: [96 (Action.IDLE, 3),97 (Action.BLINK, 2),98 (Action.DOZE, 4),99 ],100}101102103# ---------------------------------------------------------------------------104# ASCII art frames105# ---------------------------------------------------------------------------106107_FRAME_IDLE = r"""108 /\_/\109 ( o.o )110 > ^ <111"""112113_FRAME_BLINK = r"""114 /\_/\115 ( -.- )116 > ^ <117"""118119_FRAME_WIGGLE_LEFT = r"""120 /\_/\121 ( o.o )122 /> ^ <123"""124125_FRAME_WIGGLE_RIGHT = r"""126 /\_/\127 ( o.o )128 > ^ <\129"""130131_FRAME_BOUNCE_UP = r"""132 /\_/\133 ( ^.^ )134 > ^ <135 / \136"""137138_FRAME_BOUNCE_DOWN = r"""139140 /\_/\141 ( ^.^ )142 > ^ <143"""144145_FRAME_DOZE = r"""146 /\_/\147 ( -.- ) z148 > ^ <149"""150151_FRAME_DOZE_DEEP = r"""152 /\_/\153 ( -.- ) zZ154 > ^ <155"""156157158# ---------------------------------------------------------------------------159# CLI parsing160# ---------------------------------------------------------------------------161162163def _clamped_duration(value: str) -> int:164 """Validate and clamp the --duration flag to the allowed range.165166 Args:167 value: Raw string from argparse.168169 Returns:170 Duration in seconds, clamped to [_MIN_DURATION_SECONDS, _MAX_DURATION_SECONDS].171172 Raises:173 argparse.ArgumentTypeError: If the value is not a valid integer.174175 """176 try:177 parsed = int(value)178 except ValueError as exc:179 msg = f"'{value}' is not a valid integer"180 raise argparse.ArgumentTypeError(msg) from exc181182 return max(_MIN_DURATION_SECONDS, min(parsed, _MAX_DURATION_SECONDS))183184185def parse_args(argv: list[str] | None = None) -> PetConfig:186 """Parse command-line arguments into an immutable PetConfig.187188 Args:189 argv: Argument list. Defaults to sys.argv[1:] when None.190191 Returns:192 Fully validated PetConfig ready for the animation loop.193194 """195 parser = argparse.ArgumentParser(196 prog="buddy",197 description="A cozy ASCII pocket pet that keeps you company.",198 epilog="Press Ctrl+C at any time to say goodbye early.",199 )200 parser.add_argument(201 "--name",202 type=str,203 default="Buddy",204 help="Give your pet a name (default: Buddy)",205 )206 parser.add_argument(207 "--mood",208 type=str,209 choices=[m.value for m in Mood],210 default=Mood.CHILL.value,211 help="Set the pet's mood (default: chill)",212 )213 parser.add_argument(214 "--duration",215 type=_clamped_duration,216 default=_DEFAULT_DURATION_SECONDS,217 metavar="SECONDS",218 help=(219 f"How long the pet stays, in seconds "220 f"({_MIN_DURATION_SECONDS}-{_MAX_DURATION_SECONDS}, "221 f"default: {_DEFAULT_DURATION_SECONDS})"222 ),223 )224 parser.add_argument(225 "--version",226 action="version",227 version=f"%(prog)s {_VERSION}",228 )229230 args = parser.parse_args(argv)231 return PetConfig(232 name=args.name,233 mood=Mood(args.mood),234 duration=args.duration,235 )236237238# ---------------------------------------------------------------------------239# Terminal helpers240# ---------------------------------------------------------------------------241242243def _enable_win_vt() -> None:244 """Enable virtual-terminal processing on Windows 10+.245246 No-op on non-Windows platforms. Silently ignored if the call fails247 (e.g. older Windows or non-console handle).248 """249 if os.name != "nt":250 return251 try:252 import ctypes # noqa: PLC0415 # pylint: disable=import-outside-toplevel253254 kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]255 handle = kernel32.GetStdHandle(-11)256 mode = ctypes.c_ulong()257 kernel32.GetConsoleMode(handle, ctypes.byref(mode))258 # 0x0004 = ENABLE_VIRTUAL_TERMINAL_PROCESSING259 kernel32.SetConsoleMode(handle, mode.value | 0x0004)260 except (ImportError, OSError, ValueError):261 pass262263264def clear_screen() -> None:265 """Clear the terminal and reset the cursor to the top-left corner."""266 sys.stdout.write(_ESC_CLEAR + _ESC_HOME)267 sys.stdout.flush()268269270def _hide_cursor() -> None:271 """Hide the terminal cursor for cleaner animation."""272 sys.stdout.write(_ESC_HIDE_CURSOR)273 sys.stdout.flush()274275276def _show_cursor() -> None:277 """Restore the terminal cursor."""278 sys.stdout.write(_ESC_SHOW_CURSOR)279 sys.stdout.flush()280281282# ---------------------------------------------------------------------------283# Rendering284# ---------------------------------------------------------------------------285286287def render(name: str, frame: str, status: str) -> None:288 """Write a single animation frame to the terminal.289290 Moves the cursor home instead of clearing to avoid full-screen flicker.291 Each output line is padded with a clear-to-EOL escape so leftover292 characters from wider previous frames are erased.293294 Args:295 name: Display name shown above the pet.296 frame: Multi-line ASCII art string for this frame.297 status: Short status line shown below the pet.298299 """300 lines: list[str] = [301 "",302 f" {name}",303 *frame.strip("\n").splitlines(),304 "",305 f" {status}",306 "",307 ]308 buf = _ESC_HOME + "".join(line + _ESC_CLEAR_LINE + "\n" for line in lines)309 sys.stdout.write(buf)310 sys.stdout.flush()311312313# ---------------------------------------------------------------------------314# Animation primitives315# ---------------------------------------------------------------------------316317318def idle_frames() -> list[str]:319 """Return frames for the idle (standing) animation.320321 Returns:322 Single-element list containing the default pose.323324 """325 return [_FRAME_IDLE]326327328def blink_frames() -> list[str]:329 """Return frames for a single eye-blink.330331 Returns:332 Sequence: eyes closed, then eyes open.333334 """335 return [_FRAME_BLINK] * _BLINK_HOLD_FRAMES + [_FRAME_IDLE]336337338def wiggle_frames() -> list[str]:339 """Return frames for a left-right wiggle.340341 Returns:342 Sequence alternating left and right leans.343344 """345 return [346 _FRAME_WIGGLE_LEFT,347 _FRAME_IDLE,348 _FRAME_WIGGLE_RIGHT,349 _FRAME_IDLE,350 _FRAME_WIGGLE_LEFT,351 _FRAME_IDLE,352 ]353354355def bounce_frames() -> list[str]:356 """Return frames for a small vertical bounce.357358 Returns:359 Sequence: up, down, up, settle.360361 """362 return [363 _FRAME_BOUNCE_UP,364 _FRAME_BOUNCE_DOWN,365 _FRAME_BOUNCE_UP,366 _FRAME_IDLE,367 ]368369370def doze_frames() -> list[str]:371 """Return frames for a sleepy doze animation.372373 Returns:374 Sequence cycling through doze depths.375376 """377 return [378 _FRAME_DOZE,379 _FRAME_DOZE,380 _FRAME_DOZE_DEEP,381 _FRAME_DOZE_DEEP,382 _FRAME_DOZE,383 _FRAME_BLINK,384 ]385386387_ACTION_FRAMES: dict[Action, list[str]] = {388 Action.IDLE: idle_frames(),389 Action.BLINK: blink_frames(),390 Action.WIGGLE: wiggle_frames(),391 Action.BOUNCE: bounce_frames(),392 Action.DOZE: doze_frames(),393}394395_ACTION_STATUS: dict[Action, str] = {396 Action.IDLE: "...",397 Action.BLINK: "*blink*",398 Action.WIGGLE: "~wiggle~",399 Action.BOUNCE: "^bounce^",400 Action.DOZE: "zzz...",401}402403404# ---------------------------------------------------------------------------405# Action selection406# ---------------------------------------------------------------------------407408409def choose_action(mood: Mood) -> Action:410 """Select a random action weighted by the pet's current mood.411412 Uses ``random.choices`` with integer weights so results are413 deterministic for a given PRNG state.414415 Args:416 mood: The pet's active mood governing action probabilities.417418 Returns:419 A single Action to animate next.420421 """422 entries = _MOOD_WEIGHTS[mood]423 actions = [a for a, _ in entries]424 weights = [w for _, w in entries]425 return random.choices(actions, weights=weights, k=1)[0] # noqa: S311426427428# ---------------------------------------------------------------------------429# Greeting / Goodbye430# ---------------------------------------------------------------------------431432433def print_greeting(config: PetConfig) -> None:434 """Render the opening greeting when the pet appears.435436 Args:437 config: Current run configuration.438439 """440 clear_screen()441 lines = [442 "",443 f" {config.name} has arrived!",444 f" Mood: {config.mood.value}",445 f" Staying for {config.duration}s",446 "",447 ]448 sys.stdout.write("\n".join(lines) + "\n")449 sys.stdout.flush()450 time.sleep(1.5)451452453def print_goodbye(name: str) -> None:454 """Render the farewell message when the pet leaves.455456 Args:457 name: The pet's display name.458459 """460 clear_screen()461 lines = [462 "",463 f" {name} waves goodbye!",464 "",465 _FRAME_IDLE,466 " See you next time!",467 "",468 ]469 sys.stdout.write("\n".join(lines) + "\n")470 sys.stdout.flush()471472473# ---------------------------------------------------------------------------474# Main animation loop475# ---------------------------------------------------------------------------476477478def run_animation(config: PetConfig) -> None:479 """Drive the frame-based animation loop until duration expires.480481 The loop is structured around *actions*: each action is a short482 sequence of frames held for ``_ACTION_HOLD_FRAMES`` ticks before483 a new action is chosen. Between actions, the pet returns to idle484 for a brief rest so the rhythm feels calm rather than frenetic.485486 Args:487 config: Current run configuration.488489 """490 deadline = time.monotonic() + config.duration491 frames_in_action = 0492 current_action = Action.IDLE493 current_frames = _ACTION_FRAMES[Action.IDLE]494 frame_index = 0495496 while time.monotonic() < deadline:497 # Advance or pick a new action when the current one completes.498 if frames_in_action >= _ACTION_HOLD_FRAMES:499 current_action = choose_action(config.mood)500 current_frames = _ACTION_FRAMES[current_action]501 frame_index = 0502 frames_in_action = 0503504 frame_art = current_frames[frame_index % len(current_frames)]505 status = _ACTION_STATUS[current_action]506507 render(config.name, frame_art, status)508509 frame_index += 1510 frames_in_action += 1511 time.sleep(_FRAME_INTERVAL_SECONDS)512513514# ---------------------------------------------------------------------------515# Entry point516# ---------------------------------------------------------------------------517518519def main() -> None:520 """Parse arguments, run the animation, and exit cleanly."""521 config = parse_args()522523 _enable_win_vt()524 _hide_cursor()525526 try:527 print_greeting(config)528 run_animation(config)529 except KeyboardInterrupt:530 pass531 finally:532 _show_cursor()533 print_goodbye(config.name)534535536if __name__ == "__main__":537 main()538