repwise 1.2.0__tar.gz → 2.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 (65) hide show
  1. {repwise-1.2.0/src/repwise.egg-info → repwise-2.0.0}/PKG-INFO +4 -10
  2. {repwise-1.2.0 → repwise-2.0.0}/README.md +2 -1
  3. {repwise-1.2.0 → repwise-2.0.0}/pyproject.toml +22 -8
  4. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/__init__.py +1 -1
  5. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/checking.py +15 -1
  6. repwise-2.0.0/src/repwise/app/fetch.py +203 -0
  7. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/update.py +9 -20
  8. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/cli/__init__.py +44 -16
  9. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/cli/completion.py +3 -12
  10. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/cli/parser.py +37 -26
  11. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/config.py +76 -36
  12. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/domain/matching.py +29 -0
  13. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/domain/models.py +13 -1
  14. repwise-2.0.0/src/repwise/dumps.py +265 -0
  15. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/errors.py +27 -0
  16. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/garmin/client.py +203 -14
  17. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/garmin/payloads.py +24 -3
  18. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/importer.py +4 -4
  19. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/planner.py +36 -13
  20. {repwise-1.2.0 → repwise-2.0.0/src/repwise.egg-info}/PKG-INFO +4 -10
  21. {repwise-1.2.0 → repwise-2.0.0}/src/repwise.egg-info/SOURCES.txt +3 -0
  22. repwise-2.0.0/src/repwise.egg-info/requires.txt +2 -0
  23. {repwise-1.2.0 → repwise-2.0.0}/tests/test_checking.py +21 -1
  24. {repwise-1.2.0 → repwise-2.0.0}/tests/test_cli.py +29 -13
  25. repwise-2.0.0/tests/test_client.py +735 -0
  26. {repwise-1.2.0 → repwise-2.0.0}/tests/test_completion.py +1 -1
  27. {repwise-1.2.0 → repwise-2.0.0}/tests/test_config.py +133 -52
  28. repwise-2.0.0/tests/test_dumps.py +310 -0
  29. repwise-2.0.0/tests/test_fetch.py +360 -0
  30. {repwise-1.2.0 → repwise-2.0.0}/tests/test_main.py +65 -13
  31. {repwise-1.2.0 → repwise-2.0.0}/tests/test_payloads.py +11 -0
  32. {repwise-1.2.0 → repwise-2.0.0}/tests/test_planner.py +93 -0
  33. {repwise-1.2.0 → repwise-2.0.0}/tests/test_update.py +53 -3
  34. repwise-1.2.0/src/repwise/app/fetch.py +0 -58
  35. repwise-1.2.0/src/repwise.egg-info/requires.txt +0 -10
  36. repwise-1.2.0/tests/test_client.py +0 -361
  37. {repwise-1.2.0 → repwise-2.0.0}/LICENSE +0 -0
  38. {repwise-1.2.0 → repwise-2.0.0}/setup.cfg +0 -0
  39. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/__main__.py +0 -0
  40. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/__init__.py +0 -0
  41. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/importing.py +0 -0
  42. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/listing.py +0 -0
  43. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/logout.py +0 -0
  44. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/report.py +0 -0
  45. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/checker.py +0 -0
  46. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/domain/__init__.py +0 -0
  47. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/domain/effort.py +0 -0
  48. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/domain/progression.py +0 -0
  49. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/garmin/__init__.py +0 -0
  50. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/garmin/catalog.py +0 -0
  51. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/log.py +0 -0
  52. {repwise-1.2.0 → repwise-2.0.0}/src/repwise/yamlio.py +0 -0
  53. {repwise-1.2.0 → repwise-2.0.0}/src/repwise.egg-info/dependency_links.txt +0 -0
  54. {repwise-1.2.0 → repwise-2.0.0}/src/repwise.egg-info/entry_points.txt +0 -0
  55. {repwise-1.2.0 → repwise-2.0.0}/src/repwise.egg-info/top_level.txt +0 -0
  56. {repwise-1.2.0 → repwise-2.0.0}/tests/test_catalog.py +0 -0
  57. {repwise-1.2.0 → repwise-2.0.0}/tests/test_checker.py +0 -0
  58. {repwise-1.2.0 → repwise-2.0.0}/tests/test_effort.py +0 -0
  59. {repwise-1.2.0 → repwise-2.0.0}/tests/test_importer.py +0 -0
  60. {repwise-1.2.0 → repwise-2.0.0}/tests/test_log.py +0 -0
  61. {repwise-1.2.0 → repwise-2.0.0}/tests/test_logout.py +0 -0
  62. {repwise-1.2.0 → repwise-2.0.0}/tests/test_matching.py +0 -0
  63. {repwise-1.2.0 → repwise-2.0.0}/tests/test_models.py +0 -0
  64. {repwise-1.2.0 → repwise-2.0.0}/tests/test_progression.py +0 -0
  65. {repwise-1.2.0 → repwise-2.0.0}/tests/test_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repwise
