Skip to content

Commit fd8953b

Browse files
feat: refactor
1 parent 8304f33 commit fd8953b

21 files changed

Lines changed: 1177 additions & 771 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,6 @@ dmypy.json
127127

128128
# Pyre type checker
129129
.pyre/
130+
131+
# rtk token-killer local cache
132+
rtk/

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# Changelog
22

3+
## [v1.3.0](https://github.com/willtheorangeguy/PyWorkout/releases/tag/v1.3.0)
4+
5+
### Added
6+
7+
- Workout history saved between sessions (`history` command and `--history` flag).
8+
- Command-line flags: `--group`, `--list`, `--history`, `--init-config`, `--config`, `--version`.
9+
- User config file (`~/.pyworkout/config.json`) for exercises, reps, and video paths — no code editing required.
10+
11+
### Changed
12+
13+
- Exercise data refactored into a single structure; the old parallel-list / if-elif layout is gone.
14+
- Video paths now come from config instead of being hardcoded.
15+
- Packaging consolidated into `pyproject.toml` (removed `setup.py`, trimmed `setup.cfg`).
16+
17+
### Fixed
18+
19+
- Triceps `list` showing glutes exercises.
20+
- `start` always reporting 0% progress.
21+
- Off-by-one bounds and a rep-count mismatch on five-exercise groups.
22+
- `stats` no longer breaks after `skip`.
23+
324
## [v1.2.0](https://github.com/willtheorangeguy/PyWorkout/releases/tag/v1.2.0)
425

526
### Added

README.md

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,11 @@
5454
* Automatic workout selection by day.
5555
* Time and percentage complete indicators.
5656
* Step by step guide through a workout program.
57-
* Statistics view.
57+
* Statistics view (now works alongside `skip`).
58+
* Workout history saved between sessions.
59+
* Command-line flags for scriptable, non-interactive use.
5860
* Can skip unlikeable workouts.
59-
* Able to customize number of workouts, muscle groups, type of workout and more.
61+
* Customize workouts, muscle groups, reps, and videos via a JSON config — no code editing.
6062
* Cross platform.
6163

6264
## Download
@@ -118,28 +120,37 @@ start Starts the workout and displays the first workout activity.
118120
next Moves to the next workout activity.
119121
skip Skips the current workout activity.
120122
end Completes the workout and display full workout statistics.
121-
stats Shows workout statistics at any point (does not work with the `skip` command).
123+
stats Shows workout statistics at any point.
122124
video Opens the workout video assigned to each muscle group.
125+
history Shows your past completed workouts.
123126
license Shows the license.
124127
help Prints this help text.
125128
quit Ends the program.
126129
```
127130

128-
Note: the `skip` command cannot be used in conjunction with the `stats` command.
131+
### Command-line flags
129132

130-
**To use the `video` command, you must change the video variables.** To do so:
133+
PyWorkout can also be driven non-interactively:
131134

132-
1. Open `main.py` in a text editor.
133-
2. Change the _Video File Paths_ variables to the **absolute** path for each video by muscle group (double slashes (`\\`) are only necessary on Windows).
134-
135-
```python
136-
# Video File Paths
137-
abs_video = "D:\\Videos\\Workout Videos\\10 Minute Ab Workout.mp4" # change these to personal video path
135+
```text
136+
pyworkout Launch the interactive workout REPL.
137+
pyworkout -g abs Preselect the "abs" group and skip the prompt.
138+
pyworkout --list List every group's exercises and exit.
139+
pyworkout --list abs List one group's exercises and exit.
140+
pyworkout --history Print past workouts and exit.
141+
pyworkout --init-config Write a default config file you can edit.
142+
pyworkout --config PATH Use an alternate config file.
143+
pyworkout --version Print the version.
138144
```
139145

140-
3. Running the `video` command will now include your local workout videos.
146+
**To use the `video` command, set your video paths in the config file.** To do so:
147+
148+
1. Run `pyworkout --init-config` to create `~/.pyworkout/config.json`.
149+
2. Set the `"video"` field for each muscle group to a local **absolute** path or a
150+
web/YouTube URL (on Windows, escape backslashes in JSON: `"C:\\Videos\\abs.mp4"`).
151+
3. Running the `video` command will now open your chosen workout video.
141152

