repwise 1.0.0__py3-none-any.whl
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/__init__.py +4 -0
- repwise/__main__.py +7 -0
- repwise/app/__init__.py +7 -0
- repwise/app/checking.py +109 -0
- repwise/app/fetch.py +58 -0
- repwise/app/importing.py +72 -0
- repwise/app/listing.py +35 -0
- repwise/app/report.py +170 -0
- repwise/app/update.py +642 -0
- repwise/checker.py +287 -0
- repwise/cli/__init__.py +105 -0
- repwise/cli/parser.py +201 -0
- repwise/config.py +427 -0
- repwise/domain/__init__.py +6 -0
- repwise/domain/effort.py +199 -0
- repwise/domain/matching.py +83 -0
- repwise/domain/models.py +138 -0
- repwise/domain/progression.py +404 -0
- repwise/errors.py +79 -0
- repwise/garmin/__init__.py +1 -0
- repwise/garmin/catalog.py +185 -0
- repwise/garmin/client.py +247 -0
- repwise/garmin/payloads.py +705 -0
- repwise/importer.py +265 -0
- repwise/log.py +72 -0
- repwise/planner.py +968 -0
- repwise/yamlio.py +102 -0
- repwise-1.0.0.dist-info/METADATA +197 -0
- repwise-1.0.0.dist-info/RECORD +33 -0
- repwise-1.0.0.dist-info/WHEEL +5 -0
- repwise-1.0.0.dist-info/entry_points.txt +2 -0
- repwise-1.0.0.dist-info/licenses/LICENSE +21 -0
- repwise-1.0.0.dist-info/top_level.txt +1 -0
repwise/__init__.py
ADDED
repwise/__main__.py
ADDED
repwise/app/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The use cases: one module per command, and the report they print.
|
|
2
|
+
|
|
3
|
+
Each `run_*` function is the whole of a command apart from parsing and
|
|
4
|
+
dispatch. It is handed the things it needs - a Garmin session, a config, its
|
|
5
|
+
options - rather than building them, so nothing here imports argparse and
|
|
6
|
+
nothing here decides what a process exits with beyond returning it.
|
|
7
|
+
"""
|
repwise/app/checking.py
ADDED
|
@@ -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
|
repwise/app/fetch.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Download what Garmin holds as JSON: your workouts, or its exercise catalog."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from ..domain.models import Config, GarminSettings
|
|
8
|
+
from ..errors import ExitCode, GarminError
|
|
9
|
+
from ..garmin import catalog
|
|
10
|
+
from ..garmin.client import GarminSession
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_fetch_exercises(settings: GarminSettings) -> ExitCode:
|
|
16
|
+
"""Download Garmin's exercise catalog, replacing any cached copy.
|
|
17
|
+
|
|
18
|
+
Unconditional, unlike the first-run download `check` does for itself:
|
|
19
|
+
asking for the catalog by name is how you refresh one that has gone stale,
|
|
20
|
+
so finding a copy already there is not a reason to stop.
|
|
21
|
+
|
|
22
|
+
No session is opened. The catalog is public, and requiring a login to
|
|
23
|
+
download it would be a password prompt in exchange for nothing.
|
|
24
|
+
"""
|
|
25
|
+
payload = catalog.download()
|
|
26
|
+
# Parsed for the count, and to fail before overwriting a good cache with a
|
|
27
|
+
# response that turned out not to be a catalog at all.
|
|
28
|
+
parsed = catalog.ExerciseCatalog.parse(payload)
|
|
29
|
+
path = catalog.save(settings, payload)
|
|
30
|
+
logger.info(
|
|
31
|
+
f"Saved {len(parsed)} exercises in {len(parsed.categories)} "
|
|
32
|
+
f"categories -> {path}"
|
|
33
|
+
)
|
|
34
|
+
return ExitCode.OK
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_fetch(
|
|
38
|
+
session: GarminSession, config: Config, workout_ids: list[str] | None = None
|
|
39
|
+
) -> ExitCode:
|
|
40
|
+
# A workout Garmin does not hold yet has no definition to download, so it
|
|
41
|
+
# is simply not among the ids rather than a failure to report.
|
|
42
|
+
ids = workout_ids or [w.garmin_workout_id for w in config if w.garmin_workout_id]
|
|
43
|
+
failed = False
|
|
44
|
+
for workout_id in ids:
|
|
45
|
+
try:
|
|
46
|
+
payload = session.workout(workout_id)
|
|
47
|
+
except GarminError as exc:
|
|
48
|
+
# One unreachable workout should not cost the user the others.
|
|
49
|
+
logger.error(f"FAILED {workout_id}: {exc}")
|
|
50
|
+
failed = True
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
path = os.path.join(config.garmin.dump_dir, f"workout-{workout_id}.json")
|
|
54
|
+
with open(path, "w") as fh:
|
|
55
|
+
json.dump(payload, fh, indent=2)
|
|
56
|
+
logger.info(f"Saved {payload.get('workoutName', '(unnamed)')} -> {path}")
|
|
57
|
+
|
|
58
|
+
return ExitCode.NOTHING_USABLE if failed else ExitCode.OK
|
repwise/app/importing.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Turn workouts built in Garmin Connect into workouts.yaml content."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..errors import ActivityNotFound, ExitCode, UsageError
|
|
9
|
+
from ..garmin.client import STRENGTH, GarminSession
|
|
10
|
+
from ..importer import describe_workout, render_config
|
|
11
|
+
from ..yamlio import write
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ImportOptions:
|
|
18
|
+
"""Which workouts to import, and where the result goes."""
|
|
19
|
+
|
|
20
|
+
name: str | None = None
|
|
21
|
+
id: str | None = None
|
|
22
|
+
output: str | None = None
|
|
23
|
+
force: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def select(session: GarminSession, options: ImportOptions) -> list[dict[str, Any]]:
|
|
27
|
+
"""The workout summaries an import should cover."""
|
|
28
|
+
workouts = session.list_workouts(sport_type=STRENGTH)
|
|
29
|
+
if options.id:
|
|
30
|
+
wanted = [w for w in workouts if str(w.get("workoutId")) == options.id]
|
|
31
|
+
if not wanted:
|
|
32
|
+
raise ActivityNotFound(f"No strength workout with id {options.id}.")
|
|
33
|
+
return wanted
|
|
34
|
+
if options.name:
|
|
35
|
+
needle = options.name.lower()
|
|
36
|
+
wanted = [w for w in workouts if needle in (w.get("workoutName") or "").lower()]
|
|
37
|
+
if not wanted:
|
|
38
|
+
names = ", ".join(repr(w.get("workoutName")) for w in workouts)
|
|
39
|
+
raise ActivityNotFound(
|
|
40
|
+
f"No strength workout matching {options.name!r}. Found: {names}"
|
|
41
|
+
)
|
|
42
|
+
return wanted
|
|
43
|
+
return workouts
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_import(session: GarminSession, options: ImportOptions) -> ExitCode:
|
|
47
|
+
summaries = select(session, options)
|
|
48
|
+
|
|
49
|
+
imported = [
|
|
50
|
+
describe_workout(session.workout(str(s["workoutId"]))) for s in summaries
|
|
51
|
+
]
|
|
52
|
+
text = render_config(imported)
|
|
53
|
+
|
|
54
|
+
if not options.output:
|
|
55
|
+
# Config content, not a report: written straight out so that it stays
|
|
56
|
+
# redirectable and never picks up a log prefix.
|
|
57
|
+
print(text)
|
|
58
|
+
return ExitCode.OK
|
|
59
|
+
|
|
60
|
+
if os.path.exists(options.output) and not options.force:
|
|
61
|
+
raise UsageError(
|
|
62
|
+
f"{options.output} already exists. Pass --force to overwrite it."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
write(options.output, text)
|
|
66
|
+
|
|
67
|
+
exercises = sum(len(w.exercises) for w in imported)
|
|
68
|
+
logger.info(
|
|
69
|
+
f"Wrote {len(imported)} workout(s), {exercises} exercises -> {options.output}"
|
|
70
|
+
)
|
|
71
|
+
logger.info("Check the TODO comments before using it.")
|
|
72
|
+
return ExitCode.OK
|
repwise/app/listing.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Show the Garmin workouts in the account, with the ids workouts.yaml needs."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from ..domain.models import Config
|
|
6
|
+
from ..errors import ExitCode
|
|
7
|
+
from ..garmin.client import STRENGTH, GarminSession
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_list(
|
|
13
|
+
session: GarminSession, config: Config, every_sport: bool = False
|
|
14
|
+
) -> ExitCode:
|
|
15
|
+
workouts = session.list_workouts(sport_type=None if every_sport else STRENGTH)
|
|
16
|
+
|
|
17
|
+
if not workouts:
|
|
18
|
+
logger.warning("No workouts found.")
|
|
19
|
+
return ExitCode.NOTHING_USABLE
|
|
20
|
+
|
|
21
|
+
known = {w.garmin_workout_id for w in config if w.garmin_workout_id}
|
|
22
|
+
logger.info(f"{'ID':<12} {'UPDATED':<11} {'':<3}NAME")
|
|
23
|
+
for entry in workouts:
|
|
24
|
+
workout_id = str(entry.get("workoutId"))
|
|
25
|
+
updated = (entry.get("updateDate") or "")[:10]
|
|
26
|
+
mark = "*" if workout_id in known else " "
|
|
27
|
+
name = entry.get("workoutName") or "(unnamed)"
|
|
28
|
+
if every_sport:
|
|
29
|
+
kind = (entry.get("sportType") or {}).get("sportTypeKey", "?")
|
|
30
|
+
name = f"{name} [{kind}]"
|
|
31
|
+
logger.info(f"{workout_id:<12} {updated:<11} {mark:<3}{name}")
|
|
32
|
+
|
|
33
|
+
logger.info("")
|
|
34
|
+
logger.info(f"{len(workouts)} workout(s); * already in your config")
|
|
35
|
+
return ExitCode.OK
|
repwise/app/report.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""How a change, a plan and a finding are printed.
|
|
2
|
+
|
|
3
|
+
Report lines go out through the standard library logger, the way `log.py`
|
|
4
|
+
describes: INFO is the report the user asked for and lands on stdout, WARNING
|
|
5
|
+
and above are problems and land on stderr. A use case therefore emits its
|
|
6
|
+
report without knowing where the report goes - only `main()` decides that.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from ..domain.models import ExerciseSpec
|
|
12
|
+
from ..domain.progression import Target
|
|
13
|
+
from ..planner import (
|
|
14
|
+
Change,
|
|
15
|
+
GapChange,
|
|
16
|
+
NoteChange,
|
|
17
|
+
Plan,
|
|
18
|
+
RestChange,
|
|
19
|
+
SetChange,
|
|
20
|
+
SkipChange,
|
|
21
|
+
StructureChange,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
#: How `check` shows a finding: the marker that survives a plain run, and the
|
|
27
|
+
#: level it is logged at, so severity outlives a redirect of stdout too. Both
|
|
28
|
+
#: levels are things to go and fix - an error stops the exercise working at
|
|
29
|
+
#: all, a warning means it works by luck - and both fail the command.
|
|
30
|
+
SEVERITY = {
|
|
31
|
+
"error": ("!!", logging.ERROR),
|
|
32
|
+
"warning": (" !", logging.WARNING),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#: How wide the before and after columns are. Enough for a target written out
|
|
36
|
+
#: set by set - `9,9,8,8 x 30 kg` - so that a ramp does not push every reason
|
|
37
|
+
#: beside it out of line.
|
|
38
|
+
COLUMN = 17
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def describe(spec: ExerciseSpec, target: Target) -> str:
|
|
42
|
+
"""Render a target the way the exercise is actually measured.
|
|
43
|
+
|
|
44
|
+
A ramped target is written out set by set - `9,9,8,8` - because the whole
|
|
45
|
+
point of it is that the sets differ, and a single figure could only ever
|
|
46
|
+
name one of them.
|
|
47
|
+
"""
|
|
48
|
+
figure = target.spread(spec.sets, spec.rep_step)
|
|
49
|
+
if spec.time_based:
|
|
50
|
+
return f"{figure} s"
|
|
51
|
+
if spec.bodyweight:
|
|
52
|
+
return f"{figure} reps"
|
|
53
|
+
return f"{figure} x {target.weight:g} kg"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def report_change(change: Change, force_flag: str | None = None) -> None:
|
|
57
|
+
flag = force_flag or ("*" if change.moved else " ")
|
|
58
|
+
logger.info(
|
|
59
|
+
f"{flag} {change.spec.name:<40}"
|
|
60
|
+
f" {describe(change.spec, change.old):>{COLUMN}}"
|
|
61
|
+
f" -> {describe(change.spec, change.new):<{COLUMN}} ({change.reason})"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def report_prescribed(name: str, old: str, new: str, source: str) -> None:
|
|
66
|
+
"""A number workouts.yaml moved, in the same columns as a target.
|
|
67
|
+
|
|
68
|
+
Shown like a target rather than hidden like a note: these change how the
|
|
69
|
+
workout is performed, and are on the watch from the next sync.
|
|
70
|
+
"""
|
|
71
|
+
logger.info(f"* {name:<40} {old:>{COLUMN}} -> {new:<{COLUMN}} ({source})")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def report_note(change: NoteChange) -> None:
|
|
75
|
+
"""The one-line note the watch shows, which workouts.yaml also decides.
|
|
76
|
+
|
|
77
|
+
Shown rather than hidden because a config edit that only touches the
|
|
78
|
+
programming - a rep range, a weight step - moves no target at all, and the
|
|
79
|
+
run would otherwise say every exercise is up to date while still having a
|
|
80
|
+
reason to write.
|
|
81
|
+
"""
|
|
82
|
+
report_prescribed(
|
|
83
|
+
change.spec.name,
|
|
84
|
+
change.old or "no note",
|
|
85
|
+
change.new,
|
|
86
|
+
"note from workouts.yaml",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def report_rest(change: RestChange) -> None:
|
|
91
|
+
report_prescribed(
|
|
92
|
+
change.spec.name,
|
|
93
|
+
f"{change.old} s rest",
|
|
94
|
+
f"{change.new} s rest",
|
|
95
|
+
"rest from workouts.yaml",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def report_sets(change: SetChange) -> None:
|
|
100
|
+
report_prescribed(
|
|
101
|
+
change.spec.name,
|
|
102
|
+
f"{change.old} sets",
|
|
103
|
+
f"{change.new} sets",
|
|
104
|
+
"sets from workouts.yaml",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def report_skips(change: SkipChange) -> None:
|
|
109
|
+
"""A group that had been dropping the rest after its final set."""
|
|
110
|
+
report_prescribed(
|
|
111
|
+
change.spec.name,
|
|
112
|
+
"no last rest",
|
|
113
|
+
"rest after every set",
|
|
114
|
+
"was skipping the last rest",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def report_gaps(change: GapChange) -> None:
|
|
119
|
+
"""The rest between exercises: one line for the workout, not one per gap."""
|
|
120
|
+
report_prescribed(
|
|
121
|
+
"Between exercises",
|
|
122
|
+
change.before,
|
|
123
|
+
f"{change.new} s rest",
|
|
124
|
+
f"{change.gaps} gap(s), from workouts.yaml",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
#: The marker each kind of structural change prints under. Deliberately not
|
|
129
|
+
#: `*`: these change what the workout is, not what it asks of you.
|
|
130
|
+
STRUCTURE = {"added": "+", "removed": "-", "moved": "~"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def report_structure(change: StructureChange) -> None:
|
|
134
|
+
"""An exercise the config added, dropped, or put somewhere else."""
|
|
135
|
+
if change.kind == "added" and change.spec and change.target:
|
|
136
|
+
detail = (
|
|
137
|
+
f"new at position {change.position}, "
|
|
138
|
+
f"{change.spec.sets} x {describe(change.spec, change.target)}"
|
|
139
|
+
)
|
|
140
|
+
elif change.kind == "removed":
|
|
141
|
+
detail = "removed: no longer in workouts.yaml"
|
|
142
|
+
else:
|
|
143
|
+
detail = f"moved to position {change.position}"
|
|
144
|
+
|
|
145
|
+
logger.info(f"{STRUCTURE.get(change.kind, ' ')} {change.name:<40} {detail}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def report_plan(plan: Plan, force_flag: str | None = None) -> None:
|
|
149
|
+
# Structure first: what a workout holds has to make sense before what each
|
|
150
|
+
# of its exercises is asking for does.
|
|
151
|
+
for shape in plan.structure:
|
|
152
|
+
report_structure(shape)
|
|
153
|
+
for change in plan.changes:
|
|
154
|
+
report_change(change, force_flag)
|
|
155
|
+
for count in plan.sets:
|
|
156
|
+
report_sets(count)
|
|
157
|
+
for rest in plan.rests:
|
|
158
|
+
report_rest(rest)
|
|
159
|
+
for skip in plan.skips:
|
|
160
|
+
report_skips(skip)
|
|
161
|
+
if plan.gaps:
|
|
162
|
+
report_gaps(plan.gaps)
|
|
163
|
+
# Last of the config-driven lines, as in the closing summary: a note says
|
|
164
|
+
# how an exercise is programmed rather than what it asks of you today.
|
|
165
|
+
for note in plan.notes:
|
|
166
|
+
report_note(note)
|
|
167
|
+
for warning in plan.warnings:
|
|
168
|
+
# The marker survives the move to logging: it still sets a warning
|
|
169
|
+
# apart when the level itself is not shown.
|
|
170
|
+
logger.warning(f" ! {warning}")
|