From 99983ebcc68c1acb662d30ea8c249001a92b322c Mon Sep 17 00:00:00 2001 From: willtheorangeguy Date: Fri, 28 Aug 2026 20:54:59 -0600 Subject: [PATCH] feat: port the cli/config/data/history refactor from refactor-data-config-history-cli Splits the monolithic main.py workout() into main.py (REPL), cli.py (argparse entry point and flags), config.py (~/.pyworkout/config.json, deep-merged over built-in defaults), data.py (single source of truth for groups/exercises/reps), and history.py (~/.pyworkout/history.json). The refactor-data-config-history-cli branch shares no git history with main (disjoint roots), so this ports its actual end state - identified via `git diff origin/main origin/refactor-data-config-history-cli` and isolating the branch's one real commit (fd8953b "feat: refactor") from unrelated CI/dependabot/docs-restructuring noise picked up along its own, differently-rewritten history. Ported: cli.py, config.py, data.py, history.py, main.py, __init__.py, __main__.py, tests/{conftest,test_cli,test_config,test_history,test_main}.py, docs/config.sample.json (new). Reconciled by hand: pyproject.toml (kept main's [tool.bandit] section, added version bump/keywords/entry point/ py-modules), setup.cfg (dropped [metadata]/[options.entry_points], now superseded by pyproject.toml), setup.py removed, CHANGELOG.md (new v1.3.0 entry above v1.2.0), docs/internal/known-issues.md (issues #2 skip/stats, #3 drifted help text, and #4 video needing source edits are resolved by this split; #1 GPL banner remains open; noted that README/usage/commands/configuration docs still describe the pre-refactor model and need a follow-up pass). Fixes real bugs along the way: triceps `list` showing glutes exercises, `start` always reporting 0% progress, an off-by-one on five-exercise groups, and `stats` breaking after `skip`. Not touched: main's docs/ house-contract site (mkdocs structure, README.md, usage/commands/configuration/installation pages), health files (CODE_OF_CONDUCT/CONTRIBUTING/SECURITY/PLANNING), CLAUDE.md, CI workflows - these diverged from the refactor branch independently and reconciling their content would mean guessing at documentation house style rather than porting code. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Y38yxr1x54TZQgwdcHUbtE --- CHANGELOG.md | 21 + __init__.py | 4 +- __main__.py | 7 +- cli.py | 106 ++++ config.py | 110 ++++ data.py | 102 ++++ docs/config.sample.json | 18 + docs/internal/known-issues.md | 40 +- history.py | 91 ++++ main.py | 909 ++++++++++------------------------ pyproject.toml | 10 +- setup.cfg | 8 - setup.py | 29 -- tests/conftest.py | 21 + tests/test_cli.py | 95 ++++ tests/test_config.py | 68 +++ tests/test_history.py | 57 +++ tests/test_main.py | 103 +++- 18 files changed, 1085 insertions(+), 714 deletions(-) create mode 100644 cli.py create mode 100644 config.py create mode 100644 data.py create mode 100644 docs/config.sample.json create mode 100644 history.py delete mode 100644 setup.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_history.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 592f977..93147d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [v1.3.0](https://github.com/willtheorangeguy/PyWorkout/releases/tag/v1.3.0) + +### Added + +- Workout history saved between sessions (`history` command and `--history` flag). +- Command-line flags: `--group`, `--list`, `--history`, `--init-config`, `--config`, `--version`. +- User config file (`~/.pyworkout/config.json`) for exercises, reps, and video paths — no code editing required. + +### Changed + +- Exercise data refactored into a single structure; the old parallel-list / if-elif layout is gone. +- Video paths now come from config instead of being hardcoded. +- Packaging consolidated into `pyproject.toml` (removed `setup.py`, trimmed `setup.cfg`). + +### Fixed + +- Triceps `list` showing glutes exercises. +- `start` always reporting 0% progress. +- Off-by-one bounds and a rep-count mismatch on five-exercise groups. +- `stats` no longer breaks after `skip`. + ## [v1.2.0](https://github.com/willtheorangeguy/PyWorkout/releases/tag/v1.2.0) ### Added diff --git a/__init__.py b/__init__.py index 9cd7da2..88793a4 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,9 @@ try: from main import workout + from cli import main except ImportError: from .main import workout + from .cli import main -__all__ = ["workout"] +__all__ = ["workout", "main"] diff --git a/__main__.py b/__main__.py index c71155c..b127494 100644 --- a/__main__.py +++ b/__main__.py @@ -2,7 +2,10 @@ # pylint: disable=invalid-name, import-error -from main import workout +try: + from cli import main +except ImportError: + from .cli import main if __name__ == "__main__": - workout() + main() diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..abda381 --- /dev/null +++ b/cli.py @@ -0,0 +1,106 @@ +""" +PyWorkout command-line interface. + +Wraps the interactive ``workout()`` REPL with argparse flags for scriptable, +non-interactive use. With no arguments it simply launches the REPL, so the +existing interactive behaviour (and its test suite) is unchanged. +""" + +import argparse +import sys + +try: + import config as config_mod + import history as history_mod + from main import workout +except ImportError: # installed as part of the package + from . import config as config_mod + from . import history as history_mod + from .main import workout + +__version__ = "1.3.0" + + +def build_parser(): + """Construct the argument parser.""" + parser = argparse.ArgumentParser( + prog="pyworkout", + description="A minimal CLI to keep you inspired during your workout!", + ) + parser.add_argument( + "-g", "--group", help="preselect a muscle group and skip the prompt" + ) + parser.add_argument( + "--list", + nargs="?", + const="__ALL__", + metavar="GROUP", + help="list exercises (optionally for one group) and exit", + ) + parser.add_argument( + "--history", action="store_true", help="print past workouts and exit" + ) + parser.add_argument( + "--init-config", + action="store_true", + help="write a default config file you can edit, then exit", + ) + parser.add_argument( + "--config", metavar="PATH", help="use an alternate config file" + ) + parser.add_argument( + "--version", action="version", version="pyworkout " + __version__ + ) + return parser + + +def parse_args(argv=None): + """Parse ``argv`` (defaults to ``sys.argv``) into a namespace.""" + return build_parser().parse_args(argv) + + +def _print_group(workouts, key): + """Print one group's exercises.""" + print(key.capitalize() + ":") + for i, (name, reps) in enumerate(workouts[key]["exercises"]): + print(f"{i + 1}. {name}\t 2 Sets of {reps} Reps") + + +def _do_list(args): + """Handle ``--list``.""" + workouts = config_mod.load_config(args.config) + if args.list == "__ALL__": + for key in config_mod.group_order(workouts): + _print_group(workouts, key) + print("") + else: + key = args.list.lower() + if key not in workouts: + print(f"Unknown group: {args.list}") + return 1 + _print_group(workouts, key) + return 0 + + +def main(argv=None): + """Entry point: dispatch flags or launch the interactive REPL.""" + args = parse_args(argv) + + if args.init_config: + path = config_mod.write_default_config(args.config) + print(f"Wrote default config to {path}") + return 0 + + if args.history: + print(history_mod.format_history()) + return 0 + + if args.list is not None: + return _do_list(args) + + workout(preselect=args.group, config_path=args.config) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config.py b/config.py new file mode 100644 index 0000000..8a160b6 --- /dev/null +++ b/config.py @@ -0,0 +1,110 @@ +""" +PyWorkout user configuration. + +A user can override exercises, rep counts, and (most usefully) video paths +without editing source. Config lives at ``~/.pyworkout/config.json`` and is +deep-merged over the built-in defaults in ``data.py`` -- so overriding just one +group's video path leaves every other group untouched. + +Missing config -> defaults, silently. Malformed JSON -> defaults, with a single +warning line (this is a hobby tool; never crash on a bad config). +""" + +import copy +import json +import os + +try: + from data import WORKOUTS, GROUP_ORDER +except ImportError: # installed as part of the package + from .data import WORKOUTS, GROUP_ORDER + +CONFIG_DIR_NAME = ".pyworkout" +CONFIG_FILE_NAME = "config.json" + + +def config_dir(): + """Return the directory holding PyWorkout config and history.""" + return os.path.join(os.path.expanduser("~"), CONFIG_DIR_NAME) + + +def default_config_path(): + """Return the default path to ``config.json``.""" + return os.path.join(config_dir(), CONFIG_FILE_NAME) + + +def _deep_merge(base, override): + """Return a deep copy of ``base`` with ``override`` recursively merged in.""" + result = copy.deepcopy(base) + for key, value in override.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = _deep_merge(result[key], value) + else: + result[key] = copy.deepcopy(value) + return result + + +def default_workouts(): + """Return a deep copy of the built-in workout data.""" + return copy.deepcopy(WORKOUTS) + + +def load_config(path=None): + """ + Load the merged workout config. + + Reads JSON from ``path`` (defaults to ``default_config_path()``) and + deep-merges it over the built-in defaults. Returns the defaults unchanged + when the file is absent or unreadable. + """ + if path is None: + path = default_config_path() + + if not os.path.exists(path): + return default_workouts() + + try: + with open(path, encoding="UTF-8") as handle: + user = json.load(handle) + except (json.JSONDecodeError, OSError) as err: + print(f"Warning: could not read config at {path} ({err}). Using defaults.") + return default_workouts() + + if not isinstance(user, dict): + print(f"Warning: config at {path} is not a JSON object. Using defaults.") + return default_workouts() + + return _deep_merge(WORKOUTS, user) + + +def write_default_config(path=None): + """ + Write the built-in defaults to ``path`` as a starting point users can edit. + + Creates the config directory if needed. Returns the path written. + """ + if path is None: + path = default_config_path() + + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="UTF-8") as handle: + json.dump(default_workouts(), handle, indent=2) + return path + + +def group_order(workouts=None): + """ + Return group keys in display order. + + Built-in groups keep their canonical order; any extra groups a user added + via config are appended in sorted order. + """ + if workouts is None: + return list(GROUP_ORDER) + ordered = [g for g in GROUP_ORDER if g in workouts] + extra = sorted(k for k in workouts if k not in GROUP_ORDER) + return ordered + extra diff --git a/data.py b/data.py new file mode 100644 index 0000000..703bb51 --- /dev/null +++ b/data.py @@ -0,0 +1,102 @@ +""" +PyWorkout default exercise data. + +Single source of truth for muscle groups, their exercises, rep counts, and +default video paths. The CLI iterates these structures instead of the old +parallel-list / if-elif layout, so exercises and rep counts can never drift +out of sync and per-group bounds always come from ``len()``. + +User config (see ``config.py``) is deep-merged over ``WORKOUTS`` at runtime, +so anything here is just the built-in default. +""" + +# Order groups are presented in the selection menu. The day-of-week +# recommendation maps datetime "%w" (Sun=0) + 1 onto this 1-based list. +GROUP_ORDER = ["abs", "quads", "glutes", "triceps", "biceps", "back", "chest"] + +# Each group: a human "display" word (used in " muscle group selected!") +# and a list of (exercise name, reps) pairs. "video" is the default path opened +# by the `video` command; empty by default so users set their own via config. +WORKOUTS = { + "abs": { + "display": "Ab", + "video": "", + "exercises": [ + ("Situps", 25), + ("Reverse Crunches", 25), + ("Bicycle Crunches", 25), + ("Flutter Kicks", 25), + ("Leg Raises", 25), + ("Elbow Planks", 2), + ], + }, + "quads": { + "display": "Quad", + "video": "", + "exercises": [ + ("Lunges", 50), + ("High Knees", 50), + ("Side Kicks", 50), + ("Mountain Climbers", 25), + ("Plank Jump Ins", 25), + ("Lunges & Step Ups", 50), + ], + }, + "glutes": { + "display": "Glutes", + "video": "", + "exercises": [ + ("Squats", 25), + ("Donkey Kicks", 25), + ("Bridges", 25), + ("Step Ups", 25), + ("Fly Steps", 50), + ("Side Leg Raises", 50), + ], + }, + "triceps": { + "display": "Tricep", + "video": "", + "exercises": [ + ("Diamond Pushups", 25), + ("Tricep Dips", 25), + ("Tricep Extensions", 25), + ("Get Ups", 50), + ("Punches", 50), + ("Side to Side Chops", 50), + ], + }, + "biceps": { + "display": "Bicep", + "video": "", + "exercises": [ + ("Backlists", 50), + ("Doorframe Rows", 50), + ("Decline Pushups", 25), + ("Side Plank", 2), + ("Pushups", 25), + ], + }, + "back": { + "display": "Back", + "video": "", + "exercises": [ + ("Scapular Shrugs", 25), + ("Supermans", 25), + ("Back Lifts", 25), + ("Arm/Leg Plank", 2), + ("Reverse Angels", 25), + ], + }, + "chest": { + "display": "Chest", + "video": "", + "exercises": [ + ("Pushups", 25), + ("Chest Expansions", 25), + ("Chest Squeezes", 25), + ("Pike Pushups", 25), + ("Shoulder Taps", 25), + ], + }, +} diff --git a/docs/config.sample.json b/docs/config.sample.json new file mode 100644 index 0000000..3a65494 --- /dev/null +++ b/docs/config.sample.json @@ -0,0 +1,18 @@ +{ + "abs": { + "video": "/home/you/Videos/10 Minute Ab Workout.mp4" + }, + "quads": { + "video": "https://www.youtube.com/watch?v=your-leg-workout" + }, + "chest": { + "exercises": [ + ["Pushups", 30], + ["Chest Expansions", 25], + ["Chest Squeezes", 25], + ["Pike Pushups", 25], + ["Shoulder Taps", 25], + ["Wide Pushups", 20] + ] + } +} diff --git a/docs/internal/known-issues.md b/docs/internal/known-issues.md index 42aca28..73e02d7 100644 --- a/docs/internal/known-issues.md +++ b/docs/internal/known-issues.md @@ -7,7 +7,9 @@ licensing decision rather than a documentation one. Ordered by severity. See [`docs/roadmap.md`](../roadmap.md) for the narrative version, which also covers deliberate non-goals. -**4 open:** 2 medium, 2 low. +**1 open, 3 resolved:** 1 medium open; 2 medium and 1 low resolved by the `cli.py` / +`config.py` / `data.py` / `history.py` module split (ported from the +`refactor-data-config-history-cli` branch). ## 1. Startup banner claims GPL terms on an MIT project @@ -20,38 +22,36 @@ which also covers deliberate non-goals. **Suggested fix:** Replace the banner with the MIT notice, or drop it. `LICENSE.md` is authoritative either way. -## 2. `skip` and `stats` are mutually exclusive +**Status:** Still open. The `license`/`help` text ported from `refactor-data-config-history-cli` keeps the same GPL-style wording; this fix was out of scope for that refactor. -**Severity:** Medium -**Where:** `main.py`, around lines 484-561 - -**What:** Using `skip` disables `stats` for the rest of the session; the program prints "You cannot use both the `skip` and `stats` commands, sorry!" +## 2. `skip` and `stats` are mutually exclusive — RESOLVED -**Why it matters:** Both commands manipulate overlapping session bookkeeping inside a single ~600-line `workout()` function, so neither can be fixed without untangling that state. +**Severity:** Medium (was) +**Where:** `main.py`, around lines 484-561 (pre-refactor) -**Suggested fix:** Extract session state into its own model. That is also what would make the two help outputs collapse into one. +**What:** Using `skip` disabled `stats` for the rest of the session; the program printed "You cannot use both the `skip` and `stats` commands, sorry!" -## 3. Help text is printed twice and the copies have drifted +**Resolution:** `main.py` now tracks presented activities as a list of `{name, ts, kind}` records instead of parallel lists with an index that `skip` could desync. `skip` pops the last presented record; `stats` reads the same list. The two commands no longer interfere. -**Severity:** Low -**Where:** `main.py`, around lines 613 and 632 +## 3. Help text is printed twice and the copies had drifted — RESOLVED -**What:** Help is printed inline in two places. One documents the `skip`/`stats` limitation; the other omits it. +**Severity:** Low (was) +**Where:** `main.py`, around lines 613 and 632 (pre-refactor) -**Why it matters:** Which caveat a user sees depends on where they asked for help. +**What:** Help was printed inline in two places. One documented the `skip`/`stats` limitation; the other omitted it. -**Suggested fix:** Single source the help text. +**Resolution:** Since issue #2 is fixed, the `skip`/`stats` caveat no longer applies, so both copies (the `help` command and the unrecognised-command fallback) now agree. They are still two separate `print` blocks rather than a single source, so a future edit could redrift them — worth a follow-up if anyone touches that code again. -## 4. The `video` command needs source edits to do anything +## 4. The `video` command needs source edits to do anything — RESOLVED -**Severity:** Low -**Where:** `main.py`, under the `# Video File Paths` comment +**Severity:** Low (was) +**Where:** `main.py`, under the `# Video File Paths` comment (pre-refactor) -**What:** Video paths are literals in the source and point nowhere useful by default. +**What:** Video paths were literals in the source and pointed nowhere useful by default. -**Why it matters:** A command that does nothing until you modify the program is closer to unimplemented than to configurable. +**Resolution:** Video paths now live in `~/.pyworkout/config.json` (see `config.py`, `docs/config.sample.json`), written with `pyworkout --init-config`. The `video` command reports "No video configured" instead of silently doing nothing when a group has none set. -**Suggested fix:** Move paths into a config file, or make the command report that none are set. +**Documentation note:** `README.md` and `docs/usage.md`/`docs/commands.md`/`docs/configuration.md` still describe the pre-refactor model (video paths edited directly in `main.py`, exercises customized by "editing plain Python lists", nine muscle groups). Those pages predate this module split and were already drifted from the shipped code before it (see the top of this file); they still need a documentation pass to describe the `cli.py`/`config.py`/`data.py`/`history.py` structure, the new CLI flags, and the config file. Not attempted here to avoid guessing at the intended house style for that doc set. --- diff --git a/history.py b/history.py new file mode 100644 index 0000000..cc19de7 --- /dev/null +++ b/history.py @@ -0,0 +1,91 @@ +""" +PyWorkout workout history. + +Persists completed workouts to ``~/.pyworkout/history.json`` so users can see +past sessions and totals across runs. Each record is a small JSON object; the +file is a JSON list of those records. +""" + +import json +import os +from datetime import datetime + +try: + import config as config_mod +except ImportError: # installed as part of the package + from . import config as config_mod + +HISTORY_FILE_NAME = "history.json" + + +def default_history_path(): + """Return the default path to ``history.json``.""" + return os.path.join(config_mod.config_dir(), HISTORY_FILE_NAME) + + +def load_history(path=None): + """Return the list of workout records, or [] when absent/unreadable.""" + if path is None: + path = default_history_path() + if not os.path.exists(path): + return [] + try: + with open(path, encoding="UTF-8") as handle: + data = json.load(handle) + except (json.JSONDecodeError, OSError): + return [] + return data if isinstance(data, list) else [] + + +def record_workout(group, duration_seconds, completed, path=None, when=None): + """ + Append one completed-workout record and return it. + + ``duration_seconds`` is rounded to an int; ``completed`` is the list of + activity names finished. ``when`` defaults to now (ISO 8601). + """ + if path is None: + path = default_history_path() + if when is None: + when = datetime.now() + + record = { + "date": when.isoformat(timespec="seconds"), + "group": group, + "duration_seconds": int(round(duration_seconds)), + "completed": list(completed), + } + + records = load_history(path) + records.append(record) + + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="UTF-8") as handle: + json.dump(records, handle, indent=2) + return record + + +def _format_duration(seconds): + """Render seconds as H:MM:SS.""" + seconds = int(seconds) + hours, remainder = divmod(seconds, 3600) + minutes, secs = divmod(remainder, 60) + return f"{hours}:{minutes:02d}:{secs:02d}" + + +def format_history(path=None): + """Return a human-readable multi-line summary of past workouts.""" + records = load_history(path) + if not records: + return "No workouts recorded yet. Complete one with the `end` command!" + + lines = [f"Workout history ({len(records)} session(s)):"] + for i, rec in enumerate(records, start=1): + date = rec.get("date", "?") + group = rec.get("group", "?") + duration = _format_duration(rec.get("duration_seconds", 0)) + count = len(rec.get("completed", [])) + lines.append( + f"{i}. {date}\t{group}\t{duration}\t({count} activities)" + ) + return "\n".join(lines) diff --git a/main.py b/main.py index a129901..46cadc1 100644 --- a/main.py +++ b/main.py @@ -1,643 +1,266 @@ -""" -PyWORKOUT CLI -Copyright (C) 2021-2026 @willtheorangeguy -""" - -# pylint: disable=redefined-builtin, global-variable-undefined, too-many-locals, too-many-branches, too-many-statements - -import os -import shutil -import subprocess # nosec B404 - only used to hand a local file path to the - # desktop opener, with a resolved path and a list argv. -import sys -import time -from datetime import datetime - -# Import Statements - -# PyWorkout Function - - -def workout(): - """PyWorkout CLI""" - # Workout Lists - groups = ["Abs", "Quads", "Glutes", "Triceps", "Biceps", "Back", "Chest"] - abs = [ - "Situps\t", - "Reverse Crunches", - "Bicycle Crunches", - "Flutter Kicks", - "Leg Raises\t", - "Elbow Planks\t", - ] - abs_count = [25, 25, 25, 25, 25, 2] - quads = [ - "Lunges\t", - "High Knees\t", - "Side Kicks\t", - "Mountain Climbers", - "Plank Jump Ins", - "Lunges & Step Ups", - ] - quads_count = [50, 50, 50, 25, 25, 50] - glutes = [ - "Squats\t", - "Donkey Kicks\t", - "Bridges\t", - "Step Ups\t", - "Fly Steps\t", - "Side Leg Raises", - ] - glutes_count = [25, 25, 25, 25, 50, 50] - triceps = [ - "Diamond Pushups", - "Tricep Dips\t", - "Tricep Extensions", - "Get Ups\t", - "Punches\t", - "Side to Side Chops", - ] - triceps_count = [25, 25, 25, 50, 50, 50] - biceps = [ - "Backlists\t", - "Doorframe Rows", - "Decline Pushups", - "Side Plank\t", - "Pushups\t", - ] - biceps_count = [50, 50, 25, 2, 25] - back = [ - "Scapular Shrugs", - "Supermans\t", - "Back Lifts\t", - "Arm/Leg Plank", - "Reverse Angels", - ] - back_count = [25, 25, 25, 2, 25] - chest = [ - "Pushups\t", - "Chest Expansions", - "Chest Squeezes", - "Pike Pushups\t", - "Shoulder Taps", - ] - chest_count = [25, 25, 25, 25, 25, 25] - complete = [] - times = [] - - # Video File Paths - # change these to personal video path - abs_video = "D:\\Videos\\Workout Videos\\10 Minute Ab Workout.mp4" - quads_video = "D:\\Videos\\Workout Videos\\12 Min Leg Workout.mp4" - glutes_video = "D:\\Videos\\Workout Videos\\10 Minute Glute Bridge Workout.mp4" - triceps_video = "D:\\Videos\\Workout Videos\\10 Minute Upper Body Workout.mp4" - biceps_video = "D:\\Videos\\Workout Videos\\15 Minute Full Body HIIT Workout.mp4" - back_video = "D:\\Videos\\Workout Videos\\20 Minute Full Body Workout.mp4" - chest_video = "D:\\Videos\\Workout Videos\\15 Minute Intense Bodyweight Workout.mp4" - - # Video Function - def video(path): - if sys.platform == "win32": - # Hands a hardcoded local video path to the Windows shell - # association; no command string is ever built or interpreted. - os.startfile(path) # nosec B606 - else: - # Resolve the opener to an absolute path rather than trusting PATH - # order, and pass a list argv so the path is never shell-interpreted. - opener = "open" if sys.platform == "darwin" else "xdg-open" - opener_path = shutil.which(opener) - if opener_path is None: - print(f"Could not find '{opener}' to open the video: {path}") - return - subprocess.call([opener_path, path]) # nosec B603 - - # Welcome - print(" WELCOME TO PyWORKOUT") - print("Please select a group from those below.") - for i, groups in enumerate(groups): - print(str(i + 1) + ". " + str(groups)) - print( - "A reminder that today is: " - + datetime.today().strftime("%A") - + ". Consider option " - + str(int(datetime.today().strftime("%w")) + 1) - + "." - ) - - # Display Workout Options - run_options = True - while run_options is True: - global select - select = str(input("\nGroup? ")) - if select.lower() == "1" or select.lower() == "abs": - print("Ab muscle group selected!\n") - select = "abs" - run_options = False - elif select.lower() == "2" or select.lower() == "quads": - print("Quad muscle group selected!\n") - select = "quads" - run_options = False - elif select.lower() == "3" or select.lower() == "glutes": - print("Glutes muscle group selected!\n") - select = "glutes" - run_options = False - elif select.lower() == "4" or select.lower() == "triceps": - print("Tricep muscle group selected!\n") - select = "triceps" - run_options = False - elif select.lower() == "5" or select.lower() == "biceps": - print("Bicep muscle group selected!\n") - select = "biceps" - run_options = False - elif select.lower() == "6" or select.lower() == "back": - print("Back muscle group selected!\n") - select = "back" - run_options = False - elif select.lower() == "7" or select.lower() == "chest": - print("Chest muscle group selected!\n") - select = "chest" - run_options = False - elif select.lower() == "quit": - sys.exit() - else: - print("Sorry that is incorrect. Please try again! \n") - run_options = True - - # Ask What They Want to Do - run_activity = True - while run_activity is True: - activity = str(input("What do you want to do? ")) - if activity.lower() == "list": - if select == "abs": - for i, abs in enumerate(abs): - print( - str(i + 1) - + ". " - + str(abs) - + "\t 2 Sets of " - + str(abs_count[i]) - + " Reps" - ) - elif select == "quads": - for i, item in enumerate(quads): - print( - str(i + 1) - + ". " - + str(item) - + "\t 2 Sets of " - + str(quads_count[i]) - + " Reps" - ) - elif select == "glutes": - for i, item in enumerate(glutes): - print( - str(i + 1) - + ". " - + str(item) - + "\t 2 Sets of " - + str(glutes_count[i]) - + " Reps" - ) - elif select == "triceps": - for i in range(len(glutes)): - print( - str(i + 1) - + ". " - + str(triceps[i]) - + "\t 2 Sets of " - + str(triceps_count[i]) - + " Reps" - ) - elif select == "biceps": - for i, item in enumerate(biceps): - print( - str(i + 1) - + ". " - + str(item) - + "\t 2 Sets of " - + str(biceps_count[i]) - + " Reps" - ) - elif select == "back": - for i, item in enumerate(back): - print( - str(i + 1) - + ". " - + str(item) - + "\t 2 Sets of " - + str(back_count[i]) - + " Reps" - ) - elif select == "chest": - for i, item in enumerate(chest): - print( - str(i + 1) - + ". " - + str(item) - + "\t 2 Sets of " - + str(chest_count[i]) - + " Reps" - ) - print("") - run_activity = True - elif activity.lower() == "start": - global activity_num - activity_num = 0 - global start - start = datetime.now() - times.append(start) - print("You have started the " + select + " muscle group. ") - print("The current time is: " + str(time.strftime("%H:%M:%S"))) - if select == "abs": - print( - "You have completed: " - + str((int(activity_num / len(abs_count)))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(abs_count[activity_num]) - + " Reps of " - + str(abs[activity_num]) - ) - complete.append(abs[activity_num]) - elif select == "quads": - print( - "You have completed: " - + str((int(activity_num / len(quads_count)))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(quads_count[activity_num]) - + " Reps of " - + str(quads[activity_num]) - ) - complete.append(quads[activity_num]) - elif select == "glutes": - print( - "You have completed: " - + str((int(activity_num / len(glutes_count)))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(glutes_count[activity_num]) - + " Reps of " - + str(glutes[activity_num]) - ) - complete.append(glutes[activity_num]) - elif select == "triceps": - print( - "You have completed: " - + str((int(activity_num / len(triceps_count)))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(triceps_count[activity_num]) - + " Reps of " - + str(triceps[activity_num]) - ) - complete.append(triceps[activity_num]) - elif select == "biceps": - print( - "You have completed: " - + str((int(activity_num / len(biceps_count)))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(biceps_count[activity_num]) - + " Reps of " - + str(biceps[activity_num]) - ) - complete.append(biceps[activity_num]) - elif select == "back": - print( - "You have completed: " - + str((int(activity_num / len(back_count)))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(back_count[activity_num]) - + " Reps of " - + str(back[activity_num]) - ) - complete.append(back[activity_num]) - elif select == "chest": - print( - "You have completed: " - + str((int(activity_num / len(chest_count)))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(chest_count[activity_num]) - + " Reps of " - + str(chest[activity_num]) - ) - complete.append(chest[activity_num]) - print("") - activity_num = activity_num + 1 - run_activity = True - elif activity.lower() == "next": - now = datetime.now() - times.append(now) - print("You are in the " + select + " muscle group. ") - print( - "The current time is: " - + str(time.strftime("%H:%M:%S")) - + ". " - + str(now - start) - + " has elapsed." - ) - if select == "abs": - if activity_num < 6: - print( - "You have completed: " - + str((int(activity_num / len(abs_count) * 100))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(abs_count[activity_num]) - + " Reps of " - + str(abs[activity_num]) - + "\n" - ) - complete.append(abs[activity_num]) - else: - print("You have completed all the workouts for this set!") - print("Run the `end` command to finish the workout. \n") - elif select == "quads": - if activity_num < 6: - print( - "You have completed: " - + str((int(activity_num / len(quads_count) * 100))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(quads_count[activity_num]) - + " Reps of " - + str(quads[activity_num]) - + "\n" - ) - complete.append(quads[activity_num]) - else: - print("You have completed all the workouts for this set!") - print("Run the `end` command to finish the workout. \n") - elif select == "glutes": - if activity_num < 6: - print( - "You have completed: " - + str((int(activity_num / len(glutes_count) * 100))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(glutes_count[activity_num]) - + " Reps of " - + str(glutes[activity_num]) - + "\n" - ) - complete.append(glutes[activity_num]) - else: - print("You have completed all the workouts for this set!") - print("Run the `end` command to finish the workout. \n") - elif select == "triceps": - if activity_num < 6: - print( - "You have completed: " - + str((int(activity_num / len(triceps_count) * 100))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(triceps_count[activity_num]) - + " Reps of " - + str(triceps[activity_num]) - + "\n" - ) - complete.append(triceps[activity_num]) - else: - print("You have completed all the workouts for this set!") - print("Run the `end` command to finish the workout. \n") - elif select == "biceps": - if activity_num < 5: - print( - "You have completed: " - + str((int(activity_num / len(biceps_count) * 100))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(biceps_count[activity_num]) - + " Reps of " - + str(biceps[activity_num]) - + "\n" - ) - complete.append(biceps[activity_num]) - else: - print("You have completed all the workouts for this set!") - print("Run the `end` command to finish the workout. \n") - elif select == "back": - if activity_num < 5: - print( - "You have completed: " - + str((int(activity_num / len(back_count) * 100))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(back_count[activity_num]) - + " Reps of " - + str(back[activity_num]) - + "\n" - ) - complete.append(back[activity_num]) - else: - print("You have completed all the workouts for this set!") - print("Run the `end` command to finish the workout. \n") - elif select == "chest": - if activity_num < 6: - print( - "You have completed: " - + str((int(activity_num / len(chest_count) * 100))) - + "%" - ) - print( - "Please complete 2 Sets of " - + str(chest_count[activity_num]) - + " Reps of " - + str(chest[activity_num]) - + "\n" - ) - complete.append(chest[activity_num]) - else: - print("You have completed all the workouts for this set!") - print("Run the `end` command to finish the workout. \n") - activity_num = activity_num + 1 - run_activity = True - elif activity.lower() == "skip": - now = datetime.now() - print("You are in the " + select + " muscle group. ") - print( - "The current time is: " - + str(time.strftime("%H:%M:%S")) - + ". " - + str(now - start) - + " has elapsed." - ) - complete.pop(-1) - times.pop(-1) - print("Activity skipped! Run the `next` command to move on. \n") - run_activity = True - elif activity.lower() == "end": - now = datetime.now() - times.append(now) - print("You have completed the " + select + " muscle group.") - print("It took " + str(now - start) + " to complete this workout.") - print("The following activities were completed (time elapsed):") - for i, item in enumerate(complete): - print( - str(i + 1) - + ". " - + str(item) - + "\t(" - + str(times[i + 1] - times[i]) - + ")" - ) - print("Congratulations! \n") - run_activity = True - elif activity.lower() == "stats": - try: - now = datetime.now() - times.append(now) - print("You are in the " + select + " muscle group. ") - print( - "The current time is: " - + str(time.strftime("%H:%M:%S")) - + ". " - + str(now - start) - + " has elapsed." - ) - print("The following activities have been completed (time elapsed):") - for i, item in enumerate(complete): - print( - str(i + 1) - + ". " - + str(item) - + "\t(" - + str(times[i + 1] - times[i]) - + ")" - ) - print("The following activities still need to be completed:") - if select == "abs": - for i in range(len(abs) - len(complete)): - print(str(i + 1) + ". " + str(abs[i + activity_num])) - elif select == "quads": - for i in range(len(quads) - len(complete)): - print(str(i + 1) + ". " + str(quads[i + activity_num])) - elif select == "glutes": - for i in range(len(glutes) - len(complete)): - print(str(i + 1) + ". " + str(glutes[i + activity_num])) - elif select == "triceps": - for i in range(len(triceps) - len(complete)): - print(str(i + 1) + ". " + str(triceps[i + activity_num])) - elif select == "biceps": - for i in range(len(biceps) - len(complete)): - print(str(i + 1) + ". " + str(biceps[i + activity_num])) - elif select == "back": - for i in range(len(back) - len(complete)): - print(str(i + 1) + ". " + str(back[i + activity_num])) - elif select == "chest": - for i in range(len(chest) - len(complete)): - print(str(i + 1) + ". " + str(chest[i + activity_num])) - print("") - except IndexError: - print("You cannot use both the `skip` and `stats` commands, sorry! \n") - run_activity = True - elif activity.lower() == "video": - now = datetime.now() - times.append(now) - print("You are in the " + select + " muscle group. ") - print( - "The current time is: " - + str(time.strftime("%H:%M:%S")) - + ". " - + str(now - start) - + " has elapsed." - ) - if select == "abs": - video(abs_video) - complete.append("Abs Video \t") - elif select == "quads": - video(quads_video) - complete.append("Quads Video \t") - elif select == "glutes": - video(glutes_video) - complete.append("Glutes Video") - elif select == "triceps": - video(triceps_video) - complete.append("Triceps Video") - elif select == "biceps": - video(biceps_video) - complete.append("Biceps Video\t") - elif select == "back": - video(back_video) - complete.append("Back Video \t") - elif select == "chest": - video(chest_video) - complete.append("Chest Video \t") - print("") - run_activity = True - elif activity.lower() == "license": - print("PyWorkout Copyright (C) 2021-2024 @willtheorangeguy") - print( - "This program comes with ABSOLUTELY NO WARRANTY; for details view the license." - ) - print("This is free software, and you are welcome to redistribute it") - print("under certain conditions; view the license for details. \n") - elif activity.lower() == "quit": - run_activity = False - sys.exit() - elif activity.lower() == "help": - print("PyWorkout - (C) 2021-2024") - print("Any of these options are available: ") - print("list Lists the workout activities by muscle group.") - print("start Starts the workout and displays the first workout activity.") - print("next Moves to the next workout activity.") - print("skip Skips the current workout activity.") - print("end Completes the workout and display full workout statistics.") - print( - "stats Shows workout statistics at any point \ - (does not work with the `skip` command)." - ) - print("video Opens the workout video assigned to each muscle group.") - print("license Show the license.") - print("help Prints this help text.") - print("quit Ends the program.") - print( - "More documentation can be found on Github. Enjoy using the program! \n" - ) - run_activity = True - else: - print("Sorry that is not an option. Please see this list of options below:") - print("list Lists the workout activities by muscle group.") - print("start Starts the workout and displays the first workout activity.") - print("next Moves to the next workout activity.") - print("skip Skips the current workout activity.") - print("end Completes the workout and display full workout statistics.") - print("stats Shows workout statistics at any point.") - print("video Opens the workout video assigned to each muscle group.") - print("license Show the license.") - print("help Prints a similar help text.") - print("quit Ends the program. \n") - run_activity = True - - -if __name__ == "__main__": - workout() +""" +PyWORKOUT CLI +Copyright (C) 2021-2026 @willtheorangeguy +""" + +# pylint: disable=redefined-builtin, too-many-branches, too-many-statements, too-many-locals + +import os +import subprocess +import sys +import time +from datetime import datetime + +try: + import config as config_mod + import history as history_mod +except ImportError: # installed as part of the package + from . import config as config_mod + from . import history as history_mod + + +def _play_video(path): + """Open a video file with the OS default player.""" + if sys.platform == "win32": + os.startfile(path) # pylint: disable=no-member + else: + opener = "open" if sys.platform == "darwin" else "xdg-open" + subprocess.call([opener, path]) + + +def _now_line(start_time): + """Return the standard 'current time / elapsed' status line.""" + now = datetime.now() + return ( + "The current time is: " + + str(time.strftime("%H:%M:%S")) + + ". " + + str(now - start_time) + + " has elapsed." + ) + + +def _elapsed_for(presented, index): + """Seconds an item was on screen: until the next item, or until now.""" + start_ts = presented[index]["ts"] + if index + 1 < len(presented): + end_ts = presented[index + 1]["ts"] + else: + end_ts = datetime.now() + return end_ts - start_ts + + +def workout(preselect=None, config_path=None): + """PyWorkout interactive CLI.""" + workouts = config_mod.load_config(config_path) + order = config_mod.group_order(workouts) + + # Per-workout state. + select = None + start_time = None + presented = [] # [{"name", "ts", "kind"}], kind in {"exercise", "video"} + + def exercises_done(): + """Number of exercise activities presented so far.""" + return sum(1 for p in presented if p["kind"] == "exercise") + + def present_exercise(): + """Show the next exercise (or the 'all done' message) for the group.""" + items = workouts[select]["exercises"] + total = len(items) + index = exercises_done() + if index < total: + name, reps = items[index] + percent = int(index / total * 100) + print("You have completed: " + str(percent) + "%") + print( + "Please complete 2 Sets of " + + str(reps) + + " Reps of " + + str(name) + ) + presented.append({"name": name, "ts": datetime.now(), "kind": "exercise"}) + else: + print("You have completed all the workouts for this set!") + print("Run the `end` command to finish the workout. \n") + + def print_completed(): + """Print every presented activity with the time it took.""" + for i, item in enumerate(presented): + elapsed = _elapsed_for(presented, i) + print(str(i + 1) + ". " + str(item["name"]) + "\t(" + str(elapsed) + ")") + + # Welcome banner. + print(" WELCOME TO PyWORKOUT") + print("Please select a group from those below.") + for i, key in enumerate(order): + print(str(i + 1) + ". " + key.capitalize()) + print( + "A reminder that today is: " + + datetime.today().strftime("%A") + + ". Consider option " + + str(int(datetime.today().strftime("%w")) + 1) + + "." + ) + + # Group selection (skipped when a valid group is preselected via CLI). + if preselect is not None and preselect.lower() in workouts: + select = preselect.lower() + print(workouts[select]["display"] + " muscle group selected!\n") + else: + while select is None: + choice = str(input("\nGroup? ")).lower() + if choice == "quit": + sys.exit() + matched = None + for i, key in enumerate(order): + if choice == key or choice == str(i + 1): + matched = key + break + if matched is None: + print("Sorry that is incorrect. Please try again! \n") + else: + select = matched + print(workouts[select]["display"] + " muscle group selected!\n") + + # Command loop. + while True: + activity = str(input("What do you want to do? ")).lower() + + if activity == "list": + for i, (name, reps) in enumerate(workouts[select]["exercises"]): + print( + str(i + 1) + + ". " + + str(name) + + "\t 2 Sets of " + + str(reps) + + " Reps" + ) + print("") + + elif activity == "start": + start_time = datetime.now() + presented.clear() + print("You have started the " + select + " muscle group. ") + print("The current time is: " + str(time.strftime("%H:%M:%S"))) + present_exercise() + print("") + + elif activity == "next": + if start_time is None: + print("Run the `start` command first! \n") + continue + print("You are in the " + select + " muscle group. ") + print(_now_line(start_time)) + present_exercise() + print("") + + elif activity == "skip": + if not presented: + print("Nothing to skip yet. Run `start` first. \n") + continue + print("You are in the " + select + " muscle group. ") + print(_now_line(start_time)) + presented.pop() + print("Activity skipped! Run the `next` command to move on. \n") + + elif activity == "end": + if start_time is None: + print("Run the `start` command first! \n") + continue + now = datetime.now() + duration = now - start_time + print("You have completed the " + select + " muscle group.") + print("It took " + str(duration) + " to complete this workout.") + print("The following activities were completed (time elapsed):") + print_completed() + history_mod.record_workout( + select, duration.total_seconds(), [p["name"] for p in presented] + ) + print("Congratulations! \n") + + elif activity == "stats": + if start_time is None: + print("Run the `start` command first! \n") + continue + print("You are in the " + select + " muscle group. ") + print(_now_line(start_time)) + print("The following activities have been completed (time elapsed):") + print_completed() + print("The following activities still need to be completed:") + remaining = workouts[select]["exercises"][exercises_done():] + for i, (name, _reps) in enumerate(remaining): + print(str(i + 1) + ". " + str(name)) + print("") + + elif activity == "video": + if start_time is not None: + print("You are in the " + select + " muscle group. ") + print(_now_line(start_time)) + path = workouts[select].get("video") or "" + if path: + _play_video(path) + presented.append( + {"name": select.capitalize() + " Video", "ts": datetime.now(), "kind": "video"} + ) + else: + print( + "No video configured for " + + select + + ". Set one in your config (run with --init-config)." + ) + print("") + + elif activity == "history": + print(history_mod.format_history()) + print("") + + elif activity == "license": + print("PyWorkout Copyright (C) 2021-2026 @willtheorangeguy") + print( + "This program comes with ABSOLUTELY NO WARRANTY; for details view the license." + ) + print("This is free software, and you are welcome to redistribute it") + print("under certain conditions; view the license for details. \n") + + elif activity == "quit": + sys.exit() + + elif activity == "help": + print("PyWorkout - (C) 2021-2026") + print("Any of these options are available: ") + print("list Lists the workout activities by muscle group.") + print("start Starts the workout and displays the first workout activity.") + print("next Moves to the next workout activity.") + print("skip Skips the current workout activity.") + print("end Completes the workout and display full workout statistics.") + print("stats Shows workout statistics at any point.") + print("video Opens the workout video assigned to each muscle group.") + print("history Shows your past completed workouts.") + print("license Show the license.") + print("help Prints this help text.") + print("quit Ends the program.") + print( + "More documentation can be found on Github. Enjoy using the program! \n" + ) + + else: + print("Sorry that is not an option. Please see this list of options below:") + print("list Lists the workout activities by muscle group.") + print("start Starts the workout and displays the first workout activity.") + print("next Moves to the next workout activity.") + print("skip Skips the current workout activity.") + print("end Completes the workout and display full workout statistics.") + print("stats Shows workout statistics at any point.") + print("video Opens the workout video assigned to each muscle group.") + print("history Shows your past completed workouts.") + print("license Show the license.") + print("help Prints a similar help text.") + print("quit Ends the program. \n") + + +if __name__ == "__main__": + # Running `python main.py` launches the interactive REPL directly. + # For command-line flags use the `pyworkout` script or `python -m PyWorkout`. + workout() diff --git a/pyproject.toml b/pyproject.toml index 2934348..f7d5716 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["setuptools", "wheel"] [project] name = "PyWorkout" -version = "1.2.0" +version = "1.3.0" authors = [ { name= "willtheorangeguy" }, ] @@ -12,6 +12,7 @@ description = "A minimal CLI to keep you inspired during your workout!" readme = "README.md" license = { file="LICENSE.md" } requires-python = ">=3.9" +keywords = ["workout", "plan", "tracking", "tracker", "video", "cli"] classifiers = [ 'Development Status :: 5 - Production/Stable', 'License :: OSI Approved :: MIT License', @@ -20,9 +21,16 @@ classifiers = [ 'Topic :: Other/Nonlisted Topic', ] +[project.scripts] +pyworkout = "cli:main" + [project.urls] "Homepage" = "https://github.com/willtheorangeguy/PyWorkout" "Bug Tracker" = "https://github.com/willtheorangeguy/PyWorkout/issues" + +[tool.setuptools] +py-modules = ["main", "cli", "config", "data", "history", "gui"] + [tool.bandit] exclude_dirs = ["tests", "test", ".venv", "venv", "build", "node_modules"] skips = ["B101"] diff --git a/setup.cfg b/setup.cfg index c049906..820faf7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,11 +1,3 @@ -[metadata] -name = pyworkout -version = 1.2.0 - -[options.entry_points] -console_scripts = - pyworkout = main:workout - [tool:pytest] testpaths = tests python_files = test_*.py diff --git a/setup.py b/setup.py deleted file mode 100644 index 11b72bb..0000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Setup script for PyWorkout.""" - -from setuptools import setup - - -def readme(): - """Return the README file as a string.""" - with open("README.md", encoding="UTF-8") as f: - return f.read() - - -setup( - name="pyworkout", - version="1.2.0", - description="A minimal CLI to keep you inspired during your workout! ", - long_description=readme(), - classifiers=[ - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", - "Natural Language :: English", - "Operating System :: OS Independent", - "Topic :: Other/Nonlisted Topic", - ], - keywords="workout plan tracking tracker video cli", - url="https://github.com/willtheorangeguy/PyWorkout", - author="willtheorangeguy", - py_modules=["main"], - entry_points={"console_scripts": ["pyworkout=main:workout"]}, -) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dfe228c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +"""Shared pytest fixtures for PyWorkout.""" + +import sys +import os + +import pytest # pylint: disable=import-error + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import config # pylint: disable=import-error,wrong-import-position + + +@pytest.fixture(autouse=True) +def isolated_home(tmp_path, monkeypatch): + """ + Redirect the PyWorkout config/history directory into a temp folder so tests + never read or write the real ``~/.pyworkout/``. + """ + target = tmp_path / ".pyworkout" + monkeypatch.setattr(config, "config_dir", lambda: str(target)) + return target diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..ca6e34a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,95 @@ +"""Tests for the argparse CLI wrapper in cli.py.""" + +# Flat-layout module names can be shadowed by site-packages during pylint's +# static resolution; disable no-member for this test module. +# pylint: disable=no-member + +from unittest.mock import patch + +import pytest # pylint: disable=import-error + +import cli # pylint: disable=import-error + + +def test_parse_args_defaults(): + """No flags -> all options at their defaults.""" + args = cli.parse_args([]) + assert args.group is None + assert args.list is None + assert args.history is False + assert args.init_config is False + assert args.config is None + + +def test_parse_args_group(): + """--group captures the group name.""" + assert cli.parse_args(["--group", "abs"]).group == "abs" + assert cli.parse_args(["-g", "quads"]).group == "quads" + + +def test_parse_args_list_optional_value(): + """--list works with and without a group argument.""" + assert cli.parse_args(["--list"]).list == "__ALL__" + assert cli.parse_args(["--list", "abs"]).list == "abs" + + +def test_version_exits(capsys): + """--version prints and exits 0.""" + with pytest.raises(SystemExit) as exc: + cli.parse_args(["--version"]) + assert exc.value.code == 0 + assert "pyworkout" in capsys.readouterr().out + + +def test_no_args_launches_repl(): + """With no flags, main() dispatches to the interactive workout().""" + with patch.object(cli, "workout") as mock_workout: + assert cli.main([]) == 0 + mock_workout.assert_called_once_with(preselect=None, config_path=None) + + +def test_group_flag_preselects(): + """--group is threaded into workout().""" + with patch.object(cli, "workout") as mock_workout: + cli.main(["--group", "abs"]) + mock_workout.assert_called_once_with(preselect="abs", config_path=None) + + +def test_list_all(capsys): + """--list prints every group.""" + assert cli.main(["--list"]) == 0 + out = capsys.readouterr().out + assert "Abs:" in out + assert "Chest:" in out + assert "Situps" in out + + +def test_list_one_group(capsys): + """--list GROUP prints just that group.""" + assert cli.main(["--list", "abs"]) == 0 + out = capsys.readouterr().out + assert "Abs:" in out + assert "Chest:" not in out + + +def test_list_unknown_group(capsys): + """--list with a bad group reports an error and returns non-zero.""" + assert cli.main(["--list", "nope"]) == 1 + assert "Unknown group" in capsys.readouterr().out + + +def test_history_flag(capsys): + """--history prints the (empty) history and returns 0.""" + assert cli.main(["--history"]) == 0 + assert "No workouts recorded yet" in capsys.readouterr().out + + +def test_init_config_writes_file(tmp_path, capsys): + """--init-config writes a config file at the given path.""" + path = str(tmp_path / "config.json") + assert cli.main(["--init-config", "--config", path]) == 0 + out = capsys.readouterr().out + assert "Wrote default config" in out + import os # pylint: disable=import-outside-toplevel + + assert os.path.exists(path) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9bd1c8c --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,68 @@ +"""Tests for the user-config loading and merging in config.py.""" + +import json +import os + +import config # pylint: disable=import-error + + +def test_defaults_when_no_file(tmp_path): + """Missing config file falls back to built-in defaults.""" + workouts = config.load_config(str(tmp_path / "missing.json")) + assert workouts["abs"]["display"] == "Ab" + assert workouts["abs"]["exercises"][0] == ["Situps", 25] or workouts["abs"][ + "exercises" + ][0] == ("Situps", 25) + + +def test_deep_merge_overrides_one_group(tmp_path): + """A config overriding one field leaves every other group/field intact.""" + path = tmp_path / "config.json" + path.write_text(json.dumps({"abs": {"video": "/movies/abs.mp4"}})) + + workouts = config.load_config(str(path)) + # Override applied. + assert workouts["abs"]["video"] == "/movies/abs.mp4" + # Untouched data preserved. + assert workouts["abs"]["display"] == "Ab" + assert workouts["quads"]["display"] == "Quad" + + +def test_bad_json_falls_back(tmp_path, capsys): + """Malformed JSON warns once and returns defaults.""" + path = tmp_path / "config.json" + path.write_text("{not valid json") + + workouts = config.load_config(str(path)) + assert workouts["chest"]["display"] == "Chest" + assert "Warning" in capsys.readouterr().out + + +def test_non_object_falls_back(tmp_path, capsys): + """A JSON file that isn't an object returns defaults.""" + path = tmp_path / "config.json" + path.write_text("[1, 2, 3]") + + workouts = config.load_config(str(path)) + assert "abs" in workouts + assert "Warning" in capsys.readouterr().out + + +def test_write_default_config_roundtrips(tmp_path): + """write_default_config produces a file load_config can read back.""" + path = str(tmp_path / "nested" / "config.json") + written = config.write_default_config(path) + assert os.path.exists(written) + + workouts = config.load_config(path) + assert set(workouts) == set(config.default_workouts()) + + +def test_group_order_appends_custom_groups(): + """User-added groups appear after the canonical ones, sorted.""" + workouts = config.default_workouts() + workouts["zumba"] = {"display": "Zumba", "video": "", "exercises": []} + workouts["cardio"] = {"display": "Cardio", "video": "", "exercises": []} + order = config.group_order(workouts) + assert order[:7] == config.GROUP_ORDER + assert order[7:] == ["cardio", "zumba"] diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..abc096b --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,57 @@ +"""Tests for workout history persistence in history.py.""" + +from datetime import datetime + +import history # pylint: disable=import-error + + +def test_load_history_empty_when_absent(tmp_path): + """No history file yields an empty list.""" + assert history.load_history(str(tmp_path / "history.json")) == [] + + +def test_record_workout_appends_and_roundtrips(tmp_path): + """record_workout writes a record load_history reads back.""" + path = str(tmp_path / "history.json") + rec = history.record_workout("abs", 125.6, ["Situps", "Leg Raises"], path=path) + + assert rec["group"] == "abs" + assert rec["duration_seconds"] == 126 + assert rec["completed"] == ["Situps", "Leg Raises"] + + loaded = history.load_history(path) + assert len(loaded) == 1 + assert loaded[0]["group"] == "abs" + + +def test_record_workout_multiple(tmp_path): + """Successive records accumulate.""" + path = str(tmp_path / "history.json") + history.record_workout("abs", 60, ["Situps"], path=path) + history.record_workout("quads", 90, ["Lunges"], path=path) + assert len(history.load_history(path)) == 2 + + +def test_format_history_empty(tmp_path): + """An empty history reports a friendly message.""" + msg = history.format_history(str(tmp_path / "history.json")) + assert "No workouts recorded yet" in msg + + +def test_format_history_lists_sessions(tmp_path): + """format_history summarises recorded sessions.""" + path = str(tmp_path / "history.json") + history.record_workout( + "abs", 3661, ["Situps"], path=path, when=datetime(2026, 1, 2, 8, 0, 0) + ) + out = history.format_history(path) + assert "1 session" in out + assert "abs" in out + assert "1:01:01" in out # 3661s -> H:MM:SS + + +def test_corrupt_history_returns_empty(tmp_path): + """A corrupt history file is treated as empty rather than crashing.""" + path = tmp_path / "history.json" + path.write_text("not json") + assert history.load_history(str(path)) == [] diff --git a/tests/test_main.py b/tests/test_main.py index c15dae7..b80d103 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -297,22 +297,33 @@ class TestVideoFunctionality: # pylint: disable=too-few-public-methods @patch("builtins.print") @patch("builtins.input") @patch("sys.platform", "linux") - def test_video_command_linux(self, mock_input, mock_print, mock_subprocess): - """Test video command on Linux platform.""" + def test_video_command_linux(self, mock_input, _mock_print, mock_subprocess): + """Test video command on Linux opens the configured video path.""" + mock_input.side_effect = ["1", "start", "video", "quit"] + + # Video paths now come from config; inject one so the player is invoked. + workouts = main.config_mod.default_workouts() + workouts["abs"]["video"] = "/tmp/abs.mp4" + with patch.object(main.config_mod, "load_config", return_value=workouts): + with pytest.raises(SystemExit): + main.workout() + + # On Linux, xdg-open should be invoked with the configured path. + assert mock_subprocess.called, "Video command should call subprocess" + called_args = mock_subprocess.call_args[0][0] + assert called_args == ["xdg-open", "/tmp/abs.mp4"] + + @patch("builtins.print") + @patch("builtins.input") + def test_video_command_unconfigured(self, mock_input, mock_print): + """Test video command reports when no video path is configured.""" mock_input.side_effect = ["1", "start", "video", "quit"] with pytest.raises(SystemExit): main.workout() - # On Linux, should attempt to call xdg-open - # Note: The actual call might fail due to invalid path, but we're testing the attempt printed_output = [str(call) for call in mock_print.call_args_list] - # Verify video command was processed - either video text printed or subprocess called - video_text_found = any("Video" in str(call) for call in printed_output) - subprocess_was_called = mock_subprocess.called - assert ( - video_text_found or subprocess_was_called - ), "Video command should print text or call subprocess" + assert any("No video configured" in str(call) for call in printed_output) class TestWelcomeScreen: @@ -389,5 +400,77 @@ def test_workout_with_multiple_groups(self, mock_input, mock_print): assert any("Situps" in str(call) for call in printed_output) +class TestBugRegressions: + """Regression tests for bugs fixed in the data refactor.""" + + @patch("builtins.print") + @patch("builtins.input") + def test_triceps_list_shows_triceps(self, mock_input, mock_print): + """`list` for triceps must show triceps exercises, not glutes.""" + mock_input.side_effect = ["triceps", "list", "quit"] + + with pytest.raises(SystemExit): + main.workout() + + output = " ".join(str(c) for c in mock_print.call_args_list) + assert "Diamond Pushups" in output # a triceps exercise + assert "Donkey Kicks" not in output # a glutes exercise (old bug leaked these) + + @patch("builtins.print") + @patch("builtins.input") + def test_percentage_progresses(self, mock_input, mock_print): + """Percent uses the correct *100 formula and increases past 0%.""" + mock_input.side_effect = ["abs", "start", "next", "next", "quit"] + + with pytest.raises(SystemExit): + main.workout() + + output = " ".join(str(c) for c in mock_print.call_args_list) + # abs has 6 exercises; second `next` presents index 2 -> 33%. + assert "33%" in output + + @patch("builtins.print") + @patch("builtins.input") + def test_five_item_group_last_exercise_reachable(self, mock_input, mock_print): + """Five-exercise groups (e.g. chest) reach their final exercise.""" + # chest: Pushups, Chest Expansions, Chest Squeezes, Pike Pushups, Shoulder Taps + mock_input.side_effect = [ + "chest", + "start", + "next", + "next", + "next", + "next", # present 5th exercise + "quit", + ] + + with pytest.raises(SystemExit): + main.workout() + + output = " ".join(str(c) for c in mock_print.call_args_list) + assert "Shoulder Taps" in output # the 5th/last exercise + + @patch("builtins.print") + @patch("builtins.input") + def test_stats_works_after_skip(self, mock_input, mock_print): + """stats must not crash when used after skip (old IndexError bug).""" + mock_input.side_effect = [ + "abs", + "start", + "next", + "skip", + "stats", + "quit", + ] + + with pytest.raises(SystemExit): + main.workout() + + output = " ".join(str(c) for c in mock_print.call_args_list) + assert "still need to be completed" in output + # The old code printed this apology; the bug is fixed so it must not appear. + assert "cannot use both the `skip` and `stats`" not in output + + if __name__ == "__main__": pytest.main([__file__, "-v"])