142-
Customizing the list of workouts, workout activities and number of activities can be found in [`CUSTOMIZATION.md`](https://github.com/willtheorangeguy/PyWorkout/tree/main/docs). More documentation is available in the **[Documentation](https://github.com/willtheorangeguy/PyWorkout/tree/main/docs)** and on the **[Wiki](https://github.com/willtheorangeguy/PyWorkout/wiki)**. If more support is required, please open a **[GitHub Discussion](https://github.com/willtheorangeguy/PyWorkout/discussions/new)** or join our **[Discord](https://discord.gg/YFMcACG9rh)**.
153+
Customizing workouts, activities, reps, and videos is covered in [`CUSTOMIZATION.md`](https://github.com/willtheorangeguy/PyWorkout/tree/main/docs). Completed workouts are saved to `~/.pyworkout/history.json` and viewable with the `history` command or `--history` flag. More documentation is available in the **[Documentation](https://github.com/willtheorangeguy/PyWorkout/tree/main/docs)** and on the **[Wiki](https://github.com/willtheorangeguy/PyWorkout/wiki)**. If more support is required, please open a **[GitHub Discussion](https://github.com/willtheorangeguy/PyWorkout/discussions/new)** or join our **[Discord](https://discord.gg/YFMcACG9rh)**.
143154

144155
## Contributing
145156

__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
try:
66
from main import workout
7+
from cli import main
78
except ImportError:
89
from .main import workout
10+
from .cli import main
911

10-
__all__ = ["workout"]
12+
__all__ = ["workout", "main"]

__main__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
# pylint: disable=invalid-name, import-error
44

5-
from main import workout
5+
try:
6+
from cli import main
7+
except ImportError:
8+
from .cli import main
69

710
if __name__ == "__main__":
8-
workout()
11+
main()

cli.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
PyWorkout command-line interface.
3+
4+
Wraps the interactive ``workout()`` REPL with argparse flags for scriptable,
5+
non-interactive use. With no arguments it simply launches the REPL, so the
6+
existing interactive behaviour (and its test suite) is unchanged.
7+
"""
8+
9+
import argparse
10+
import sys
11+
12+
try:
13+
import config as config_mod
14+
import history as history_mod
15+
from main import workout
16+
except ImportError: # installed as part of the package
17+
from . import config as config_mod
18+
from . import history as history_mod
19+
from .main import workout
20+
21+
__version__ = "1.3.0"
22+
23+
24+
def build_parser():
25+
"""Construct the argument parser."""
26+
parser = argparse.ArgumentParser(
27+
prog="pyworkout",
28+
description="A minimal CLI to keep you inspired during your workout!",
29+
)
30+
parser.add_argument(
31+
"-g", "--group", help="preselect a muscle group and skip the prompt"
32+
)
33+
parser.add_argument(
34+
"--list",
35+
nargs="?",
36+
const="__ALL__",
37+
metavar="GROUP",
38+
help="list exercises (optionally for one group) and exit",
39+
)
40+
parser.add_argument(
41+
"--history", action="store_true", help="print past workouts and exit"
42+
)
43+
parser.add_argument(
44+
"--init-config",
45+
action="store_true",
46+
help="write a default config file you can edit, then exit",
47+
)
48+
parser.add_argument(
49+
"--config", metavar="PATH", help="use an alternate config file"
50+
)
51+
parser.add_argument(
52+
"--version", action="version", version="pyworkout " + __version__
53+
)
54+
return parser
55+
56+
57+
def parse_args(argv=None):
58+
"""Parse ``argv`` (defaults to ``sys.argv``) into a namespace."""
59+
return build_parser().parse_args(argv)
60+
61+
62+
def _print_group(workouts, key):
63+
"""Print one group's exercises."""
64+
print(key.capitalize() + ":")
65+
for i, (name, reps) in enumerate(workouts[key]["exercises"]):
66+
print(f"{i + 1}. {name}\t 2 Sets of {reps} Reps")
67+
68+
69+
def _do_list(args):
70+
"""Handle ``--list``."""
71+
workouts = config_mod.load_config(args.config)
72+
if args.list == "__ALL__":
73+
for key in config_mod.group_order(workouts):
74+
_print_group(workouts, key)
75+
print("")
76+
else:
77+
key = args.list.lower()
78+
if key not in workouts:
79+
print(f"Unknown group: {args.list}")
80+
return 1
81+
_print_group(workouts, key)
82+
return 0
83+
84+
85+
def main(argv=None):
86+
"""Entry point: dispatch flags or launch the interactive REPL."""
87+
args = parse_args(argv)
88+
89+
if args.init_config:
90+
path = config_mod.write_default_config(args.config)
91+
print(f"Wrote default config to {path}")
92+
return 0
93+
94+
if args.history:
95+
print(history_mod.format_history())
96+
return 0
97+
98+
if args.list is not None:
99+
return _do_list(args)
100+
101+
workout(preselect=args.group, config_path=args.config)
102+
return 0
103+
104+
105+
if __name__ == "__main__":
106+
sys.exit(main())

config.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""
2+
PyWorkout user configuration.
3+
4+
A user can override exercises, rep counts, and (most usefully) video paths
5+
without editing source. Config lives at ``~/.pyworkout/config.json`` and is
6+
deep-merged over the built-in defaults in ``data.py`` -- so overriding just one
7+
group's video path leaves every other group untouched.
8+
9+
Missing config -> defaults, silently. Malformed JSON -> defaults, with a single
10+
warning line (this is a hobby tool; never crash on a bad config).
11+
"""
12+
13+
import copy
14+
import json
15+
import os
16+
17+
try:
18+
from data import WORKOUTS, GROUP_ORDER
19+
except ImportError: # installed as part of the package
20+
from .data import WORKOUTS, GROUP_ORDER
21+
22+
CONFIG_DIR_NAME = ".pyworkout"
23+
CONFIG_FILE_NAME = "config.json"
24+
25+
26+
def config_dir():
27+
"""Return the directory holding PyWorkout config and history."""
28+
return os.path.join(os.path.expanduser("~"), CONFIG_DIR_NAME)
29+
30+
31+
def default_config_path():
32+
"""Return the default path to ``config.json``."""
33+
return os.path.join(config_dir(), CONFIG_FILE_NAME)
34+
35+
36+
def _deep_merge(base, override):
37+
"""Return a deep copy of ``base`` with ``override`` recursively merged in."""
38+
result = copy.deepcopy(base)
39+
for key, value in override.items():
40+
if (
41+
key in result
42+
and isinstance(result[key], dict)
43+
and isinstance(value, dict)
44+
):
45+
result[key] = _deep_merge(result[key], value)
46+
else:
47+
result[key] = copy.deepcopy(value)
48+
return result
49+
50+
51+
def default_workouts():
52+
"""Return a deep copy of the built-in workout data."""
53+
return copy.deepcopy(WORKOUTS)
54+
55+
56+
def load_config(path=None):
57+
"""
58+
Load the merged workout config.
59+
60+
Reads JSON from ``path`` (defaults to ``default_config_path()``) and
61+
deep-merges it over the built-in defaults. Returns the defaults unchanged
62+
when the file is absent or unreadable.
63+
"""
64+
if path is None:
65+
path = default_config_path()
66+
67+
if not os.path.exists(path):
68+
return default_workouts()
69+
70+
try:
71+
with open(path, encoding="UTF-8") as handle:
72+
user = json.load(handle)
73+
except (json.JSONDecodeError, OSError) as err:
74+
print(f"Warning: could not read config at {path} ({err}). Using defaults.")
75+
return default_workouts()
76+
77+
if not isinstance(user, dict):
78+
print(f"Warning: config at {path} is not a JSON object. Using defaults.")
79+
return default_workouts()
80+
81+
return _deep_merge(WORKOUTS, user)
82+
83+
84+
def write_default_config(path=None):
85+
"""
86+
Write the built-in defaults to ``path`` as a starting point users can edit.
87+
88+
Creates the config directory if needed. Returns the path written.
89+
"""
90+
if path is None:
91+
path = default_config_path()
92+
93+
os.makedirs(os.path.dirname(path), exist_ok=True)
94+
with open(path, "w", encoding="UTF-8") as handle:
95+
json.dump(default_workouts(), handle, indent=2)
96+
return path
97+
98+
99+
def group_order(workouts=None):
100+
"""
101+
Return group keys in display order.
102+
103+
Built-in groups keep their canonical order; any extra groups a user added
104+
via config are appended in sorted order.
105+
"""
106+
if workouts is None:
107+
return list(GROUP_ORDER)
108+
ordered = [g for g in GROUP_ORDER if g in workouts]
109+
extra = sorted(k for k in workouts if k not in GROUP_ORDER)
110+
return ordered + extra

0 commit comments

Comments
 (0)