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.
- {repwise-1.2.0/src/repwise.egg-info → repwise-2.0.0}/PKG-INFO +4 -10
- {repwise-1.2.0 → repwise-2.0.0}/README.md +2 -1
- {repwise-1.2.0 → repwise-2.0.0}/pyproject.toml +22 -8
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/__init__.py +1 -1
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/checking.py +15 -1
- repwise-2.0.0/src/repwise/app/fetch.py +203 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/update.py +9 -20
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/cli/__init__.py +44 -16
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/cli/completion.py +3 -12
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/cli/parser.py +37 -26
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/config.py +76 -36
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/domain/matching.py +29 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/domain/models.py +13 -1
- repwise-2.0.0/src/repwise/dumps.py +265 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/errors.py +27 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/garmin/client.py +203 -14
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/garmin/payloads.py +24 -3
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/importer.py +4 -4
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/planner.py +36 -13
- {repwise-1.2.0 → repwise-2.0.0/src/repwise.egg-info}/PKG-INFO +4 -10
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise.egg-info/SOURCES.txt +3 -0
- repwise-2.0.0/src/repwise.egg-info/requires.txt +2 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_checking.py +21 -1
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_cli.py +29 -13
- repwise-2.0.0/tests/test_client.py +735 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_completion.py +1 -1
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_config.py +133 -52
- repwise-2.0.0/tests/test_dumps.py +310 -0
- repwise-2.0.0/tests/test_fetch.py +360 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_main.py +65 -13
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_payloads.py +11 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_planner.py +93 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_update.py +53 -3
- repwise-1.2.0/src/repwise/app/fetch.py +0 -58
- repwise-1.2.0/src/repwise.egg-info/requires.txt +0 -10
- repwise-1.2.0/tests/test_client.py +0 -361
- {repwise-1.2.0 → repwise-2.0.0}/LICENSE +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/setup.cfg +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/__main__.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/__init__.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/importing.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/listing.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/logout.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/app/report.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/checker.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/domain/__init__.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/domain/effort.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/domain/progression.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/garmin/__init__.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/garmin/catalog.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/log.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise/yamlio.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise.egg-info/dependency_links.txt +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise.egg-info/entry_points.txt +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/src/repwise.egg-info/top_level.txt +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_catalog.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_checker.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_effort.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_importer.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_log.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_logout.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_matching.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_models.py +0 -0
- {repwise-1.2.0 → repwise-2.0.0}/tests/test_progression.py +0 -0
- {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:
|
|
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.
|
|
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 = "
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
56
|
-
"mypy==2.3.
|
|
57
|
-
"pre-commit==4.6.
|
|
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.
|
|
60
|
-
"types-PyYAML==6.0.12.
|
|
73
|
+
"ruff==0.16.3",
|
|
74
|
+
"types-PyYAML==6.0.12.20260815",
|
|
61
75
|
]
|
|
62
76
|
|
|
63
77
|
[project.scripts]
|
|
@@ -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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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"`
|
|
59
|
-
f"
|
|
61
|
+
f"`{stray}` says what to download, so it goes first: "
|
|
62
|
+
f"`repwise fetch {stray}`."
|
|
60
63
|
)
|
|
61
|
-
return
|
|
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=
|
|
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(
|
|
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`
|
|
18
|
-
#: rather than
|
|
19
|
-
#:
|
|
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
|
|
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
|
|
153
|
-
description="Save
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
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
|
-
"
|
|
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="
|
|
168
|
-
"
|
|
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
|
|