repwise 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- repwise-1.0.0/LICENSE +21 -0
- repwise-1.0.0/PKG-INFO +197 -0
- repwise-1.0.0/README.md +163 -0
- repwise-1.0.0/pyproject.toml +138 -0
- repwise-1.0.0/setup.cfg +4 -0
- repwise-1.0.0/src/repwise/__init__.py +4 -0
- repwise-1.0.0/src/repwise/__main__.py +7 -0
- repwise-1.0.0/src/repwise/app/__init__.py +7 -0
- repwise-1.0.0/src/repwise/app/checking.py +109 -0
- repwise-1.0.0/src/repwise/app/fetch.py +58 -0
- repwise-1.0.0/src/repwise/app/importing.py +72 -0
- repwise-1.0.0/src/repwise/app/listing.py +35 -0
- repwise-1.0.0/src/repwise/app/report.py +170 -0
- repwise-1.0.0/src/repwise/app/update.py +642 -0
- repwise-1.0.0/src/repwise/checker.py +287 -0
- repwise-1.0.0/src/repwise/cli/__init__.py +105 -0
- repwise-1.0.0/src/repwise/cli/parser.py +201 -0
- repwise-1.0.0/src/repwise/config.py +427 -0
- repwise-1.0.0/src/repwise/domain/__init__.py +6 -0
- repwise-1.0.0/src/repwise/domain/effort.py +199 -0
- repwise-1.0.0/src/repwise/domain/matching.py +83 -0
- repwise-1.0.0/src/repwise/domain/models.py +138 -0
- repwise-1.0.0/src/repwise/domain/progression.py +404 -0
- repwise-1.0.0/src/repwise/errors.py +79 -0
- repwise-1.0.0/src/repwise/garmin/__init__.py +1 -0
- repwise-1.0.0/src/repwise/garmin/catalog.py +185 -0
- repwise-1.0.0/src/repwise/garmin/client.py +247 -0
- repwise-1.0.0/src/repwise/garmin/payloads.py +705 -0
- repwise-1.0.0/src/repwise/importer.py +265 -0
- repwise-1.0.0/src/repwise/log.py +72 -0
- repwise-1.0.0/src/repwise/planner.py +968 -0
- repwise-1.0.0/src/repwise/yamlio.py +102 -0
- repwise-1.0.0/src/repwise.egg-info/PKG-INFO +197 -0
- repwise-1.0.0/src/repwise.egg-info/SOURCES.txt +52 -0
- repwise-1.0.0/src/repwise.egg-info/dependency_links.txt +1 -0
- repwise-1.0.0/src/repwise.egg-info/entry_points.txt +2 -0
- repwise-1.0.0/src/repwise.egg-info/requires.txt +10 -0
- repwise-1.0.0/src/repwise.egg-info/top_level.txt +1 -0
- repwise-1.0.0/tests/test_catalog.py +184 -0
- repwise-1.0.0/tests/test_checker.py +293 -0
- repwise-1.0.0/tests/test_checking.py +200 -0
- repwise-1.0.0/tests/test_cli.py +144 -0
- repwise-1.0.0/tests/test_client.py +244 -0
- repwise-1.0.0/tests/test_config.py +626 -0
- repwise-1.0.0/tests/test_effort.py +245 -0
- repwise-1.0.0/tests/test_importer.py +164 -0
- repwise-1.0.0/tests/test_log.py +86 -0
- repwise-1.0.0/tests/test_main.py +250 -0
- repwise-1.0.0/tests/test_matching.py +92 -0
- repwise-1.0.0/tests/test_models.py +43 -0
- repwise-1.0.0/tests/test_payloads.py +629 -0
- repwise-1.0.0/tests/test_planner.py +914 -0
- repwise-1.0.0/tests/test_progression.py +642 -0
- repwise-1.0.0/tests/test_update.py +840 -0
repwise-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Żmuda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
repwise-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: repwise
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Advance Garmin strength workout targets using double progression
|
|
5
|
+
Author-email: Adam Żmuda <adam@zmuda.pro>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/theadzik/repwise
|
|
8
|
+
Project-URL: Documentation, https://github.com/theadzik/repwise/tree/main/docs
|
|
9
|
+
Project-URL: Changelog, https://github.com/theadzik/repwise/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/theadzik/repwise/issues
|
|
11
|
+
Keywords: garmin,strength-training,progressive-overload
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Natural Language :: English
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: garminconnect==0.3.7
|
|
25
|
+
Requires-Dist: pyyaml==6.0.3
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: commitizen==4.17.0; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy==2.3.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pre-commit==4.6.1; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest==9.1.1; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff==0.16.1; extra == "dev"
|
|
32
|
+
Requires-Dist: types-PyYAML==6.0.12.20260724; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# Garmin Double Progression
|
|
36
|
+
|
|
37
|
+
Reads what you actually lifted from Garmin Connect and advances the targets in
|
|
38
|
+
your Garmin workouts automatically, using double progression.
|
|
39
|
+
|
|
40
|
+
Train, run one command, sync your watch. Next session's numbers are already
|
|
41
|
+
waiting on it.
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
$ repwise update
|
|
45
|
+
|
|
46
|
+
Activity: Workout B (1234567890)
|
|
47
|
+
Updating: Workout B -> workout 111111111
|
|
48
|
+
|
|
49
|
+
* Barbell Deadlift 10 x 60 kg -> 6 x 65 kg (hit 10 on every set, +5 kg and reset to 6)
|
|
50
|
+
* Dumbbell Lateral Raise 12 x 8 kg -> 13 x 8 kg (add 1 rep (12 -> 13))
|
|
51
|
+
Sit-up 11 reps -> 11 reps (missed target (10/11 on worst set), repeat)
|
|
52
|
+
|
|
53
|
+
Dry run: 2 step(s) would change. Re-run with --apply.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
You need workouts already built in Garmin Connect, and Python 3.14 or newer.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/theadzik/repwise.git
|
|
62
|
+
cd repwise
|
|
63
|
+
python3 -m venv .venv
|
|
64
|
+
.venv/bin/pip install -e .
|
|
65
|
+
|
|
66
|
+
repwise import -o workouts.yaml # build a config from your Garmin workouts
|
|
67
|
+
repwise update # after a session: see what would change
|
|
68
|
+
repwise update --apply --push # write it, and send it to your watch
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The first command that reaches Garmin asks for your email, password and MFA
|
|
72
|
+
code, then caches a token so later runs do not. Credentials are never stored by
|
|
73
|
+
this tool.
|
|
74
|
+
|
|
75
|
+
**Nothing is written to Garmin without `--apply`.** A dry run is the default.
|
|
76
|
+
|
|
77
|
+
The full walkthrough is in [Getting started][getting-started].
|
|
78
|
+
|
|
79
|
+
## What it does
|
|
80
|
+
|
|
81
|
+
It compares what you performed against the target stored in Garmin and picks
|
|
82
|
+
the next one. Every workout in your config is advanced from its own latest
|
|
83
|
+
session, so training A and then B and running once brings both up to date:
|
|
84
|
+
|
|
85
|
+
1. Start at the bottom of the rep range.
|
|
86
|
+
2. Add a rep to every set each session.
|
|
87
|
+
3. Once every set reaches the top of the range, add weight and reset to the
|
|
88
|
+
bottom.
|
|
89
|
+
4. If you missed the target, repeat it unchanged.
|
|
90
|
+
5. A load only counts once you can carry it for the bottom of the range.
|
|
91
|
+
|
|
92
|
+
Three things it handles that a spreadsheet would not:
|
|
93
|
+
|
|
94
|
+
- **It judges progress by your weakest set.** Extra reps on the easy sets do not
|
|
95
|
+
pull the target somewhere you cannot repeat.
|
|
96
|
+
- **It eases you back after a stall.** Miss a target twice and the session that
|
|
97
|
+
finally beats it earns a rep on two of your four sets rather than on all of
|
|
98
|
+
them, so you are not asked again for the jump that just failed.
|
|
99
|
+
- **It follows the weight you actually used.** Bump the load mid-session and the
|
|
100
|
+
new load is banked, not discarded.
|
|
101
|
+
- **It won't keep a load you didn't earn.** Take the 4 kg dumbbells because the
|
|
102
|
+
3 kg pair was gone, come up short of the range, and your target stays where it
|
|
103
|
+
was rather than following you onto a weight that was too heavy.
|
|
104
|
+
|
|
105
|
+
Each exercise also gets a one-line note on its Garmin step, such as
|
|
106
|
+
`6-10 reps | +5 kg`, so the watch shows what you are working towards and not
|
|
107
|
+
just today's target. Notes you wrote yourself are left alone.
|
|
108
|
+
|
|
109
|
+
## Your config is the workout
|
|
110
|
+
|
|
111
|
+
`workouts.yaml` decides what a workout **is**; Garmin keeps track of where each
|
|
112
|
+
exercise has **got to**. Edit the file and the next run brings Garmin into
|
|
113
|
+
line:
|
|
114
|
+
|
|
115
|
+
- **Write a workout that does not exist yet.** Leave `garmin_workout_id` out
|
|
116
|
+
and it is built in Garmin, then the id is written back into your file.
|
|
117
|
+
- **Reorder the exercises.** The order in the file is the order on the watch.
|
|
118
|
+
Add one and it is added; delete one and it is deleted.
|
|
119
|
+
- **Set the rests and the sets.** Including the rest *between* exercises, which
|
|
120
|
+
Garmin leaves as a wait for the lap button until you ask for a time.
|
|
121
|
+
|
|
122
|
+
An exercise that moves keeps everything it had, target included, because the
|
|
123
|
+
step itself is moved rather than rebuilt. That is the difference between
|
|
124
|
+
reordering your workout and quietly restarting your progression.
|
|
125
|
+
|
|
126
|
+
Nothing reaches Garmin without `--apply`, and a dry run prints every addition,
|
|
127
|
+
removal and move first.
|
|
128
|
+
|
|
129
|
+
It also covers timed holds like planks, exercises the watch counts per side, and
|
|
130
|
+
keeping an exercise in sync when it appears in more than one workout.
|
|
131
|
+
[Progression][progression] explains every decision it can make.
|
|
132
|
+
|
|
133
|
+
## Commands
|
|
134
|
+
|
|
135
|
+
| Command | What it does |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `repwise update` | Advance targets from the latest session, and bring every workout in line with the config. Dry run by default |
|
|
138
|
+
| `repwise update --apply --push` | Write all of that to Garmin and send it to your watch |
|
|
139
|
+
| `repwise list` | Show your Garmin workouts and their ids |
|
|
140
|
+
| `repwise import` | Build a `workouts.yaml` from your Garmin workouts |
|
|
141
|
+
| `repwise check` | Check that your config names exercises Garmin actually has, that it still names the ones your workouts hold, and that every rep range fits what its weight step is really worth |
|
|
142
|
+
| `repwise fetch` | Download raw workout JSON |
|
|
143
|
+
| `repwise fetch exercises` | Refresh Garmin's list of every exercise it knows, which `check` reads |
|
|
144
|
+
|
|
145
|
+
Any command takes `-v` to show debug output as well. Full flags and output for
|
|
146
|
+
each are in [Commands][commands].
|
|
147
|
+
|
|
148
|
+
## Your routine
|
|
149
|
+
|
|
150
|
+
Your routine lives in `workouts.yaml`: the exercises, their order, rep ranges,
|
|
151
|
+
set counts, rests, and which Garmin workout each belongs to. Generate it with
|
|
152
|
+
`repwise import`, or copy [workouts.example.yaml][workouts-example] - a
|
|
153
|
+
complete working A/B full body split, annotated field by field.
|
|
154
|
+
|
|
155
|
+
It is the source of truth rather than a copy of one: `update` writes what it
|
|
156
|
+
says to Garmin, and the only thing ever written back into it is an id Garmin
|
|
157
|
+
issues for a workout it has just created.
|
|
158
|
+
|
|
159
|
+
That file is gitignored, so your routine and Garmin ids stay out of version
|
|
160
|
+
control. Every field is described in [Configuration][configuration].
|
|
161
|
+
|
|
162
|
+
## Documentation
|
|
163
|
+
|
|
164
|
+
For users:
|
|
165
|
+
|
|
166
|
+
| Page | Contents |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| [Getting started][getting-started] | Install to first update, step by step |
|
|
169
|
+
| [Commands][commands] | Every command, flag, output marker and exit code |
|
|
170
|
+
| [Configuration][configuration] | The `workouts.yaml` reference, and finding your exercise identifiers |
|
|
171
|
+
| [Progression][progression] | How the next target is decided, with worked examples |
|
|
172
|
+
| [Troubleshooting][troubleshooting] | Login problems, skipped exercises, known limitations |
|
|
173
|
+
|
|
174
|
+
For contributors:
|
|
175
|
+
|
|
176
|
+
| Page | Contents |
|
|
177
|
+
| --- | --- |
|
|
178
|
+
| [Architecture][architecture] | Module layout, dependency and data-flow diagrams |
|
|
179
|
+
| [Garmin's API][garmin-api] | The undocumented payloads and endpoints, and their traps |
|
|
180
|
+
| [Contributing][contributing] | Dev setup, tests, where to make a change |
|
|
181
|
+
| [Releasing][releasing] | Commit message format, and how the version is derived from it |
|
|
182
|
+
|
|
183
|
+
## Licence
|
|
184
|
+
|
|
185
|
+
[MIT][license].
|
|
186
|
+
|
|
187
|
+
[getting-started]: https://github.com/theadzik/repwise/blob/main/docs/getting-started.md
|
|
188
|
+
[commands]: https://github.com/theadzik/repwise/blob/main/docs/commands.md
|
|
189
|
+
[configuration]: https://github.com/theadzik/repwise/blob/main/docs/configuration.md
|
|
190
|
+
[progression]: https://github.com/theadzik/repwise/blob/main/docs/progression.md
|
|
191
|
+
[troubleshooting]: https://github.com/theadzik/repwise/blob/main/docs/troubleshooting.md
|
|
192
|
+
[architecture]: https://github.com/theadzik/repwise/blob/main/docs/architecture.md
|
|
193
|
+
[garmin-api]: https://github.com/theadzik/repwise/blob/main/docs/garmin-api.md
|
|
194
|
+
[contributing]: https://github.com/theadzik/repwise/blob/main/docs/contributing.md
|
|
195
|
+
[releasing]: https://github.com/theadzik/repwise/blob/main/docs/releasing.md
|
|
196
|
+
[workouts-example]: https://github.com/theadzik/repwise/blob/main/workouts.example.yaml
|
|
197
|
+
[license]: https://github.com/theadzik/repwise/blob/main/LICENSE
|
repwise-1.0.0/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Garmin Double Progression
|
|
2
|
+
|
|
3
|
+
Reads what you actually lifted from Garmin Connect and advances the targets in
|
|
4
|
+
your Garmin workouts automatically, using double progression.
|
|
5
|
+
|
|
6
|
+
Train, run one command, sync your watch. Next session's numbers are already
|
|
7
|
+
waiting on it.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
$ repwise update
|
|
11
|
+
|
|
12
|
+
Activity: Workout B (1234567890)
|
|
13
|
+
Updating: Workout B -> workout 111111111
|
|
14
|
+
|
|
15
|
+
* Barbell Deadlift 10 x 60 kg -> 6 x 65 kg (hit 10 on every set, +5 kg and reset to 6)
|
|
16
|
+
* Dumbbell Lateral Raise 12 x 8 kg -> 13 x 8 kg (add 1 rep (12 -> 13))
|
|
17
|
+
Sit-up 11 reps -> 11 reps (missed target (10/11 on worst set), repeat)
|
|
18
|
+
|
|
19
|
+
Dry run: 2 step(s) would change. Re-run with --apply.
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
You need workouts already built in Garmin Connect, and Python 3.14 or newer.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git clone https://github.com/theadzik/repwise.git
|
|
28
|
+
cd repwise
|
|
29
|
+
python3 -m venv .venv
|
|
30
|
+
.venv/bin/pip install -e .
|
|
31
|
+
|
|
32
|
+
repwise import -o workouts.yaml # build a config from your Garmin workouts
|
|
33
|
+
repwise update # after a session: see what would change
|
|
34
|
+
repwise update --apply --push # write it, and send it to your watch
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The first command that reaches Garmin asks for your email, password and MFA
|
|
38
|
+
code, then caches a token so later runs do not. Credentials are never stored by
|
|
39
|
+
this tool.
|
|
40
|
+
|
|
41
|
+
**Nothing is written to Garmin without `--apply`.** A dry run is the default.
|
|
42
|
+
|
|
43
|
+
The full walkthrough is in [Getting started][getting-started].
|
|
44
|
+
|
|
45
|
+
## What it does
|
|
46
|
+
|
|
47
|
+
It compares what you performed against the target stored in Garmin and picks
|
|
48
|
+
the next one. Every workout in your config is advanced from its own latest
|
|
49
|
+
session, so training A and then B and running once brings both up to date:
|
|
50
|
+
|
|
51
|
+
1. Start at the bottom of the rep range.
|
|
52
|
+
2. Add a rep to every set each session.
|
|
53
|
+
3. Once every set reaches the top of the range, add weight and reset to the
|
|
54
|
+
bottom.
|
|
55
|
+
4. If you missed the target, repeat it unchanged.
|
|
56
|
+
5. A load only counts once you can carry it for the bottom of the range.
|
|
57
|
+
|
|
58
|
+
Three things it handles that a spreadsheet would not:
|
|
59
|
+
|
|
60
|
+
- **It judges progress by your weakest set.** Extra reps on the easy sets do not
|
|
61
|
+
pull the target somewhere you cannot repeat.
|
|
62
|
+
- **It eases you back after a stall.** Miss a target twice and the session that
|
|
63
|
+
finally beats it earns a rep on two of your four sets rather than on all of
|
|
64
|
+
them, so you are not asked again for the jump that just failed.
|
|
65
|
+
- **It follows the weight you actually used.** Bump the load mid-session and the
|
|
66
|
+
new load is banked, not discarded.
|
|
67
|
+
- **It won't keep a load you didn't earn.** Take the 4 kg dumbbells because the
|
|
68
|
+
3 kg pair was gone, come up short of the range, and your target stays where it
|
|
69
|
+
was rather than following you onto a weight that was too heavy.
|
|
70
|
+
|
|
71
|
+
Each exercise also gets a one-line note on its Garmin step, such as
|
|
72
|
+
`6-10 reps | +5 kg`, so the watch shows what you are working towards and not
|
|
73
|
+
just today's target. Notes you wrote yourself are left alone.
|
|
74
|
+
|
|
75
|
+
## Your config is the workout
|
|
76
|
+
|
|
77
|
+
`workouts.yaml` decides what a workout **is**; Garmin keeps track of where each
|
|
78
|
+
exercise has **got to**. Edit the file and the next run brings Garmin into
|
|
79
|
+
line:
|
|
80
|
+
|
|
81
|
+
- **Write a workout that does not exist yet.** Leave `garmin_workout_id` out
|
|
82
|
+
and it is built in Garmin, then the id is written back into your file.
|
|
83
|
+
- **Reorder the exercises.** The order in the file is the order on the watch.
|
|
84
|
+
Add one and it is added; delete one and it is deleted.
|
|
85
|
+
- **Set the rests and the sets.** Including the rest *between* exercises, which
|
|
86
|
+
Garmin leaves as a wait for the lap button until you ask for a time.
|
|
87
|
+
|
|
88
|
+
An exercise that moves keeps everything it had, target included, because the
|
|
89
|
+
step itself is moved rather than rebuilt. That is the difference between
|
|
90
|
+
reordering your workout and quietly restarting your progression.
|
|
91
|
+
|
|
92
|
+
Nothing reaches Garmin without `--apply`, and a dry run prints every addition,
|
|
93
|
+
removal and move first.
|
|
94
|
+
|
|
95
|
+
It also covers timed holds like planks, exercises the watch counts per side, and
|
|
96
|
+
keeping an exercise in sync when it appears in more than one workout.
|
|
97
|
+
[Progression][progression] explains every decision it can make.
|
|
98
|
+
|
|
99
|
+
## Commands
|
|
100
|
+
|
|
101
|
+
| Command | What it does |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `repwise update` | Advance targets from the latest session, and bring every workout in line with the config. Dry run by default |
|
|
104
|
+
| `repwise update --apply --push` | Write all of that to Garmin and send it to your watch |
|
|
105
|
+
| `repwise list` | Show your Garmin workouts and their ids |
|
|
106
|
+
| `repwise import` | Build a `workouts.yaml` from your Garmin workouts |
|
|
107
|
+
| `repwise check` | Check that your config names exercises Garmin actually has, that it still names the ones your workouts hold, and that every rep range fits what its weight step is really worth |
|
|
108
|
+
| `repwise fetch` | Download raw workout JSON |
|
|
109
|
+
| `repwise fetch exercises` | Refresh Garmin's list of every exercise it knows, which `check` reads |
|
|
110
|
+
|
|
111
|
+
Any command takes `-v` to show debug output as well. Full flags and output for
|
|
112
|
+
each are in [Commands][commands].
|
|
113
|
+
|
|
114
|
+
## Your routine
|
|
115
|
+
|
|
116
|
+
Your routine lives in `workouts.yaml`: the exercises, their order, rep ranges,
|
|
117
|
+
set counts, rests, and which Garmin workout each belongs to. Generate it with
|
|
118
|
+
`repwise import`, or copy [workouts.example.yaml][workouts-example] - a
|
|
119
|
+
complete working A/B full body split, annotated field by field.
|
|
120
|
+
|
|
121
|
+
It is the source of truth rather than a copy of one: `update` writes what it
|
|
122
|
+
says to Garmin, and the only thing ever written back into it is an id Garmin
|
|
123
|
+
issues for a workout it has just created.
|
|
124
|
+
|
|
125
|
+
That file is gitignored, so your routine and Garmin ids stay out of version
|
|
126
|
+
control. Every field is described in [Configuration][configuration].
|
|
127
|
+
|
|
128
|
+
## Documentation
|
|
129
|
+
|
|
130
|
+
For users:
|
|
131
|
+
|
|
132
|
+
| Page | Contents |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| [Getting started][getting-started] | Install to first update, step by step |
|
|
135
|
+
| [Commands][commands] | Every command, flag, output marker and exit code |
|
|
136
|
+
| [Configuration][configuration] | The `workouts.yaml` reference, and finding your exercise identifiers |
|
|
137
|
+
| [Progression][progression] | How the next target is decided, with worked examples |
|
|
138
|
+
| [Troubleshooting][troubleshooting] | Login problems, skipped exercises, known limitations |
|
|
139
|
+
|
|
140
|
+
For contributors:
|
|
141
|
+
|
|
142
|
+
| Page | Contents |
|
|
143
|
+
| --- | --- |
|
|
144
|
+
| [Architecture][architecture] | Module layout, dependency and data-flow diagrams |
|
|
145
|
+
| [Garmin's API][garmin-api] | The undocumented payloads and endpoints, and their traps |
|
|
146
|
+
| [Contributing][contributing] | Dev setup, tests, where to make a change |
|
|
147
|
+
| [Releasing][releasing] | Commit message format, and how the version is derived from it |
|
|
148
|
+
|
|
149
|
+
## Licence
|
|
150
|
+
|
|
151
|
+
[MIT][license].
|
|
152
|
+
|
|
153
|
+
[getting-started]: https://github.com/theadzik/repwise/blob/main/docs/getting-started.md
|
|
154
|
+
[commands]: https://github.com/theadzik/repwise/blob/main/docs/commands.md
|
|
155
|
+
[configuration]: https://github.com/theadzik/repwise/blob/main/docs/configuration.md
|
|
156
|
+
[progression]: https://github.com/theadzik/repwise/blob/main/docs/progression.md
|
|
157
|
+
[troubleshooting]: https://github.com/theadzik/repwise/blob/main/docs/troubleshooting.md
|
|
158
|
+
[architecture]: https://github.com/theadzik/repwise/blob/main/docs/architecture.md
|
|
159
|
+
[garmin-api]: https://github.com/theadzik/repwise/blob/main/docs/garmin-api.md
|
|
160
|
+
[contributing]: https://github.com/theadzik/repwise/blob/main/docs/contributing.md
|
|
161
|
+
[releasing]: https://github.com/theadzik/repwise/blob/main/docs/releasing.md
|
|
162
|
+
[workouts-example]: https://github.com/theadzik/repwise/blob/main/workouts.example.yaml
|
|
163
|
+
[license]: https://github.com/theadzik/repwise/blob/main/LICENSE
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=83.0.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "repwise"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Advance Garmin strength workout targets using double progression"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.14"
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Adam Żmuda", email = "adam@zmuda.pro" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["garmin", "strength-training", "progressive-overload"]
|
|
17
|
+
# How PyPI files and filters this. Deliberately no "License :: OSI Approved"
|
|
18
|
+
# line: PEP 639 replaced those with the SPDX `license` field above, and
|
|
19
|
+
# setuptools refuses to build at all when both are present.
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 5 - Production/Stable",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: End Users/Desktop",
|
|
24
|
+
"Natural Language :: English",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
28
|
+
# One line per supported minor, and requires-python above is what actually
|
|
29
|
+
# enforces it - this only decides what the sidebar on PyPI says.
|
|
30
|
+
"Programming Language :: Python :: 3.14",
|
|
31
|
+
# Trove has no classifier for fitness, training or sport, so this is the
|
|
32
|
+
# closest honest fit.
|
|
33
|
+
"Topic :: Utilities",
|
|
34
|
+
]
|
|
35
|
+
# Pinned to exact versions. This is an application rather than a library, so a
|
|
36
|
+
# reproducible install matters more than being co-installable, and Dependabot
|
|
37
|
+
# raises a PR per release. garminconnect in particular has broken
|
|
38
|
+
# authentication before, so the working version is recorded deliberately.
|
|
39
|
+
dependencies = [
|
|
40
|
+
"garminconnect==0.3.7",
|
|
41
|
+
"pyyaml==6.0.3",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# The sidebar on the PyPI page. Absolute, because the description rendered
|
|
45
|
+
# there is README.md torn out of the repository, where nothing relative
|
|
46
|
+
# resolves.
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/theadzik/repwise"
|
|
49
|
+
Documentation = "https://github.com/theadzik/repwise/tree/main/docs"
|
|
50
|
+
Changelog = "https://github.com/theadzik/repwise/blob/main/CHANGELOG.md"
|
|
51
|
+
Issues = "https://github.com/theadzik/repwise/issues"
|
|
52
|
+
|
|
53
|
+
[project.optional-dependencies]
|
|
54
|
+
dev = [
|
|
55
|
+
"commitizen==4.17.0",
|
|
56
|
+
"mypy==2.3.0",
|
|
57
|
+
"pre-commit==4.6.1",
|
|
58
|
+
"pytest==9.1.1",
|
|
59
|
+
"ruff==0.16.1",
|
|
60
|
+
"types-PyYAML==6.0.12.20260724",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[project.scripts]
|
|
64
|
+
repwise = "repwise.cli:main"
|
|
65
|
+
|
|
66
|
+
[tool.setuptools.packages.find]
|
|
67
|
+
where = ["src"]
|
|
68
|
+
|
|
69
|
+
# Conventional commits in, version numbers out. `cz check` runs from
|
|
70
|
+
# .pre-commit-config.yaml on every commit message; `cz bump` reads the commits
|
|
71
|
+
# since the last tag and decides the release. See docs/releasing.md.
|
|
72
|
+
[tool.commitizen]
|
|
73
|
+
name = "cz_conventional_commits"
|
|
74
|
+
# Read and write [project].version above, so the version is stated once.
|
|
75
|
+
version_provider = "pep621"
|
|
76
|
+
# Bare "0.2.0", not "v0.2.0". This is Commitizen's own default and matches the
|
|
77
|
+
# hand-cut 0.1.0 tag, so it is stated only to pin it: the default has changed
|
|
78
|
+
# upstream before, and a silent change would start a second tag series.
|
|
79
|
+
tag_format = "$version"
|
|
80
|
+
version_files = ["src/repwise/__init__.py:__version__"]
|
|
81
|
+
update_changelog_on_bump = true
|
|
82
|
+
# History before 0.1.0 predates conventional commits, so there is nothing to
|
|
83
|
+
# generate a changelog from.
|
|
84
|
+
changelog_start_rev = "0.1.0"
|
|
85
|
+
# Commitizen's own Markdown template with a commit link on each entry. The path
|
|
86
|
+
# is resolved against the working directory, so `cz` has to run from the
|
|
87
|
+
# repository root - which is where both the hook and the workflow run it.
|
|
88
|
+
template = ".cz/CHANGELOG.md.j2"
|
|
89
|
+
|
|
90
|
+
# Linting and formatting. The pre-commit hook and a bare `ruff check` read this
|
|
91
|
+
# same section, so they cannot disagree.
|
|
92
|
+
[tool.ruff]
|
|
93
|
+
line-length = 88
|
|
94
|
+
target-version = "py314"
|
|
95
|
+
|
|
96
|
+
[tool.ruff.lint]
|
|
97
|
+
select = [
|
|
98
|
+
"E", "W", # pycodestyle
|
|
99
|
+
"F", # pyflakes
|
|
100
|
+
"I", # import sorting
|
|
101
|
+
"UP", # pyupgrade: syntax available on the minimum Python
|
|
102
|
+
"B", # bugbear: likely bugs
|
|
103
|
+
"C4", # comprehension rewrites
|
|
104
|
+
"SIM", # simplifications
|
|
105
|
+
"RET", # return-statement hygiene
|
|
106
|
+
"PIE", # small correctness idioms
|
|
107
|
+
"Q", # quote consistency
|
|
108
|
+
"RSE", # raise cleanliness
|
|
109
|
+
"SLF", # private member access
|
|
110
|
+
"BLE", # blind except - the existing noqa comments name this rule
|
|
111
|
+
"PL", # pylint subset
|
|
112
|
+
"RUF", # ruff's own
|
|
113
|
+
]
|
|
114
|
+
ignore = [
|
|
115
|
+
# This is a CLI whose job is printing, and its errors carry the detail a
|
|
116
|
+
# user needs, so neither "no print" nor "no message in the raise" applies.
|
|
117
|
+
# T201 and EM/TRY are left unselected for the same reason.
|
|
118
|
+
#
|
|
119
|
+
# Rewriting os.path to pathlib (PTH) and rejecting relative imports (TID252)
|
|
120
|
+
# would both fight deliberate choices, so they stay unselected too.
|
|
121
|
+
"PLR2004", # magic values: tests compare against literal reps and weights
|
|
122
|
+
"PLC0415", # imports inside functions are deliberate here
|
|
123
|
+
"RUF022", # __all__ is ordered by meaning, not alphabetically
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
[tool.mypy]
|
|
127
|
+
files = ["src", "tests"]
|
|
128
|
+
python_version = "3.14"
|
|
129
|
+
|
|
130
|
+
# garminconnect ships no type information. garmin/client.py is the only module
|
|
131
|
+
# that imports it, which is what keeping the adapter in one place buys.
|
|
132
|
+
[[tool.mypy.overrides]]
|
|
133
|
+
module = "garminconnect.*"
|
|
134
|
+
ignore_missing_imports = true
|
|
135
|
+
|
|
136
|
+
[tool.pytest.ini_options]
|
|
137
|
+
pythonpath = ["src"]
|
|
138
|
+
testpaths = ["tests"]
|
repwise-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The use cases: one module per command, and the report they print.
|
|
2
|
+
|
|
3
|
+
Each `run_*` function is the whole of a command apart from parsing and
|
|
4
|
+
dispatch. It is handed the things it needs - a Garmin session, a config, its
|
|
5
|
+
options - rather than building them, so nothing here imports argparse and
|
|
6
|
+
nothing here decides what a process exits with beyond returning it.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Report where workouts.yaml and the Garmin workouts disagree."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from ..checker import Finding, check_catalog, check_programming, check_workout
|
|
6
|
+
from ..domain.models import Config, GarminSettings
|
|
7
|
+
from ..errors import ExitCode, GarminError
|
|
8
|
+
from ..garmin.catalog import ExerciseCatalog, ensure
|
|
9
|
+
from ..garmin.client import GarminSession
|
|
10
|
+
from .report import SEVERITY
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _catalog(settings: GarminSettings) -> ExerciseCatalog | None:
|
|
16
|
+
"""Garmin's exercise list, downloaded on the first run that wants it.
|
|
17
|
+
|
|
18
|
+
Fetched here rather than demanded of the user, because a check that only
|
|
19
|
+
works after another command has been run is a check that goes unrun. The
|
|
20
|
+
copy is cached, so this costs one download ever.
|
|
21
|
+
|
|
22
|
+
A failure costs the name checks and nothing else, exactly as a missing
|
|
23
|
+
weigh-in costs the range checks. `check` is worth running with no network
|
|
24
|
+
at all, and the questions it can still answer are worth answering.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
return ensure(settings)
|
|
28
|
+
except GarminError as exc:
|
|
29
|
+
logger.warning(f"Exercise names were not checked: {exc}")
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _bodyweight(session: GarminSession, config: Config) -> float | None:
|
|
34
|
+
"""How much of you the bodyweight-loaded exercises are carrying.
|
|
35
|
+
|
|
36
|
+
What the config states wins, since someone who wrote it down means it.
|
|
37
|
+
Otherwise Garmin is asked, which is the answer that stays current without
|
|
38
|
+
anyone editing a file. A failure here is not worth failing the command
|
|
39
|
+
over: it costs the range checks on a few exercises, and `check_programming`
|
|
40
|
+
reports each of those where it finds them.
|
|
41
|
+
"""
|
|
42
|
+
if config.bodyweight is not None:
|
|
43
|
+
return config.bodyweight
|
|
44
|
+
|
|
45
|
+
if not any(
|
|
46
|
+
spec.bodyweight_factor for workout in config for spec in workout.exercises
|
|
47
|
+
):
|
|
48
|
+
return None # nothing would read it, so do not spend a request on it
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
weight = session.bodyweight()
|
|
52
|
+
except GarminError as exc:
|
|
53
|
+
logger.debug(f"Could not read your weigh-ins: {exc}")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
if weight is not None:
|
|
57
|
+
logger.debug(f"Bodyweight {weight:g} kg, averaged from your Garmin weigh-ins.")
|
|
58
|
+
return weight
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_check(session: GarminSession, config: Config) -> ExitCode:
|
|
62
|
+
findings: list[Finding] = []
|
|
63
|
+
bodyweight = _bodyweight(session, config)
|
|
64
|
+
catalog = _catalog(config.garmin)
|
|
65
|
+
for workout in config:
|
|
66
|
+
workout_id = workout.garmin_workout_id
|
|
67
|
+
# First, and outside the branch below, because it is the only check
|
|
68
|
+
# that does not need Garmin to hold the workout. Reported first too: an
|
|
69
|
+
# exercise that does not exist explains whatever the checks below go on
|
|
70
|
+
# to say about it, which reads better before them than after.
|
|
71
|
+
found = check_catalog(workout, catalog) if catalog else []
|
|
72
|
+
|
|
73
|
+
if workout_id is None:
|
|
74
|
+
# Nothing in Garmin to disagree with yet. Said out loud, because
|
|
75
|
+
# silence here would read as "checked, and fine" - and the names
|
|
76
|
+
# above were checked, which is the point of doing it now.
|
|
77
|
+
logger.info(f"{workout.key} (not in Garmin yet)")
|
|
78
|
+
else:
|
|
79
|
+
logger.info(f"{workout.key} ({workout_id})")
|
|
80
|
+
try:
|
|
81
|
+
payload = session.workout(workout_id)
|
|
82
|
+
except GarminError as exc:
|
|
83
|
+
# A workout that cannot be read is itself an error-level
|
|
84
|
+
# finding, so an unreachable workout still fails the command.
|
|
85
|
+
found.append(
|
|
86
|
+
Finding(
|
|
87
|
+
workout.key,
|
|
88
|
+
f"could not fetch workout {workout_id}: {exc}",
|
|
89
|
+
"error",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
found += check_workout(workout, payload)
|
|
94
|
+
found += check_programming(workout, payload, bodyweight)
|
|
95
|
+
|
|
96
|
+
if not found:
|
|
97
|
+
logger.info(" ok")
|
|
98
|
+
for finding in found:
|
|
99
|
+
marker, level = SEVERITY[finding.severity]
|
|
100
|
+
logger.log(level, f" {marker} {finding.detail}")
|
|
101
|
+
logger.info("")
|
|
102
|
+
findings.extend(found)
|
|
103
|
+
|
|
104
|
+
# Everything reported here needs a hand, so any finding at all fails the
|
|
105
|
+
# command. That is what makes it worth putting in a cron job: it goes off
|
|
106
|
+
# when the config is wrong, not when you have edited a rest and not yet
|
|
107
|
+
# run `update`.
|
|
108
|
+
logger.info(f"{len(findings)} issue(s) across {len(config.workouts)} workout(s)")
|
|
109
|
+
return ExitCode.NOTHING_USABLE if findings else ExitCode.OK
|