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 ADDED
@@ -0,0 +1,4 @@
1
+ """Advance Garmin strength workout targets using double progression."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "1.0.0"
repwise/__main__.py ADDED
@@ -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
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
@@ -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}")