3
- Version: 1.2.0
3
+ Version: 2.0.0
4
4
  Summary: Advance Garmin strength workout targets using double progression
5
5
  Author-email: Adam Żmuda <adam@zmuda.pro>
6
6
  License-Expression: MIT
@@ -21,15 +21,8 @@ Classifier: Topic :: Utilities
21
21
  Requires-Python: >=3.14
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: garminconnect==0.3.8
24
+ Requires-Dist: garminconnect==0.3.11
25
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
26
  Dynamic: license-file
34
27
 
35
28
  # Garmin Double Progression
@@ -143,7 +136,8 @@ keeping an exercise in sync when it appears in more than one workout.
143
136
  | `repwise list` | Show your Garmin workouts and their ids |
144
137
  | `repwise import` | Build a `workouts.yaml` from your Garmin workouts |
145
138
  | `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 |
146
- | `repwise fetch` | Download raw workout JSON |
139
+ | `repwise fetch workouts` | Download raw workout JSON |
140
+ | `repwise fetch activities` | Download the raw JSON of the sessions you performed: the summary, the sets your watch recorded, and the workout each was run against |
147
141
  | `repwise fetch exercises` | Refresh Garmin's list of every exercise it knows, which `check` and `update` read |
148
142
  | `repwise logout` | Delete the cached Garmin token, so the next run logs in again |
149
143
 
@@ -109,7 +109,8 @@ keeping an exercise in sync when it appears in more than one workout.
109
109
  | `repwise list` | Show your Garmin workouts and their ids |
110
110
  | `repwise import` | Build a `workouts.yaml` from your Garmin workouts |
111
111
  | `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 |
112
- | `repwise fetch` | Download raw workout JSON |
112
+ | `repwise fetch workouts` | Download raw workout JSON |
113
+ | `repwise fetch activities` | Download the raw JSON of the sessions you performed: the summary, the sets your watch recorded, and the workout each was run against |
113
114
  | `repwise fetch exercises` | Refresh Garmin's list of every exercise it knows, which `check` and `update` read |
114
115
  | `repwise logout` | Delete the cached Garmin token, so the next run logs in again |
