diff --git a/README.md b/README.md index 923cd43..8be8b3d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![quality](https://github.com/matt-dray/pet/actions/workflows/code-quality.yaml/badge.svg) ![tests](https://github.com/matt-dray/pet/actions/workflows/tests.yaml/badge.svg) -A virtual pet that lives on the command line and is remembered between terminal sessions. +A persistent cyberpet that lives on the command line. A proof-of-concept to learn more about packaging-up command-line interfaces (CLIs) made with Python and tools like [InquirerPy](https://inquirerpy.readthedocs.io/en/latest/) and [platformdirs](https://platformdirs.readthedocs.io/en/latest/index.html). diff --git a/pyproject.toml b/pyproject.toml index 21cae7e..e0ffced 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pet" -version = "0.0.0.9000" -description = "A virtual pet that lives on the command line and is remembered between terminal sessions." +version = "0.0.0.9001" +description = "A persistent cyberpet that lives on the command line." readme = "README.md" requires-python = ">=3.12" license = { text = "MIT" } diff --git a/src/pet/__init__.py b/src/pet/__init__.py index 9d28c6c..bea303b 100644 --- a/src/pet/__init__.py +++ b/src/pet/__init__.py @@ -1,19 +1,25 @@ """ -A virtual pet that lives on the command line and is remembered between terminal sessions. +A persistent cyberpet that lives on the command line. """ from .utils import ( - write_pet_data, - read_pet_data, - delete_pet_data, - extract_timestamp, - calculate_time_delta, + init_stats, + read_stats, + delete_stats, + get_datetime, + update_time_stats, + update_health_stats, + feed_pet, + print_pet, ) __all__ = [ - "write_pet_data", - "read_pet_data", - "delete_pet_data", - "extract_timestamp", - "calculate_time_delta", + "init_stats", + "read_stats", + "delete_stats", + "get_datetime", + "update_time_stats", + "update_health_stats", + "feed_pet", + "print_pet", ] diff --git a/src/pet/__main__.py b/src/pet/__main__.py index ea8f2d7..4d14ccc 100644 --- a/src/pet/__main__.py +++ b/src/pet/__main__.py @@ -1,5 +1,5 @@ """ -A virtual pet that lives on the command line and is remembered between terminal sessions. +A persistent cyberpet that lives on the command line. """ from .cli import main diff --git a/src/pet/cli.py b/src/pet/cli.py index a5bd0b8..295624b 100644 --- a/src/pet/cli.py +++ b/src/pet/cli.py @@ -1,52 +1,82 @@ """ -CLI entry with user input. +Command-line interface (CLI) for interacting with pet statistics. +Accepts user input to read, write or delete pet data. """ -import datetime as dt from InquirerPy import inquirer from pathlib import Path from platformdirs import user_data_dir from .utils import ( - write_pet_data, - read_pet_data, - delete_pet_data, - extract_timestamp, - calculate_time_delta, + init_stats, + read_stats, + delete_stats, + get_datetime, + update_time_stats, + update_health_stats, + feed_pet, + print_pet, ) def main(): - pet_data_path = Path(user_data_dir("pet")) / "pet.json" + stats_path = Path(user_data_dir("pet")) / "pet.json" while True: - if not pet_data_path.exists(): - timestamp = dt.datetime.now() + if not stats_path.exists(): name = inquirer.text(message="Your pet's name:").execute() - write_pet_data(pet_data_path, name, timestamp) + init_stats(stats_path, name) - stats = read_pet_data(pet_data_path) + stats = read_stats(stats_path) + update_time_stats(stats, stats_path) + stats = read_stats(stats_path) + update_health_stats(stats, stats_path) + stats = read_stats(stats_path) + + if stats["HEALTH"] <= 0: + print("đŸĒĢ Uh-oh, your pet's health is low!") action = inquirer.select( message="What would you like to do?", - choices=["Check", "Delete", "Quit"], + choices=["📊 Stats", "👀 See", "đŸŖ Feed", "❌ Quit", "👋 Release"], ).execute() - if action == "Check": - timestamp = extract_timestamp(stats["TIMESTAMP"]) - delta = calculate_time_delta(stats["TIMESTAMP"]) - print(f"Name: {stats['NAME']}") - print(f"Birth: {timestamp}") - print(f"Age: {delta} seconds") + if "Stats" in action: + birth = get_datetime(stats["BORN"]) + print( + f"📛 Name: {stats['NAME']}", + f"đŸŖ Birth: {birth['DATE']} at {birth['TIME']}", + f"📅 Age: {stats['AGE']} days", + f"🔋 Health: {stats['HEALTH']}/10", + sep="\n", + ) - if action == "Delete": - delete_pet_data(pet_data_path) - print("Pet data deleted. Goodbye!") - break + if "See" in action: + print_pet() - if action == "Quit": - print("Goodbye!") + if "Feed" in action: + if stats["HEALTH"] == 10: + print(f"đŸ¤ĸ {stats['NAME']} is full (health = 10).") + else: + feed_pet(stats, stats_path) + print( + f"😋 {stats['NAME']} ate the food (health = stats['HEALTH'] + 1)." + ) + + if "Quit" in action: + print(f"👋 Goodbye {stats['NAME']}!") break + if "Release" in action: + confirm = inquirer.confirm( + message=f"🤔 Are you sure? {stats['NAME']} will be gone forever..." + ).execute() + if confirm: + delete_stats(stats_path) + print( + f"đŸĨ˛ {stats['NAME']} was released and their data deleted. Farewell!" + ) + break + if __name__ == "__main__": main() diff --git a/src/pet/utils.py b/src/pet/utils.py index 6d0cd96..6205a90 100644 --- a/src/pet/utils.py +++ b/src/pet/utils.py @@ -1,83 +1,163 @@ """ -Handle pet data. +Functions to manage pet stats stored in a JSON file on disk. """ -import datetime as dt +import datetime import json from pathlib import Path -def write_pet_data(pet_data_path: Path, name: str, timestamp: dt.datetime) -> None: +def init_stats(stats_path: Path, name: str) -> None: """ - Write pet data. + Write initial pet stats to a JSON file on disk. Args: - pet_data_path (Path): The path to the pet data file. - name (str): The pet name. - timestamp (datettime.datetime) The date-time of pet data creation. + stats_path (Path): Path to where the pet's stats json file will be written. + name (str): The pet's name provided by the user. Returns: - None: Writes to disk. + None: File is written to disk. """ - json_dict = {"NAME": name, "TIMESTAMP": timestamp.isoformat()} - pet_data_path.parent.mkdir(parents=True, exist_ok=True) - with pet_data_path.open("w", encoding="utf-8") as f: + timestamp_now = datetime.datetime.now().isoformat() + json_dict = { + "NAME": name, + "BORN": timestamp_now, + "LAST": timestamp_now, + "DELTA": 0, # mins + "AGE": 0, # days + "HEALTH": 10, + } + stats_path.parent.mkdir(parents=True, exist_ok=True) + with stats_path.open("w", encoding="utf-8") as f: json.dump(json_dict, f) -def read_pet_data(pet_data_path: Path) -> dict: +def read_stats(stats_path: Path) -> dict: """ - Read pet data. + Read pet statistics from a JSON file on disk. Args: - pet_data_path (Path): The path to the pet data file. + stats_path (Path): Path to the pet's stats file. Returns: - dict: Pet data. + dict: A dictionary containing the pet's statistics with keys: + - 'NAME' (str): The pet's name. + - 'BORN' (str): The datetime of the pet's birth. + - 'LAST' (str): The datetime of the last interaction with the pet. + - 'DELTA' (str): Difference in minutes from last interaction to now. + - 'AGE' (int): The pet's age in days. + - 'HEALTH' (int): The pet's health value (out of 10). """ - pet_data_text = pet_data_path.read_text(encoding="utf-8") - pet_data_json = json.loads(pet_data_text) - return pet_data_json + stats_text = stats_path.read_text(encoding="utf-8") + stats_json = json.loads(stats_text) + return stats_json -def delete_pet_data(pet_data_path: Path) -> None: +def delete_stats(stats_path: Path) -> None: """ - Delete pet data. + Delete the stats file on disk. Args: - pet_data_path (Path): The path to the pet data file. + stats_path (Path): Path to the pet's stats file. Returns: - None: Pet data on disk deleted. + None: File is deleted from disk. """ - if pet_data_path.exists(): - pet_data_path.unlink() + if stats_path.exists(): + stats_path.unlink() -def extract_timestamp(timestamp: str) -> str: +def get_datetime(timestamp: str) -> dict: """ - Extract timestamp from pet data. + Extract a date and time from an ISO timestamp string. Args: - timestamp (str): The path to the pet data file. + timestamp (str): The ISO string timestamp for conversion. Returns: - str: Formatted date-time. + dict: A dictionary with keys: + - 'DATE' (str): The date portion of the ISO timestamp. + - 'TIME' (str): The time portion of the ISO timestamp. """ - timestamp_iso = dt.datetime.fromisoformat(timestamp) - return timestamp_iso.strftime("%d %B %Y at %H:%M:%S") + timestamp_iso = datetime.datetime.fromisoformat(timestamp) + date = timestamp_iso.strftime("%d %b %Y") + time = timestamp_iso.strftime("%H:%M") + return {"DATE": date, "TIME": time} -def calculate_time_delta(timestamp: str) -> int: +def update_time_stats(stats: dict, stats_path: Path) -> None: """ - Calculate difference between now and timestamp from pet data. + Overwrite time-related keys in the stats json file on disk. Args: - timestamp (str): The path to the pet data file. + stats (dict): Pet stats read from the stats json file on disk. + stats_path (Path): Path to where the pet's stats json file will be written. Returns: - int: Elapsed time in seconds. + None: File is written to disk. """ - timestamp_iso = dt.datetime.fromisoformat(timestamp) - delta = dt.datetime.now() - timestamp_iso - return int(delta.total_seconds()) + now = datetime.datetime.now() + last = stats["LAST"] + last_dt = datetime.datetime.fromisoformat(last) + delta = now - last_dt + delta_mins = delta.total_seconds() // 60 + + stats["LAST"] = now.isoformat() + stats["DELTA"] = int(delta_mins) + stats["AGE"] = delta.days + + with stats_path.open("w", encoding="utf-8") as f: + json.dump(stats, f) + + +def update_health_stats(stats: dict, stats_path: Path) -> None: + """ + Overwrite health-related keys in the stats json file on disk. + + Args: + stats (dict): Pet stats read from the stats json file on disk. + stats_path (Path): Path to where the pet's stats json file will be written. + + Returns: + None: File is written to disk. + """ + delta = stats["DELTA"] + health = stats["HEALTH"] + + health_loss = delta // 60 # lose one health per hour + new_health = health - health_loss + if new_health < 0: + new_health = 0 + + stats["HEALTH"] = new_health + + with stats_path.open("w", encoding="utf-8") as f: + json.dump(stats, f) + + +def feed_pet(stats: dict, stats_path: Path) -> None: + health = stats["HEALTH"] + health += 1 + new_health = min(health, 10) + + stats["HEALTH"] = new_health + + with stats_path.open("w", encoding="utf-8") as f: + json.dump(stats, f) + + +def print_pet() -> None: + """ + Print an image of your pet. + + Returns: + None: Text is printed to the screen. + """ + print( + r" ", + r" /\__/\ ", + r" ={ o x o}= < meow ", + r" L( u u ) ", + r" ", + sep="\n", + ) diff --git a/tests/test_pet.py b/tests/test_pet.py index 26662b3..0b24f7b 100644 --- a/tests/test_pet.py +++ b/tests/test_pet.py @@ -1,42 +1,41 @@ -import datetime as dt +import datetime from pathlib import Path from pet import utils -def test_write_and_read_pet_data(tmp_path: Path): +def test_write_and_read_stats(tmp_path: Path): """Ensure we can write and read pet data.""" test_file = tmp_path / "pet.json" - timestamp = dt.datetime(2024, 1, 1, 12, 0, 0) name = "Brian" - utils.write_pet_data(test_file, name, timestamp) - data = utils.read_pet_data(test_file) + utils.init_stats(test_file, name) + data = utils.read_stats(test_file) assert data["NAME"] == name - assert data["TIMESTAMP"] == timestamp.isoformat() + assert data["BORN"] == data["LAST"] + assert data["DELTA"] == 0 + assert data["AGE"] == 0 + assert data["HEALTH"] == 10 -def test_extract_timestamp_and_delta(): - """Ensure timestamp handling works.""" - now = dt.datetime.now() +def test_get_datetime(): + """Ensure date and time are extracted correctly from saved timestamp.""" + now = datetime.datetime.now() iso_str = now.isoformat() - # Check readable timestamp format - readable = utils.extract_timestamp(iso_str) - assert isinstance(readable, str) - assert str(now.year) in readable + birth_dt = utils.get_datetime(iso_str) - # Check delta returns an int and near-zero for 'now' - delta = utils.calculate_time_delta(iso_str) - assert isinstance(delta, int) - assert delta >= 0 + assert isinstance(birth_dt, dict) + assert isinstance(birth_dt["DATE"], str) + assert isinstance(birth_dt["TIME"], str) + assert str(now.year) in birth_dt["DATE"] -def test_pet_data_gets_deleted(tmp_path: Path): +def test_delete_stats(tmp_path: Path): """Make sure pet data file gets deleted.""" test_file = tmp_path / "pet.json" test_file.write_text("{'NAME': 'Brian'}") - utils.delete_pet_data(test_file) + utils.delete_stats(test_file) assert not test_file.exists() diff --git a/uv.lock b/uv.lock index 3e26285..31acb6b 100644 --- a/uv.lock +++ b/uv.lock @@ -44,7 +44,7 @@ wheels = [ [[package]] name = "pet" -version = "0.1.0" +version = "0.0.0.9001" source = { editable = "." } dependencies = [ { name = "inquirerpy" }, @@ -55,6 +55,14 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "ruff" }, + { name = "ty" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -62,10 +70,18 @@ requires-dist = [ { name = "inquirerpy", specifier = ">=0.3.4" }, { name = "platformdirs", specifier = ">=4.5.0" }, { name = "pytest", marker = "extra == 'dev'" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.10" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12.11" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a23" }, ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "ruff", specifier = ">=0.12.11" }, + { name = "ty", specifier = ">=0.0.1a23" }, +] + [[package]] name = "pfzy" version = "0.3.4" @@ -156,6 +172,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636, upload-time = "2025-10-16T18:05:38.995Z" }, ] +[[package]] +name = "ty" +version = "0.0.1a24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/71/a1db0d604be8d0067342e7aad74ab0c7fec6bea20eb33b6a6324baabf45f/ty-0.0.1a24.tar.gz", hash = "sha256:3273c514df5b9954c9928ee93b6a0872d12310ea8de42249a6c197720853e096", size = 4386721, upload-time = "2025-10-23T13:33:29.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/89/21fb275cb676d3480b67fbbf6eb162aec200b4dcb10c7885bffc754dc73f/ty-0.0.1a24-py3-none-linux_armv6l.whl", hash = "sha256:d478cd02278b988d5767df5821a0f03b99ef848f6fc29e8c77f30e859b89c779", size = 8833903, upload-time = "2025-10-23T13:32:53.552Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/beb127bce67fc2a1f3704b6b39505d77a7078a61becfbe10c5ee7ed9f5d8/ty-0.0.1a24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:de758790f05f0a3bb396da4c75f770c85ab3a46095ec188b830c916bd5a5bc10", size = 8691210, upload-time = "2025-10-23T13:32:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/39/bd/190f5e934339669191179fa01c60f5a140822dc465f0d4d312985903d109/ty-0.0.1a24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:68f325ddc8cfb7a7883501e5e22f01284c5d5912aaa901d21e477f38edf4e625", size = 8138421, upload-time = "2025-10-23T13:32:58.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/84/f08020dabad1e660957bb641b2ba42fe1e1e87192c234b1fc1fd6fb42cf2/ty-0.0.1a24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49a52bbb1f8b0b29ad717d3fd70bd2afe752e991072fd13ff2fc14f03945c849", size = 8419861, upload-time = "2025-10-23T13:33:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/e5/cc/e3812f7c1c2a0dcfb1bf8a5d6a7e5aa807a483a632c0d5734ea50a60a9ae/ty-0.0.1a24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12945fe358fb0f73acf0b72a29efcc80da73f8d95cfe7f11a81e4d8d730e7b18", size = 8641443, upload-time = "2025-10-23T13:33:01.887Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8b/3fc047d04afbba4780aba031dc80e06f6e95d888bbddb8fd6da502975cfb/ty-0.0.1a24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6231e190989798b0860d15a8f225e3a06a6ce442a7083d743eb84f5b4b83b980", size = 8997853, upload-time = "2025-10-23T13:33:03.951Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d9/ae1475d9200ecf6b196a59357ea3e4f4aa00e1d38c9237ca3f267a4a3ef7/ty-0.0.1a24-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c6401f4a7532eab63dd7fe015c875792a701ca4b1a44fc0c490df32594e071f", size = 9676864, upload-time = "2025-10-23T13:33:05.744Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d9/abd6849f0601b24d5d5098e47b00dfbdfe44a4f6776f2e54a21005739bdf/ty-0.0.1a24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:83c69759bfa2a00278aa94210eded35aea599215d16460445cbbf5b36f77c454", size = 9351386, upload-time = "2025-10-23T13:33:07.807Z" }, + { url = "https://files.pythonhosted.org/packages/63/5c/639e0fe3b489c65b12b38385fe5032024756bc07f96cd994d7df3ab579ef/ty-0.0.1a24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71146713cb8f804aad2b2e87a8efa7e7df0a5a25aed551af34498bcc2721ae03", size = 9517674, upload-time = "2025-10-23T13:33:09.641Z" }, + { url = "https://files.pythonhosted.org/packages/78/ae/323f373fcf54a883e39ea3fb6f83ed6d1eda6dfd8246462d0cfd81dac781/ty-0.0.1a24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4836854411059de592f0ecc62193f2b24fc3acbfe6ce6ce0bf2c6d1a5ea9de7", size = 9000468, upload-time = "2025-10-23T13:33:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/14/26/1a4be005aa4326264f0e7ce554844d5ef8afc4c5600b9a38b05671e9ed18/ty-0.0.1a24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a7f0b8546d27605e09cd0fe08dc28c1d177bf7498316dd11c3bb8ef9440bf2e1", size = 8377164, upload-time = "2025-10-23T13:33:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/73/2f/dcd6b449084e53a2beb536d8721a2517143a2353413b5b323d6eb9a31705/ty-0.0.1a24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4e2fbf7dce2311127748824e03d9de2279e96ab5713029c3fa58acbaf19b2f51", size = 8672709, upload-time = "2025-10-23T13:33:15.213Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2e/8b3b45d46085a79547e6db5295f42c6b798a0240d34454181e2ca947183c/ty-0.0.1a24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f35b7f0a65f7e34e59f34173164946c89a4c4b1d1c18cabe662356a35f33efcd", size = 8788732, upload-time = "2025-10-23T13:33:17.347Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/7675ff8693ad13044d86d8d4c824caf6bbb00340df05ad93d0e9d1e0338b/ty-0.0.1a24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:120fe95eaf2a200f531f949e3dd0a9d95ab38915ce388412873eae28c499c0b9", size = 9095693, upload-time = "2025-10-23T13:33:19.836Z" }, + { url = "https://files.pythonhosted.org/packages/62/0b/bdba5d31aa3f0298900675fd355eec63a9c682aa46ef743dbac8f28b4608/ty-0.0.1a24-py3-none-win32.whl", hash = "sha256:d8d8379264a8c14e1f4ca9e117e72df3bf0a0b0ca64c5fd18affbb6142d8662a", size = 8361302, upload-time = "2025-10-23T13:33:21.572Z" }, + { url = "https://files.pythonhosted.org/packages/b4/48/127a45e16c49563df82829542ca64b0bc387591a777df450972bc85957e6/ty-0.0.1a24-py3-none-win_amd64.whl", hash = "sha256:2e826d75bddd958643128c309f6c47673ed6cef2ea5f2b3cd1a1159a1392971a", size = 9039221, upload-time = "2025-10-23T13:33:23.055Z" }, + { url = "https://files.pythonhosted.org/packages/31/67/9161fbb8c1a2005938bdb5ccd4e4c98ee4bea2d262afb777a4b69aa15eb5/ty-0.0.1a24-py3-none-win_arm64.whl", hash = "sha256:2efbfcdc94d306f0d25f3efe2a90c0f953132ca41a1a47d0bae679d11cdb15aa", size = 8514044, upload-time = "2025-10-23T13:33:27.816Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14"