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.
Files changed (54) hide show
  1. repwise-1.0.0/LICENSE +21 -0
  2. repwise-1.0.0/PKG-INFO +197 -0
  3. repwise-1.0.0/README.md +163 -0
  4. repwise-1.0.0/pyproject.toml +138 -0
  5. repwise-1.0.0/setup.cfg +4 -0
  6. repwise-1.0.0/src/repwise/__init__.py +4 -0
  7. repwise-1.0.0/src/repwise/__main__.py +7 -0
  8. repwise-1.0.0/src/repwise/app/__init__.py +7 -0
  9. repwise-1.0.0/src/repwise/app/checking.py +109 -0
  10. repwise-1.0.0/src/repwise/app/fetch.py +58 -0
  11. repwise-1.0.0/src/repwise/app/importing.py +72 -0
  12. repwise-1.0.0/src/repwise/app/listing.py +35 -0
  13. repwise-1.0.0/src/repwise/app/report.py +170 -0
  14. repwise-1.0.0/src/repwise/app/update.py +642 -0
  15. repwise-1.0.0/src/repwise/checker.py +287 -0
  16. repwise-1.0.0/src/repwise/cli/__init__.py +105 -0
  17. repwise-1.0.0/src/repwise/cli/parser.py +201 -0
  18. repwise-1.0.0/src/repwise/config.py +427 -0
  19. repwise-1.0.0/src/repwise/domain/__init__.py +6 -0
  20. repwise-1.0.0/src/repwise/domain/effort.py +199 -0
  21. repwise-1.0.0/src/repwise/domain/matching.py +83 -0
  22. repwise-1.0.0/src/repwise/domain/models.py +138 -0
  23. repwise-1.0.0/src/repwise/domain/progression.py +404 -0
  24. repwise-1.0.0/src/repwise/errors.py +79 -0
  25. repwise-1.0.0/src/repwise/garmin/__init__.py +1 -0
  26. repwise-1.0.0/src/repwise/garmin/catalog.py +185 -0
  27. repwise-1.0.0/src/repwise/garmin/client.py +247 -0
  28. repwise-1.0.0/src/repwise/garmin/payloads.py +705 -0
  29. repwise-1.0.0/src/repwise/importer.py +265 -0
  30. repwise-1.0.0/src/repwise/log.py +72 -0
  31. repwise-1.0.0/src/repwise/planner.py +968 -0
  32. repwise-1.0.0/src/repwise/yamlio.py +102 -0
  33. repwise-1.0.0/src/repwise.egg-info/PKG-INFO +197 -0
  34. repwise-1.0.0/src/repwise.egg-info/SOURCES.txt +52 -0
  35. repwise-1.0.0/src/repwise.egg-info/dependency_links.txt +1 -0
  36. repwise-1.0.0/src/repwise.egg-info/entry_points.txt +2 -0
  37. repwise-1.0.0/src/repwise.egg-info/requires.txt +10 -0
  38. repwise-1.0.0/src/repwise.egg-info/top_level.txt +1 -0
  39. repwise-1.0.0/tests/test_catalog.py +184 -0
  40. repwise-1.0.0/tests/test_checker.py +293 -0
  41. repwise-1.0.0/tests/test_checking.py +200 -0
  42. repwise-1.0.0/tests/test_cli.py +144 -0
  43. repwise-1.0.0/tests/test_client.py +244 -0
  44. repwise-1.0.0/tests/test_config.py +626 -0
  45. repwise-1.0.0/tests/test_effort.py +245 -0
  46. repwise-1.0.0/tests/test_importer.py +164 -0
  47. repwise-1.0.0/tests/test_log.py +86 -0
  48. repwise-1.0.0/tests/test_main.py +250 -0
  49. repwise-1.0.0/tests/test_matching.py +92 -0
  50. repwise-1.0.0/tests/test_models.py +43 -0
  51. repwise-1.0.0/tests/test_payloads.py +629 -0
  52. repwise-1.0.0/tests/test_planner.py +914 -0
  53. repwise-1.0.0/tests/test_progression.py +642 -0
  54. 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
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """Advance Garmin strength workout targets using double progression."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "1.0.0"
@@ -0,0 +1,7 @@
1
+ """Entry point for `python -m repwise`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -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