115
116
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "repwise"
7
- version = "1.2.0"
7
+ version = "2.0.0"
8
8
  description = "Advance Garmin strength workout targets using double progression"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -37,7 +37,7 @@ classifiers = [
37
37
  # raises a PR per release. garminconnect in particular has broken
38
38
  # authentication before, so the working version is recorded deliberately.
39
39
  dependencies = [
40
- "garminconnect==0.3.8",
40
+ "garminconnect==0.3.11",
41
41
  "pyyaml==6.0.3",
42
42
  ]
43
43
 
@@ -50,14 +50,28 @@ Documentation = "https://github.com/theadzik/repwise/tree/main/docs"
50
50
  Changelog = "https://github.com/theadzik/repwise/blob/main/CHANGELOG.md"
51
51
  Issues = "https://github.com/theadzik/repwise/issues"
52
52
 
53
- [project.optional-dependencies]
53
+ # Development tooling, as PEP 735 groups rather than extras. An extra is part
54
+ # of the package - installable by anyone from PyPI, and only alongside the
55
+ # package itself. These are neither: nobody installing repwise wants mypy, and
56
+ # the release workflow wants commitizen *without* building repwise at all.
57
+ # Groups install on their own, which is what makes that possible.
58
+ #
59
+ # `release` is split out because the release workflow needs commitizen and
60
+ # nothing else, and the version of commitizen decides the release rules - which
61
+ # commit types bump what, how the changelog renders. Pinning it here and
62
+ # including the group in `dev` means a local `cz bump --dry-run` and the
63
+ # workflow cannot disagree about the answer.
64
+ [dependency-groups]
65
+ release = [
66
+ "commitizen==4.17.1",
67
+ ]
54
68
  dev = [
55
- "commitizen==4.17.0",
56
- "mypy==2.3.0",
57
- "pre-commit==4.6.1",
69
+ { include-group = "release" },
70
+ "mypy==2.3.1",
71
+ "pre-commit==4.6.2",
58
72
  "pytest==9.1.1",
59
- "ruff==0.16.1",
60
- "types-PyYAML==6.0.12.20260724",
73
+ "ruff==0.16.3",
74
+ "types-PyYAML==6.0.12.20260815",
61
75
  ]
62
76
 
63
77
  [project.scripts]
@@ -1,4 +1,4 @@
1
1
  """Advance Garmin strength workout targets using double progression."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "1.2.0"
4
+ __version__ = "2.0.0"
@@ -4,7 +4,7 @@ import logging
4
4
 
5
5
  from ..checker import Finding, check_catalog, check_programming, check_workout
6
6
  from ..domain.models import Config, GarminSettings
7
- from ..errors import ExitCode, GarminError
7
+ from ..errors import ExitCode, GarminError, NotInGarmin
8
8
  from ..garmin.catalog import ExerciseCatalog, optional
9
9
  from ..garmin.client import GarminSession
10
10
  from .report import SEVERITY
@@ -67,6 +67,20 @@ def run_check(session: GarminSession, config: Config) -> ExitCode:
67
67
  logger.info(f"{workout.key} ({workout_id})")
68
68
  try:
69
69
  payload = session.workout(workout_id)
70
+ except NotInGarmin:
71
+ # The one failure here with a fix the user can act on: the id
72
+ # in workouts.yaml names a workout the account does not have,
73
+ # usually one deleted in Connect. Said as that rather than as
74
+ # the reading having failed, which is what the branch below is
75
+ # for and would bury the answer inside a longer sentence.
76
+ found.append(
77
+ Finding(
78
+ workout.key,
79
+ f"garmin_workout_id {workout_id} is not in your Garmin "
80
+ f"account - delete the id to have it created again",
81
+ "error",
82
+ )
83
+ )
70
84
  except GarminError as exc:
71
85
  # A workout that cannot be read is itself an error-level
72
86
  # finding, so an unreachable workout still fails the command.
@@ -0,0 +1,203 @@
1
+ """Download what Garmin holds as JSON: your workouts, your sessions, or its
2
+ exercise catalog."""
3
+
4
+ import logging
5
+ import os
6
+ from typing import Any
7
+
8
+ from .. import dumps
9
+ from ..domain.models import Config, GarminSettings
10
+ from ..errors import ExitCode, GarminError
11
+ from ..garmin import catalog
12
+ from ..garmin.client import STRENGTH, GarminSession
13
+ from ..garmin.payloads import activity_sport
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def run_fetch_exercises(settings: GarminSettings) -> ExitCode:
19
+ """Download Garmin's exercise catalog, replacing any cached copy.
20
+
21
+ Unconditional, unlike the first-run download `check` and `update` do for
22
+ themselves: asking for the catalog by name is how you refresh one that has
23
+ gone stale, so finding a copy already there is not a reason to stop.
24
+
25
+ No session is opened. The catalog is public, and requiring a login to
26
+ download it would be a password prompt in exchange for nothing.
27
+ """
28
+ payload = catalog.download()
29
+ # Parsed for the count, and to fail before overwriting a good cache with a
30
+ # response that turned out not to be a catalog at all.
31
+ parsed = catalog.ExerciseCatalog.parse(payload)
32
+ path = catalog.save(settings, payload)
33
+ logger.info(
34
+ f"Saved {len(parsed)} exercises in {len(parsed.categories)} "
35
+ f"categories -> {path}"
36
+ )
37
+ return ExitCode.OK
38
+
39
+
40
+ def run_fetch(
41
+ session: GarminSession, config: Config, workout_ids: list[str] | None = None
42
+ ) -> ExitCode:
43
+ # A workout Garmin does not hold yet has no definition to download, so it
44
+ # is simply not among the ids rather than a failure to report.
45
+ ids = workout_ids or [w.garmin_workout_id for w in config if w.garmin_workout_id]
46
+ failed = False
47
+ for workout_id in ids:
48
+ try:
49
+ payload = session.workout(workout_id)
50
+ except GarminError as exc:
51
+ # One unreachable workout should not cost the user the others.
52
+ logger.error(f"FAILED {workout_id}: {exc}")
53
+ failed = True
54
+ continue
55
+
56
+ path = dumps.write(payload, config.garmin.dump_dir, dumps.WORKOUT, workout_id)
57
+ logger.info(f"Saved {payload.get('workoutName', '(unnamed)')} -> {path}")
58
+
59
+ return ExitCode.NOTHING_USABLE if failed else ExitCode.OK
60
+
61
+
62
+ def run_fetch_activities(
63
+ session: GarminSession, config: Config, activity_ids: list[str] | None = None
64
+ ) -> ExitCode:
65
+ """Save performed sessions as JSON, three payloads to a session.
66
+
67
+ None of the three is either of the others. The summary names and dates the
68
+ session; the sets are what the watch recorded, rep by rep and in grams; the
69
+ executed workout is what that session was *asked* for, and is the only
70
+ record of it, since `update` has since rewritten the definition Garmin
71
+ stores. An activity performed against no workout has no third file.
72
+
73
+ Ids name sessions directly, and are downloaded as given: asking for one by
74
+ id says more about what you want than its sport does, and an id is also the
75
+ only way to reach a session further back than the search limit. Without
76
+ them the recent activities are scanned and the strength ones kept - how far
77
+ back that reaches is `settings.garmin.activity_search_limit`.
78
+ """
79
+ ids = list(activity_ids or [])
80
+ if not ids:
81
+ recent = session.recent_activities()
82
+ ids = [
83
+ str(activity["activityId"])
84
+ for activity in recent
85
+ if activity_sport(activity) == STRENGTH and activity.get("activityId")
86
+ ]
87
+ if not ids:
88
+ logger.warning(
89
+ f"No strength activities among the {len(recent)} most recent. "
90
+ "Raise settings.garmin.activity_search_limit to look further "
91
+ "back, or name an activity id."
92
+ )
93
+ return ExitCode.NOTHING_USABLE
94
+
95
+ saved = 0
96
+ skipped = 0
97
+ failed = False
98
+ for activity_id in ids:
99
+ # Never true unless caching is on, so a run without it downloads
100
+ # everything it is asked for exactly as it always did. `--force` says
101
+ # so by opening a session with no cache behind it, which is why there
102
+ # is no flag to read here.
103
+ if session.is_cached(activity_id):
104
+ skipped += 1
105
+ logger.info(f"Already on disk: {activity_id}")
106
+ continue
107
+
108
+ try:
109
+ name, paths = _save_activity(session, config.garmin.dump_dir, activity_id)
110
+ except GarminError as exc:
111
+ # One unreachable session should not cost the user the others.
112
+ logger.error(f"FAILED {activity_id}: {exc}")
113
+ failed = True
114
+ continue
115
+
116
+ saved += 1
117
+ written = ", ".join(os.path.basename(path) for path in paths)
118
+ logger.info(f"Saved {name} -> {written}")
119
+
120
+ logger.info("")
121
+ # Counted as they land rather than taken from `ids`, so that a run which
122
+ # lost one to a failure does not claim to have saved it.
123
+ logger.info(f"{saved} session(s) -> {config.garmin.dump_dir}")
124
+ if skipped:
125
+ logger.info(f"{skipped} already on disk; --force downloads them again.")
126
+ return ExitCode.NOTHING_USABLE if failed else ExitCode.OK
127
+
128
+
129
+ def cache_activities(
130
+ session: GarminSession, config: Config, activities: list[dict[str, Any]]
131
+ ) -> None:
132
+ """Bring the dump directory level with the sessions Garmin just listed.
133
+
134
+ What `update` does before it works anything out, so that the run reads
135
+ those sessions off disk and every run after it reads them without asking
136
+ at all. Only the strength ones: the rest hold no sets to learn from.
137
+
138
+ Silent unless caching is on. A session with no cache behind it holds
139
+ nothing, so every activity would count as missing and this would download
140
+ the whole search limit on every single run.
141
+
142
+ One session that cannot be downloaded is logged and stepped over. This is
143
+ filling a cache, not doing the work: whatever is missing afterwards is
144
+ fetched again when something actually reads it, and fails there if it
145
+ still cannot be had.
146
+ """
147
+ if not config.garmin.activity_caching:
148
+ return
149
+
150
+ missing = [
151
+ str(activity["activityId"])
152
+ for activity in activities
153
+ if activity_sport(activity) == STRENGTH
154
+ and activity.get("activityId")
155
+ and not session.is_cached(str(activity["activityId"]))
156
+ ]
157
+ if not missing:
158
+ logger.debug("Every strength session Garmin listed is already on disk.")
159
+ return
160
+
161
+ logger.info(f"Filing {len(missing)} session(s) into {config.garmin.dump_dir}")
162
+ for activity_id in missing:
163
+ try:
164
+ _save_activity(session, config.garmin.dump_dir, activity_id)
165
+ except GarminError as exc:
166
+ logger.warning(f"Could not file {activity_id}: {exc}")
167
+
168
+
169
+ def _save_activity(
170
+ session: GarminSession, directory: str, activity_id: str
171
+ ) -> tuple[str, list[str]]:
172
+ """Download one session's payloads. Its name, and the files written.
173
+
174
+ The summary is fetched even for an activity a scan just listed, so that
175
+ `activity-<id>.json` holds the same thing however it was asked for: the
176
+ detail Garmin returns for one activity is fuller than the entry it returns
177
+ for it in a list.
178
+
179
+ All three are written, including an executed workout that came back empty.
180
+ A session performed against no workout is a fact worth recording, and it is
181
+ what lets a missing file mean one thing only - that nobody has asked yet -
182
+ which is what `dumps.ActivityCache` reads it as.
183
+
184
+ Behind a caching session the same bytes have just been written by the cache
185
+ itself, since that is what a miss does. Writing them again is what makes
186
+ this work identically with caching off, which is worth more than the write
187
+ it saves - once per session, of a file that is already open in the page
188
+ cache.
189
+ """
190
+ activity = session.activity(activity_id)
191
+ executed = session.executed_workout(activity_id)
192
+ if not executed:
193
+ logger.debug(f"{activity_id} was not performed against a workout")
194
+
195
+ paths = [
196
+ dumps.write(activity, directory, dumps.ACTIVITY, activity_id),
197
+ dumps.write(
198
+ session.exercise_sets(activity_id), directory, dumps.SETS, activity_id
199
+ ),
200
+ dumps.write(executed, directory, dumps.EXECUTED, activity_id),
201
+ ]
202
+
203
+ return activity.get("activityName") or "(unnamed)", paths
@@ -6,9 +6,7 @@ replayed oldest first, and a target that moves propagated into every other
6
6
  workout containing that exercise.
7
7
  """
8
8
 
9
- import json
10
9
  import logging
11
- import os
12
10
  from collections.abc import Callable, Container, Hashable, Iterable
13
11
  from dataclasses import dataclass, field, replace
14
12
  from typing import Any
@@ -34,10 +32,12 @@ from ..planner import (
34
32
  decided_targets,
35
33
  executed_targets,
36
34
  find_workout,
35
+ index_specs,
37
36
  logged_for,
38
37
  plan_sync,
39
38
  plan_workout,
40
39
  )
40
+ from .fetch import cache_activities
41
41
  from .report import report_plan
42
42
 
43
43
  logger = logging.getLogger(__name__)
@@ -49,7 +49,6 @@ class UpdateOptions:
49
49
 
50
50
  apply: bool = False
51
51
  activity: str | None = None
52
- dump: bool = False
53
52
  push: bool = False
54
53
 
55
54
  def __post_init__(self) -> None:
@@ -97,14 +96,6 @@ class Payloads:
97
96
  return self._fetched[workout_id]
98
97
 
99
98
 
100
- def dump(payloads: dict[str, Any], directory: str, suffix: str) -> None:
101
- for label, payload in payloads.items():
102
- path = os.path.join(directory, f"dump-{label}-{suffix}.json")
103
- with open(path, "w") as fh:
104
- json.dump(payload, fh, indent=2)
105
- logger.debug(f"Wrote {path}")
106
-
107
-
108
99
  def pick_sessions(
109
100
  session: GarminSession,
110
101
  config: Config,
@@ -232,9 +223,11 @@ def gather_history(
232
223
  trained under a different workout -- contributes nothing to its own history
233
224
  rather than breaking anyone else's.
234
225
  """
226
+ specs = index_specs(workout.exercises)
227
+
235
228
  trained = {}
236
229
  for spec in workout.exercises:
237
- logged = logged_for(spec, latest)
230
+ logged = logged_for(spec, latest, specs)
238
231
  if spec.time_based:
239
232
  logged = [entry.as_time() for entry in logged]
240
233
  trained[normalise(spec.garmin_name)] = logged
@@ -259,7 +252,7 @@ def gather_history(
259
252
  for spec in workout.exercises:
260
253
  key = normalise(spec.garmin_name)
261
254
  target = targets.get(key)
262
- logged = logged_for(spec, performed)
255
+ logged = logged_for(spec, performed, specs)
263
256
  if target is None or not logged:
264
257
  continue
265
258
  if spec.time_based:
@@ -425,13 +418,6 @@ def advance_trained( # noqa: PLR0913 - each argument is one independent input
425
418
  sets_payload = session.exercise_sets(activity_id)
426
419
  payload = payloads[garmin_id(workout)]
427
420
 
428
- if options.dump:
429
- dump(
430
- {"sets": sets_payload, "workout": payload},
431
- config.garmin.dump_dir,
432
- activity_id,
433
- )
434
-
435
421
  performed = performed_sets(sets_payload)
436
422
  if not any(performed):
437
423
  logger.warning("No working sets found in that activity; nothing to do.")
@@ -510,6 +496,9 @@ def run_update(
510
496
  # Fetched once and used twice: to find the session each workout was last
511
497
  # trained in, and then to read back the ones before it.
512
498
  activities = session.recent_activities()
499
+ # With caching on, everything below reads sessions off disk, so the disk
500
+ # is brought level with Garmin first. Without it, this does nothing at all.
501
+ cache_activities(session, config, activities)
513
502
  try:
514
503
  sessions = pick_sessions(session, config, options.activity, activities)
515
504
  except ActivityNotFound as exc:
@@ -12,7 +12,7 @@ import sys
12
12
  from collections.abc import Callable
13
13
 
14
14
  from ..app.checking import run_check
15
- from ..app.fetch import run_fetch, run_fetch_exercises
15
+ from ..app.fetch import run_fetch, run_fetch_activities, run_fetch_exercises
16
16
  from ..app.importing import ImportOptions, run_import
17
17
  from ..app.listing import run_list
18
18
  from ..app.logout import run_logout
@@ -23,7 +23,7 @@ from ..errors import ExitCode, UsageError, WorkoutError
23
23
  from ..garmin.client import connect
24
24
  from ..log import configure
25
25
  from .completion import render
26
- from .parser import CATALOG, build_parser
26
+ from .parser import ACTIVITIES, CATALOG, TARGETS, build_parser
27
27
 
28
28
  __all__ = ["main", "build_parser"]
29
29
 
@@ -40,25 +40,53 @@ Handler = Callable[[argparse.Namespace, Config], ExitCode]
40
40
 
41
41
 
42
42
  def _update(args: argparse.Namespace, config: Config) -> ExitCode:
43
- options = UpdateOptions(
44
- apply=args.apply, activity=args.activity, dump=args.dump, push=args.push
45
- )
43
+ options = UpdateOptions(apply=args.apply, activity=args.activity, push=args.push)
46
44
  return run_update(connect(config.garmin), config, options)
47
45
 
48
46
 
49
- def _fetch(args: argparse.Namespace, config: Config) -> ExitCode:
50
- if CATALOG not in args.workout_ids:
51
- return run_fetch(connect(config.garmin), config, args.workout_ids)
52
- if len(args.workout_ids) > 1:
53
- # Refused rather than guessed at: the two downloads share a word and
54
- # nothing else - different source, different destination, and one of
55
- # them needs a login - so doing both from one invocation would be a
56
- # coincidence of spelling, not a feature.
47
+ def _fetch_target(args: argparse.Namespace) -> tuple[str, list[str]]:
48
+ """What `fetch` was asked to download, and which ids narrow it.
49
+
50
+ A target can only be the first word, since it says what the ids after it
51
+ are; argparse rejects anything else there. One appearing later is a mistake
52
+ rather than an id, and is refused rather than downloaded as though it were
53
+ one - which argparse cannot do for us, because the ids after the target are
54
+ numbers it has nothing to check against.
55
+ """
56
+ target, ids = args.target, list(args.ids)
57
+
58
+ stray = next((word for word in ids if word in TARGETS), None)
59
+ if stray:
57
60
  raise UsageError(
58
- f"`fetch {CATALOG}` downloads the exercise catalog and takes no "
59
- f"workout ids. Run it on its own."
61
+ f"`{stray}` says what to download, so it goes first: "
62
+ f"`repwise fetch {stray}`."
60
63
  )
61
- return run_fetch_exercises(config.garmin)
64
+ return target, ids
65
+
66
+
67
+ def _fetch(args: argparse.Namespace, config: Config) -> ExitCode:
68
+ target, ids = _fetch_target(args)
69
+
70
+ if target == CATALOG:
71
+ if ids:
72
+ # Refused rather than guessed at: the catalog shares a command with
73
+ # the other two downloads and nothing else - different source,
74
+ # different destination, and it alone needs no login - so doing
75
+ # both from one invocation would be a coincidence of spelling.
76
+ raise UsageError(
77
+ f"`fetch {CATALOG}` downloads the exercise catalog and takes no "
78
+ f"workout ids. Run it on its own."
79
+ )
80
+ return run_fetch_exercises(config.garmin)
81
+
82
+ # --force is spelled as a session with no cache behind it rather than as a
83
+ # flag the use case reads: a download that must replace what is on disk is
84
+ # one that must not answer from it either, and this way the use case has
85
+ # one path rather than two.
86
+ session = connect(config.garmin, cache=not args.force)
87
+ if target == ACTIVITIES:
88
+ return run_fetch_activities(session, config, ids)
89
+ return run_fetch(session, config, ids)
62
90
 
63
91
 
64
92
  def _list(args: argparse.Namespace, config: Config) -> ExitCode:
@@ -20,8 +20,6 @@ from collections.abc import Callable, Iterable
20
20
  from dataclasses import dataclass
21
21
  from enum import Enum
22
22
 
23
- from .parser import SUGGESTIONS
24
-
25
23
  #: The metavar of an argument that names a file. `parser.py` spells its two
26
24
  #: that way, so this one word is what decides they complete as paths.
27
25
  PATH = "PATH"
@@ -126,22 +124,15 @@ def _options(parser: argparse.ArgumentParser) -> tuple[Option, ...]:
126
124
  )
127
125
 
128
126
 
129
- def _positionals(
130
- command: str, parser: argparse.ArgumentParser
131
- ) -> tuple[Positional, ...]:
127
+ def _positionals(parser: argparse.ArgumentParser) -> tuple[Positional, ...]:
132
128
  found: list[Positional] = []
133
129
  for action in _actions(parser):
134
130
  if action.option_strings:
135
131
  continue
136
- words = tuple(action.choices or ())
137
- if not found:
138
- # A word argparse cannot be told about belongs to the first
139
- # positional, since that is the one that would read it.
140
- words += SUGGESTIONS.get(command, ())
141
132
  found.append(
142
133
  Positional(
143
134
  metavar=_metavar(action),
144
- words=words,
135
+ words=tuple(action.choices or ()),
145
136
  repeated=action.nargs in {"*", "+"},
146
137
  )
147
138
  )
@@ -167,7 +158,7 @@ def _commands(parser: argparse.ArgumentParser) -> tuple[Command, ...]:
167
158
  name=name,
168
159
  help=helps.get(name, ""),
169
160
  options=_options(sub),
170
- positionals=_positionals(name, sub),
161
+ positionals=_positionals(sub),
171
162
  )
172
163
  for name, sub in action.choices.items()
173
164
  )
@@ -14,22 +14,18 @@ from typing import Any
14
14
 
15
15
  from .. import __version__
16
16
 
17
- #: `fetch` takes workout ids, and this word instead of them. Safe as a keyword
18
- #: rather than a flag because a Garmin workout id is a number, so nothing a
19
- #: user could legitimately pass here is ambiguous with it.
17
+ #: What `fetch` can be asked to download, as the first word after it. Safe as
18
+ #: keywords rather than flags because a Garmin id is a number, so no id a user
19
+ #: could legitimately pass is ambiguous with one of these.
20
+ WORKOUTS = "workouts"
21
+ ACTIVITIES = "activities"
20
22
  CATALOG = "exercises"
23
+ TARGETS = (WORKOUTS, ACTIVITIES, CATALOG)
21
24
 
22
25
  #: The shells `completion` knows how to write for. One renderer answers each,
23
26
  #: in `completion.py`.
24
27
  SHELLS = ("bash", "zsh")
25
28
 
26
- #: Words a positional accepts that argparse cannot be told about, keyed by the
27
- #: command whose positional accepts them. `fetch` takes any number of workout
28
- #: ids, so it has no `choices` to enumerate and cannot be given one without
29
- #: rejecting every id there is - but the single word that means something else
30
- #: to it is still worth completing, and this is what says so.
31
- SUGGESTIONS: dict[str, tuple[str, ...]] = {"fetch": (CATALOG,)}
32
-
33
29
 
34
30
  def add_verbose(
35
31
  parser: argparse.ArgumentParser, default: Any = argparse.SUPPRESS
@@ -61,10 +57,10 @@ def build_parser() -> argparse.ArgumentParser:
61
57
  "examples:\n"
62
58
  " repwise update show what a run would change\n"
63
59
  " repwise update --apply write it back to Garmin\n"
64
- " repwise update --dump save the raw Garmin JSON, change nothing\n"
65
60
  " repwise update --apply --push also send them to your watch\n"
66
61
  " repwise update --activity 1234 replay one session you skipped\n"
67
- " repwise fetch download the workout definitions\n"
62
+ " repwise fetch workouts download the workout definitions\n"
63
+ " repwise fetch activities download your recent strength sessions\n"
68
64
  " repwise fetch exercises refresh Garmin's exercise catalog\n"
69
65
  " repwise list show your Garmin workouts and ids\n"
70
66
  " repwise import -o f.yaml build config from Garmin workouts\n"
@@ -135,11 +131,6 @@ def build_parser() -> argparse.ArgumentParser:
135
131
  help="update from this one activity instead of scanning for each "
136
132
  "workout's latest",
137
133
  )
138
- update.add_argument(
139
- "--dump",
140
- action="store_true",
141
- help="also save the raw Garmin JSON payloads to dump_dir",
142
- )
143
134
  update.add_argument(
144
135
  "--push",
145
136
  action="store_true",
@@ -149,23 +140,43 @@ def build_parser() -> argparse.ArgumentParser:
149
140
 
150
141
  fetch = sub.add_parser(
151
142
  "fetch",
152
- help="download workout definitions, or Garmin's exercise catalog",
153
- description="Save workout definitions as JSON into "
154
- "settings.garmin.dump_dir, for inspecting Garmin's payloads or checking "
155
- "connectivity. The single word `exercises` instead of any ids "
156
- "downloads something else entirely: Garmin's list of every exercise it "
157
- "knows and the category each is filed under, cached in "
143
+ help="download workouts, performed sessions, or Garmin's exercise catalog",
144
+ description="Save what Garmin holds as JSON, for inspecting its "
145
+ "payloads by hand or checking connectivity. The first word says which. "
146
+ "`workouts` downloads the definitions this tool writes targets into, "
147
+ "one file each. `activities` downloads the sessions you performed - "
148
+ "the summary, the sets your watch recorded and the workout each was "
149
+ "run against, three files each - and only strength ones, since the "
150
+ "rest hold no sets to read. Both take ids to narrow them, and both "
151
+ "land in settings.garmin.dump_dir; without ids, `workouts` downloads "
152
+ "every workout in your config and `activities` every strength session "
153
+ "within settings.garmin.activity_search_limit. `exercises` downloads "
154
+ "something else entirely: Garmin's list of every exercise it knows and "
155
+ "the category each is filed under, cached in "
158
156
  "settings.garmin.token_store and read by `check` to tell a real "
159
157
  "exercise name from a plausible-looking one. `check` downloads that "
160
158
  "itself the first time it needs it, so this is how you refresh a copy "
161
159
  "that has gone stale, not something to run first.",
162
160
  )
163
161
  fetch.add_argument(
164
- "workout_ids",
162
+ "target",
163
+ choices=TARGETS,
164
+ metavar="TARGET",
165
+ help=f"`{WORKOUTS}`, `{ACTIVITIES}` or `{CATALOG}`",
166
+ )
167
+ fetch.add_argument(
168
+ "ids",
165
169
  nargs="*",
166
170
  metavar="ID",
167
- help="workout ids; defaults to every workout in the config. Or the "
168
- "single word `exercises` to refresh the exercise catalog",
171
+ help="which workouts or activities to download; defaults to every "
172
+ "workout in the config, or every strength session found",
173
+ )
174
+ fetch.add_argument(
175
+ "--force",
176
+ action="store_true",
177
+ help=f"download sessions already in dump_dir again, rather than "
178
+ f"leaving them alone. Only means anything for `{ACTIVITIES}`, and "
179
+ "only with settings.garmin.activity_caching on",
169
180
  )
170
181
  add_verbose(fetch)
171
182