Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
7 changes: 5 additions & 2 deletions __main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
106 changes: 106 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
@@ -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())
110 changes: 110 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions data.py
Original file line number Diff line number Diff line change
@@ -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 "<display> 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),
],
},
}
18 changes: 18 additions & 0 deletions docs/config.sample.json
Original file line number Diff line number Diff line change
@@ -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]
]
}
}
Loading
Loading