fitdocs 0.1.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.
- fitdocs/__init__.py +125 -0
- fitdocs/agentskill.py +105 -0
- fitdocs/athlete.py +183 -0
- fitdocs/audit.py +491 -0
- fitdocs/benchmarks.py +692 -0
- fitdocs/citation.py +199 -0
- fitdocs/cli.py +1627 -0
- fitdocs/config.py +146 -0
- fitdocs/contract.py +1155 -0
- fitdocs/declaration.py +593 -0
- fitdocs/docio.py +95 -0
- fitdocs/docmerge.py +220 -0
- fitdocs/history/__init__.py +68 -0
- fitdocs/history/documents.py +223 -0
- fitdocs/history/engine.py +462 -0
- fitdocs/history/model.py +135 -0
- fitdocs/history/page.py +669 -0
- fitdocs/history/series.py +738 -0
- fitdocs/history/settings.py +268 -0
- fitdocs/history/sources.py +463 -0
- fitdocs/inbox.py +845 -0
- fitdocs/ingest/__init__.py +133 -0
- fitdocs/ingest/_fields.py +89 -0
- fitdocs/ingest/decode.py +105 -0
- fitdocs/ingest/errors.py +29 -0
- fitdocs/ingest/laps.py +171 -0
- fitdocs/ingest/records.py +164 -0
- fitdocs/ingest/sets.py +183 -0
- fitdocs/ingest/sport.py +115 -0
- fitdocs/ingest/summary.py +418 -0
- fitdocs/layout.py +453 -0
- fitdocs/load/__init__.py +108 -0
- fitdocs/load/arbitrate.py +187 -0
- fitdocs/load/channels/__init__.py +113 -0
- fitdocs/load/channels/grade.py +267 -0
- fitdocs/load/channels/heart_rate.py +292 -0
- fitdocs/load/channels/pace.py +277 -0
- fitdocs/load/channels/power.py +169 -0
- fitdocs/load/channels/sources.py +436 -0
- fitdocs/load/channels/sufficiency.py +150 -0
- fitdocs/load/channels/types.py +214 -0
- fitdocs/load/channels/weighting.py +165 -0
- fitdocs/load/docedit.py +407 -0
- fitdocs/load/engine.py +677 -0
- fitdocs/load/priority.py +66 -0
- fitdocs/load/profile.py +1008 -0
- fitdocs/load/prompts.py +460 -0
- fitdocs/load/qa/__init__.py +57 -0
- fitdocs/load/qa/cadence.py +348 -0
- fitdocs/load/qa/divergence.py +187 -0
- fitdocs/load/qa/drift.py +113 -0
- fitdocs/load/qa/flags.py +281 -0
- fitdocs/load/qa/sources.py +324 -0
- fitdocs/load/qa/staleness.py +147 -0
- fitdocs/load/qa/types.py +141 -0
- fitdocs/load/registry.py +196 -0
- fitdocs/load/render.py +468 -0
- fitdocs/load/settings.py +546 -0
- fitdocs/load/threshold/__init__.py +23 -0
- fitdocs/load/threshold/anchors.py +168 -0
- fitdocs/load/threshold/calculator.py +768 -0
- fitdocs/load/threshold/discipline.py +117 -0
- fitdocs/load/threshold/selection.py +140 -0
- fitdocs/load/types.py +451 -0
- fitdocs/metrics/__init__.py +160 -0
- fitdocs/metrics/aggregates.py +393 -0
- fitdocs/metrics/power.py +364 -0
- fitdocs/metrics/sources.py +745 -0
- fitdocs/metrics/stress.py +203 -0
- fitdocs/metrics/types.py +164 -0
- fitdocs/metrics/zones.py +75 -0
- fitdocs/model.py +241 -0
- fitdocs/performance/__init__.py +26 -0
- fitdocs/performance/derive.py +787 -0
- fitdocs/performance/engine.py +556 -0
- fitdocs/performance/models.py +159 -0
- fitdocs/performance/sources.py +665 -0
- fitdocs/performance/types.py +108 -0
- fitdocs/plans/__init__.py +221 -0
- fitdocs/plans/aggregate.py +163 -0
- fitdocs/plans/block_page.py +351 -0
- fitdocs/plans/corpus.py +206 -0
- fitdocs/plans/engine.py +577 -0
- fitdocs/plans/matching.py +395 -0
- fitdocs/plans/model.py +750 -0
- fitdocs/plans/page.py +324 -0
- fitdocs/plans/placement.py +521 -0
- fitdocs/plans/planned_page.py +113 -0
- fitdocs/plans/reconcile.py +228 -0
- fitdocs/plans/resolution.py +94 -0
- fitdocs/plans/settings.py +175 -0
- fitdocs/plans/source.py +1130 -0
- fitdocs/plugins.py +657 -0
- fitdocs/py.typed +0 -0
- fitdocs/quarantine.py +245 -0
- fitdocs/render/__init__.py +153 -0
- fitdocs/render/charts/__init__.py +9 -0
- fitdocs/render/charts/calendar.py +482 -0
- fitdocs/render/charts/hero.py +421 -0
- fitdocs/render/charts/map.py +462 -0
- fitdocs/render/charts/palette.py +190 -0
- fitdocs/render/charts/series.py +155 -0
- fitdocs/render/charts/svg.py +130 -0
- fitdocs/render/charts/zones.py +201 -0
- fitdocs/render/format.py +106 -0
- fitdocs/render/frontmatter.py +159 -0
- fitdocs/render/sections.py +618 -0
- fitdocs/render/splits.py +340 -0
- fitdocs/render/strength.py +180 -0
- fitdocs/render/views.py +245 -0
- fitdocs/settings.py +71 -0
- fitdocs/skills/build-training-block/SKILL.md +249 -0
- fitdocs/skills/build-training-block/example-block.toml +105 -0
- fitdocs/skills/fitdocs-workouts/SKILL.md +89 -0
- fitdocs/sync.py +1515 -0
- fitdocs/tiles.py +438 -0
- fitdocs/version.py +64 -0
- fitdocs-0.1.0.dist-info/METADATA +249 -0
- fitdocs-0.1.0.dist-info/RECORD +122 -0
- fitdocs-0.1.0.dist-info/WHEEL +4 -0
- fitdocs-0.1.0.dist-info/entry_points.txt +2 -0
- fitdocs-0.1.0.dist-info/licenses/LICENSE +21 -0
fitdocs/__init__.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""fitdocs: a pure library that decodes ``.fit`` files into a typed activity
|
|
2
|
+
model and computes derived metrics.
|
|
3
|
+
|
|
4
|
+
The package root is the top-level composition point -- not a layer -- so it
|
|
5
|
+
publishes exactly one stable public API drawn from *both*
|
|
6
|
+
:mod:`fitdocs.ingest` and :mod:`fitdocs.metrics`:
|
|
7
|
+
|
|
8
|
+
- the two entry points, :func:`~fitdocs.ingest.parse_fit` (bytes/path ->
|
|
9
|
+
:class:`~fitdocs.model.Activity`) and
|
|
10
|
+
:func:`~fitdocs.metrics.compute_metrics` (Activity + athlete inputs ->
|
|
11
|
+
:class:`~fitdocs.metrics.types.DerivedMetrics`);
|
|
12
|
+
- the activity-model types plus the ``SCHEMA_VERSION`` / ``fit_datetime``
|
|
13
|
+
helpers (:mod:`fitdocs.model`);
|
|
14
|
+
- the metric-input and result contracts (:mod:`fitdocs.metrics.types`),
|
|
15
|
+
including :class:`~fitdocs.metrics.types.TrimpWeighting` -- a caller has no
|
|
16
|
+
type-checked way to state a training-impulse weighting selection (Req 17.1)
|
|
17
|
+
without importing the type that names it;
|
|
18
|
+
- the decode error taxonomy (:mod:`fitdocs.ingest.errors`).
|
|
19
|
+
|
|
20
|
+
**Re-exports are lazy** (PEP 562 module ``__getattr__``): the names above are
|
|
21
|
+
resolved from their defining submodule only on first access, and the resolution
|
|
22
|
+
always reads a concrete submodule, never the ``fitdocs`` package name, so no
|
|
23
|
+
circular import can arise. Laziness is deliberate, not incidental -- it keeps
|
|
24
|
+
``import fitdocs.model`` free of the ``garmin-fit-sdk`` (which
|
|
25
|
+
:mod:`fitdocs.ingest` imports): the model stays consumable with zero FIT-format
|
|
26
|
+
knowledge (Req 2.6), and the SDK loads only when a caller actually reaches for
|
|
27
|
+
:func:`parse_fit`. Static tooling still sees fully typed re-exports via the
|
|
28
|
+
``TYPE_CHECKING`` block; ``__all__`` lists the complete public surface.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import importlib
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
# Statically-visible, fully typed re-exports for type checkers and IDEs.
|
|
38
|
+
# The redundant ``as`` aliases mark each name as an explicit re-export.
|
|
39
|
+
from fitdocs.ingest import parse_fit as parse_fit
|
|
40
|
+
from fitdocs.ingest.errors import FitDecodeError as FitDecodeError
|
|
41
|
+
from fitdocs.ingest.errors import FitIntegrityError as FitIntegrityError
|
|
42
|
+
from fitdocs.ingest.errors import NotFitFileError as NotFitFileError
|
|
43
|
+
from fitdocs.metrics import compute_metrics as compute_metrics
|
|
44
|
+
from fitdocs.metrics.types import AthleteInputs as AthleteInputs
|
|
45
|
+
from fitdocs.metrics.types import DerivedMetrics as DerivedMetrics
|
|
46
|
+
from fitdocs.metrics.types import TrimpWeighting as TrimpWeighting
|
|
47
|
+
from fitdocs.metrics.types import ZoneSpec as ZoneSpec
|
|
48
|
+
from fitdocs.model import SCHEMA_VERSION as SCHEMA_VERSION
|
|
49
|
+
from fitdocs.model import Activity as Activity
|
|
50
|
+
from fitdocs.model import DeviceInfo as DeviceInfo
|
|
51
|
+
from fitdocs.model import Lap as Lap
|
|
52
|
+
from fitdocs.model import Modality as Modality
|
|
53
|
+
from fitdocs.model import Provenance as Provenance
|
|
54
|
+
from fitdocs.model import Samples as Samples
|
|
55
|
+
from fitdocs.model import SessionSummary as SessionSummary
|
|
56
|
+
from fitdocs.model import Sport as Sport
|
|
57
|
+
from fitdocs.model import StrengthSet as StrengthSet
|
|
58
|
+
from fitdocs.model import fit_datetime as fit_datetime
|
|
59
|
+
|
|
60
|
+
# Public name -> (defining submodule, attribute) for lazy runtime resolution.
|
|
61
|
+
_LAZY_EXPORTS: dict[str, tuple[str, str]] = {
|
|
62
|
+
"parse_fit": ("fitdocs.ingest", "parse_fit"),
|
|
63
|
+
"compute_metrics": ("fitdocs.metrics", "compute_metrics"),
|
|
64
|
+
"Activity": ("fitdocs.model", "Activity"),
|
|
65
|
+
"Samples": ("fitdocs.model", "Samples"),
|
|
66
|
+
"SessionSummary": ("fitdocs.model", "SessionSummary"),
|
|
67
|
+
"Lap": ("fitdocs.model", "Lap"),
|
|
68
|
+
"StrengthSet": ("fitdocs.model", "StrengthSet"),
|
|
69
|
+
"DeviceInfo": ("fitdocs.model", "DeviceInfo"),
|
|
70
|
+
"Provenance": ("fitdocs.model", "Provenance"),
|
|
71
|
+
"Sport": ("fitdocs.model", "Sport"),
|
|
72
|
+
"Modality": ("fitdocs.model", "Modality"),
|
|
73
|
+
"SCHEMA_VERSION": ("fitdocs.model", "SCHEMA_VERSION"),
|
|
74
|
+
"fit_datetime": ("fitdocs.model", "fit_datetime"),
|
|
75
|
+
"AthleteInputs": ("fitdocs.metrics.types", "AthleteInputs"),
|
|
76
|
+
"ZoneSpec": ("fitdocs.metrics.types", "ZoneSpec"),
|
|
77
|
+
"DerivedMetrics": ("fitdocs.metrics.types", "DerivedMetrics"),
|
|
78
|
+
"TrimpWeighting": ("fitdocs.metrics.types", "TrimpWeighting"),
|
|
79
|
+
"FitDecodeError": ("fitdocs.ingest.errors", "FitDecodeError"),
|
|
80
|
+
"NotFitFileError": ("fitdocs.ingest.errors", "NotFitFileError"),
|
|
81
|
+
"FitIntegrityError": ("fitdocs.ingest.errors", "FitIntegrityError"),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"SCHEMA_VERSION",
|
|
86
|
+
"Activity",
|
|
87
|
+
"AthleteInputs",
|
|
88
|
+
"DerivedMetrics",
|
|
89
|
+
"DeviceInfo",
|
|
90
|
+
"FitDecodeError",
|
|
91
|
+
"FitIntegrityError",
|
|
92
|
+
"Lap",
|
|
93
|
+
"Modality",
|
|
94
|
+
"NotFitFileError",
|
|
95
|
+
"Provenance",
|
|
96
|
+
"Samples",
|
|
97
|
+
"SessionSummary",
|
|
98
|
+
"Sport",
|
|
99
|
+
"StrengthSet",
|
|
100
|
+
"TrimpWeighting",
|
|
101
|
+
"ZoneSpec",
|
|
102
|
+
"compute_metrics",
|
|
103
|
+
"fit_datetime",
|
|
104
|
+
"parse_fit",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def __getattr__(name: str) -> object:
|
|
109
|
+
"""Resolve a public re-export lazily from its defining submodule (PEP 562).
|
|
110
|
+
|
|
111
|
+
The resolved object is cached back onto the module globals so later access
|
|
112
|
+
skips this hook. Unknown names raise :class:`AttributeError` as usual.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
module_name, attr = _LAZY_EXPORTS[name]
|
|
116
|
+
except KeyError:
|
|
117
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
118
|
+
value = getattr(importlib.import_module(module_name), attr)
|
|
119
|
+
globals()[name] = value # cache: subsequent lookups bypass __getattr__
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def __dir__() -> list[str]:
|
|
124
|
+
"""Include the lazy public names in ``dir(fitdocs)``."""
|
|
125
|
+
return sorted(set(globals()) | set(_LAZY_EXPORTS))
|
fitdocs/agentskill.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The packaged-skill registry and by-name locator (design: AgentSkillLocator).
|
|
2
|
+
|
|
3
|
+
fitdocs ships one or more "agent skills" -- self-contained markdown
|
|
4
|
+
directories an LLM agent copies into its own skills directory -- inside the
|
|
5
|
+
installed package under :data:`SKILLS_DIR`. This module is the *one*
|
|
6
|
+
mechanism every packaged skill is located through: a declared registry of
|
|
7
|
+
names (:data:`PACKAGED_SKILLS`), not a directory scan, so a skill can never
|
|
8
|
+
silently fall out of the wheel while a scan-based listing still looks
|
|
9
|
+
"correct". The CLI's ``skill`` command and every test that needs a skill's
|
|
10
|
+
installed location resolve it here.
|
|
11
|
+
|
|
12
|
+
Two skills are registered today: :data:`BLOCK_SKILL_NAME`
|
|
13
|
+
(``build-training-block``, an athlete-facing plan-authoring workflow) and
|
|
14
|
+
:data:`INBOX_SKILL_NAME` (``fitdocs-workouts``, the turnkey inbox-drain
|
|
15
|
+
workflow for an LLM-managed wiki, distribution task 4.2). Both live under
|
|
16
|
+
:data:`PACKAGED_SKILLS` in registration order; nothing about resolution
|
|
17
|
+
distinguishes one registered name from another.
|
|
18
|
+
|
|
19
|
+
Resolution goes through :func:`importlib.resources.files`, bound at module
|
|
20
|
+
level (rather than imported and called inline) so a test has exactly one
|
|
21
|
+
attribute to monkeypatch: ``fitdocs.agentskill.files``. Nothing happens at
|
|
22
|
+
import time -- every function below does its filesystem work when called, not
|
|
23
|
+
when this module is imported -- and a root is only ever *present* when it
|
|
24
|
+
resolves to a real directory holding :data:`SKILL_FILENAME`; any other case
|
|
25
|
+
(missing name, missing directory, missing file, or a zipped/import-time
|
|
26
|
+
resource that has no real filesystem path) resolves as absent (``None`` or
|
|
27
|
+
``()``), never an exception. A zipped install (for example a zipapp or an
|
|
28
|
+
egg whose resources are not extracted to disk) therefore degrades to "the
|
|
29
|
+
skill is absent" rather than raising -- callers that want to *fail* on an
|
|
30
|
+
incomplete installation (the CLI command) turn that absence into their own
|
|
31
|
+
error path.
|
|
32
|
+
|
|
33
|
+
This module imports nothing from :mod:`fitdocs`: it is a pure leaf, holds no
|
|
34
|
+
package-wide state, and is not part of :data:`fitdocs.__all__`.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from importlib.resources import files
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Final
|
|
42
|
+
|
|
43
|
+
SKILLS_DIR: Final[str] = "skills"
|
|
44
|
+
SKILL_FILENAME: Final[str] = "SKILL.md"
|
|
45
|
+
BLOCK_SKILL_NAME: Final[str] = "build-training-block"
|
|
46
|
+
INBOX_SKILL_NAME: Final[str] = "fitdocs-workouts"
|
|
47
|
+
PACKAGED_SKILLS: Final[tuple[str, ...]] = (BLOCK_SKILL_NAME, INBOX_SKILL_NAME)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolved_directory(name: str) -> Path | None:
|
|
51
|
+
"""Return the filesystem directory for ``name``'s package resource, or ``None``.
|
|
52
|
+
|
|
53
|
+
Converts the :class:`importlib.resources.abc.Traversable` returned by
|
|
54
|
+
``files()`` to a :class:`~pathlib.Path` via ``str()`` -- the documented way
|
|
55
|
+
to obtain a filesystem path for an unzipped install, which every supported
|
|
56
|
+
fitdocs install is -- and guards that the result both exists and is a
|
|
57
|
+
directory before returning it.
|
|
58
|
+
"""
|
|
59
|
+
traversable = files("fitdocs") / SKILLS_DIR / name
|
|
60
|
+
candidate = Path(str(traversable))
|
|
61
|
+
if not candidate.is_dir():
|
|
62
|
+
return None
|
|
63
|
+
return candidate
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def skill_root(name: str) -> Path | None:
|
|
67
|
+
"""The installed directory for the packaged skill ``name``, or ``None``.
|
|
68
|
+
|
|
69
|
+
Present iff ``name`` resolves to a real directory that itself holds
|
|
70
|
+
:data:`SKILL_FILENAME`. Never raises: an unregistered name, a packaging
|
|
71
|
+
defect that dropped the directory or the file, or a zipped import all
|
|
72
|
+
resolve as absent.
|
|
73
|
+
"""
|
|
74
|
+
directory = _resolved_directory(name)
|
|
75
|
+
if directory is None:
|
|
76
|
+
return None
|
|
77
|
+
if not (directory / SKILL_FILENAME).is_file():
|
|
78
|
+
return None
|
|
79
|
+
return directory
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def skill_file(name: str) -> Path | None:
|
|
83
|
+
"""``skill_root(name) / SKILL_FILENAME``, or ``None`` when the root is absent."""
|
|
84
|
+
root = skill_root(name)
|
|
85
|
+
if root is None:
|
|
86
|
+
return None
|
|
87
|
+
return root / SKILL_FILENAME
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def skill_files(name: str) -> tuple[Path, ...]:
|
|
91
|
+
"""Every regular file under ``name``'s root, sorted by relative POSIX path.
|
|
92
|
+
|
|
93
|
+
``()`` when the root is absent. This is the one source of truth for "the
|
|
94
|
+
skill's files": the wheel-membership test and the installation copy
|
|
95
|
+
recipe both read it.
|
|
96
|
+
"""
|
|
97
|
+
root = skill_root(name)
|
|
98
|
+
if root is None:
|
|
99
|
+
return ()
|
|
100
|
+
return tuple(
|
|
101
|
+
sorted(
|
|
102
|
+
(path for path in root.rglob("*") if path.is_file()),
|
|
103
|
+
key=lambda path: path.relative_to(root).as_posix(),
|
|
104
|
+
)
|
|
105
|
+
)
|
fitdocs/athlete.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Read-only athlete-inputs reader (Req 8.3).
|
|
2
|
+
|
|
3
|
+
This module maps an *optional* ``<data_root>/athlete.toml`` onto the fit-ingest
|
|
4
|
+
:class:`~fitdocs.AthleteInputs` / :class:`~fitdocs.ZoneSpec` contract (design:
|
|
5
|
+
AthleteInputsReader). The athlete-inputs source is what unlocks the zone strip
|
|
6
|
+
(Req 8.1) and the threshold-dependent metrics -- intensity factor, training
|
|
7
|
+
stress, TRIMP (Req 8.4) -- downstream; this layer only *reads* it.
|
|
8
|
+
|
|
9
|
+
Two invariants shape the contract:
|
|
10
|
+
|
|
11
|
+
* **Optional.** An absent file yields ``None`` -- not an error. The whole
|
|
12
|
+
feature set that depends on athlete inputs simply degrades to omission.
|
|
13
|
+
* **Loud, never lossy.** A file that exists but is malformed -- invalid TOML, a
|
|
14
|
+
wrong value type, a boolean where a number is expected, or non-ascending /
|
|
15
|
+
empty zone dividers -- raises :class:`AthleteFileError`. Silently dropping a
|
|
16
|
+
bad zone would change the rendered document invisibly, so nothing is swallowed.
|
|
17
|
+
|
|
18
|
+
The reader is read-only *by construction* (Req 8.3): it never creates the file,
|
|
19
|
+
never prompts, and writes nothing on any path. Profile creation, prompting, and
|
|
20
|
+
the file's lifecycle belong to the downstream training-load feature, which may
|
|
21
|
+
add keys -- so **unknown keys are ignored** for forward compatibility.
|
|
22
|
+
|
|
23
|
+
Types are validated strictly. Because Python's ``bool`` is a subclass of
|
|
24
|
+
``int``, a TOML boolean would otherwise sneak through as ``0``/``1``; it is
|
|
25
|
+
rejected everywhere a number is required. Integer thresholds (``resting_hr_bpm``,
|
|
26
|
+
``max_hr_bpm``) require a TOML integer; ``ftp_watts`` accepts a TOML integer or
|
|
27
|
+
float and is normalized to ``float``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import tomllib
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Final
|
|
36
|
+
|
|
37
|
+
from fitdocs import AthleteInputs, ZoneSpec
|
|
38
|
+
|
|
39
|
+
ATHLETE_FILE: Final[str] = "athlete.toml"
|
|
40
|
+
|
|
41
|
+
ATHLETE_SCHEMA_VERSION: Final[int] = 2
|
|
42
|
+
"""Schema version stamped into and required of ``athlete.toml``.
|
|
43
|
+
|
|
44
|
+
1 = flat athlete-input keys only (the shipped shape); 2 adds ``[benchmarks]``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
VERSION_KEY: Final[str] = "profile_version"
|
|
48
|
+
"""The existing on-disk key name, kept unchanged for file compatibility."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AthleteFileError(Exception):
|
|
52
|
+
"""The ``athlete.toml`` file exists but cannot be read as valid inputs.
|
|
53
|
+
|
|
54
|
+
Raised for malformed TOML, a wrong value type (including a boolean where a
|
|
55
|
+
number is required), non-ascending / empty zone dividers, or a declared
|
|
56
|
+
schema version this fitdocs does not recognize. An *absent* file is never
|
|
57
|
+
an error -- it yields ``None`` instead. The message names the file and the
|
|
58
|
+
offending key so a user can correct the configuration.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_schema_version(document: Mapping[str, object], path: Path) -> int:
|
|
63
|
+
"""Read and validate the declared schema version of a decoded ``athlete.toml``.
|
|
64
|
+
|
|
65
|
+
An absent :data:`VERSION_KEY` is treated as version 1, the earliest schema
|
|
66
|
+
(Req 1.8), and any benchmarks present are still read regardless of the
|
|
67
|
+
declared version, so long as it is one this fitdocs supports (Req 1.6) --
|
|
68
|
+
the version gates *forward* only.
|
|
69
|
+
|
|
70
|
+
Raises :class:`AthleteFileError` naming *path*, the declared version and
|
|
71
|
+
the supported version when the value is not a plain integer (a TOML
|
|
72
|
+
boolean is rejected even though ``bool`` subclasses ``int``), is below 1,
|
|
73
|
+
or is greater than :data:`ATHLETE_SCHEMA_VERSION` (Req 1.7).
|
|
74
|
+
"""
|
|
75
|
+
if VERSION_KEY not in document:
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
value = document[VERSION_KEY]
|
|
79
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
80
|
+
raise AthleteFileError(
|
|
81
|
+
f"{path}: {VERSION_KEY} must be an integer between 1 and "
|
|
82
|
+
f"{ATHLETE_SCHEMA_VERSION} (the version this fitdocs supports), "
|
|
83
|
+
f"got {value!r} ({type(value).__name__})"
|
|
84
|
+
)
|
|
85
|
+
if value > ATHLETE_SCHEMA_VERSION:
|
|
86
|
+
raise AthleteFileError(
|
|
87
|
+
f"{path} declares {VERSION_KEY} = {value}, which is newer than "
|
|
88
|
+
f"the schema version this fitdocs supports "
|
|
89
|
+
f"({ATHLETE_SCHEMA_VERSION}); install a newer fitdocs to read it"
|
|
90
|
+
)
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_athlete_inputs(data_root: Path) -> AthleteInputs | None:
|
|
95
|
+
"""Read ``<data_root>/athlete.toml`` into :class:`AthleteInputs`, or ``None``.
|
|
96
|
+
|
|
97
|
+
Returns ``None`` when the file is absent -- the inputs are optional (Req
|
|
98
|
+
8.3). Otherwise every recognized key is mapped onto the corresponding
|
|
99
|
+
:class:`AthleteInputs` field (present keys only; the rest stay ``None``), and
|
|
100
|
+
unknown keys are ignored for forward compatibility with training-load.
|
|
101
|
+
|
|
102
|
+
Raises :class:`AthleteFileError` when the file exists but is malformed:
|
|
103
|
+
invalid TOML, a wrong value type, a boolean where a number is expected, or
|
|
104
|
+
zone dividers that are empty or not strictly ascending (the last surfaced by
|
|
105
|
+
:class:`~fitdocs.ZoneSpec` and re-raised here). The function only ever reads;
|
|
106
|
+
it never writes, creates, or prompts.
|
|
107
|
+
"""
|
|
108
|
+
path = data_root / ATHLETE_FILE
|
|
109
|
+
if not path.is_file():
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
with path.open("rb") as handle:
|
|
114
|
+
data = tomllib.load(handle)
|
|
115
|
+
except tomllib.TOMLDecodeError as exc:
|
|
116
|
+
raise AthleteFileError(f"{path} is not valid TOML: {exc}") from exc
|
|
117
|
+
|
|
118
|
+
check_schema_version(data, path)
|
|
119
|
+
|
|
120
|
+
return AthleteInputs(
|
|
121
|
+
ftp_watts=_optional_number(data, "ftp_watts", path),
|
|
122
|
+
resting_hr_bpm=_optional_int(data, "resting_hr_bpm", path),
|
|
123
|
+
max_hr_bpm=_optional_int(data, "max_hr_bpm", path),
|
|
124
|
+
hr_zones=_optional_zone(data, "hr_zones", path),
|
|
125
|
+
power_zones=_optional_zone(data, "power_zones", path),
|
|
126
|
+
pace_zones=_optional_zone(data, "pace_zones", path),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _optional_number(data: dict[str, Any], key: str, path: Path) -> float | None:
|
|
131
|
+
"""Map an optional numeric threshold to ``float``; reject non-numbers/bools."""
|
|
132
|
+
if key not in data:
|
|
133
|
+
return None
|
|
134
|
+
value = data[key]
|
|
135
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
136
|
+
raise AthleteFileError(
|
|
137
|
+
f"{path}: {key} must be a number, got {value!r} ({type(value).__name__})"
|
|
138
|
+
)
|
|
139
|
+
return float(value)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _optional_int(data: dict[str, Any], key: str, path: Path) -> int | None:
|
|
143
|
+
"""Map an optional integer threshold; reject floats and bools strictly."""
|
|
144
|
+
if key not in data:
|
|
145
|
+
return None
|
|
146
|
+
value = data[key]
|
|
147
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
148
|
+
raise AthleteFileError(
|
|
149
|
+
f"{path}: {key} must be an integer, got {value!r} ({type(value).__name__})"
|
|
150
|
+
)
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _optional_zone(data: dict[str, Any], key: str, path: Path) -> ZoneSpec | None:
|
|
155
|
+
"""Map an optional list of ascending dividers to a :class:`ZoneSpec`.
|
|
156
|
+
|
|
157
|
+
Every element must be a number (a boolean is rejected, never coerced to
|
|
158
|
+
``0``/``1``); the ascending / non-empty guarantee is delegated to
|
|
159
|
+
:class:`ZoneSpec`, whose :class:`ValueError` is re-raised as
|
|
160
|
+
:class:`AthleteFileError` so a bad list fails loudly instead of vanishing.
|
|
161
|
+
"""
|
|
162
|
+
if key not in data:
|
|
163
|
+
return None
|
|
164
|
+
value = data[key]
|
|
165
|
+
if not isinstance(value, list):
|
|
166
|
+
raise AthleteFileError(
|
|
167
|
+
f"{path}: {key} must be a list of ascending numbers, "
|
|
168
|
+
f"got {value!r} ({type(value).__name__})"
|
|
169
|
+
)
|
|
170
|
+
dividers: list[float] = []
|
|
171
|
+
for element in value:
|
|
172
|
+
if isinstance(element, bool) or not isinstance(element, (int, float)):
|
|
173
|
+
raise AthleteFileError(
|
|
174
|
+
f"{path}: every {key} divider must be a number, "
|
|
175
|
+
f"got {element!r} ({type(element).__name__})"
|
|
176
|
+
)
|
|
177
|
+
dividers.append(float(element))
|
|
178
|
+
try:
|
|
179
|
+
return ZoneSpec(dividers=tuple(dividers))
|
|
180
|
+
except ValueError as exc:
|
|
181
|
+
raise AthleteFileError(
|
|
182
|
+
f"{path}: {key} must be non-empty and strictly ascending ({exc})"
|
|
183
|
+
) from exc
|