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.
Files changed (122) hide show
  1. fitdocs/__init__.py +125 -0
  2. fitdocs/agentskill.py +105 -0
  3. fitdocs/athlete.py +183 -0
  4. fitdocs/audit.py +491 -0
  5. fitdocs/benchmarks.py +692 -0
  6. fitdocs/citation.py +199 -0
  7. fitdocs/cli.py +1627 -0
  8. fitdocs/config.py +146 -0
  9. fitdocs/contract.py +1155 -0
  10. fitdocs/declaration.py +593 -0
  11. fitdocs/docio.py +95 -0
  12. fitdocs/docmerge.py +220 -0
  13. fitdocs/history/__init__.py +68 -0
  14. fitdocs/history/documents.py +223 -0
  15. fitdocs/history/engine.py +462 -0
  16. fitdocs/history/model.py +135 -0
  17. fitdocs/history/page.py +669 -0
  18. fitdocs/history/series.py +738 -0
  19. fitdocs/history/settings.py +268 -0
  20. fitdocs/history/sources.py +463 -0
  21. fitdocs/inbox.py +845 -0
  22. fitdocs/ingest/__init__.py +133 -0
  23. fitdocs/ingest/_fields.py +89 -0
  24. fitdocs/ingest/decode.py +105 -0
  25. fitdocs/ingest/errors.py +29 -0
  26. fitdocs/ingest/laps.py +171 -0
  27. fitdocs/ingest/records.py +164 -0
  28. fitdocs/ingest/sets.py +183 -0
  29. fitdocs/ingest/sport.py +115 -0
  30. fitdocs/ingest/summary.py +418 -0
  31. fitdocs/layout.py +453 -0
  32. fitdocs/load/__init__.py +108 -0
  33. fitdocs/load/arbitrate.py +187 -0
  34. fitdocs/load/channels/__init__.py +113 -0
  35. fitdocs/load/channels/grade.py +267 -0
  36. fitdocs/load/channels/heart_rate.py +292 -0
  37. fitdocs/load/channels/pace.py +277 -0
  38. fitdocs/load/channels/power.py +169 -0
  39. fitdocs/load/channels/sources.py +436 -0
  40. fitdocs/load/channels/sufficiency.py +150 -0
  41. fitdocs/load/channels/types.py +214 -0
  42. fitdocs/load/channels/weighting.py +165 -0
  43. fitdocs/load/docedit.py +407 -0
  44. fitdocs/load/engine.py +677 -0
  45. fitdocs/load/priority.py +66 -0
  46. fitdocs/load/profile.py +1008 -0
  47. fitdocs/load/prompts.py +460 -0
  48. fitdocs/load/qa/__init__.py +57 -0
  49. fitdocs/load/qa/cadence.py +348 -0
  50. fitdocs/load/qa/divergence.py +187 -0
  51. fitdocs/load/qa/drift.py +113 -0
  52. fitdocs/load/qa/flags.py +281 -0
  53. fitdocs/load/qa/sources.py +324 -0
  54. fitdocs/load/qa/staleness.py +147 -0
  55. fitdocs/load/qa/types.py +141 -0
  56. fitdocs/load/registry.py +196 -0
  57. fitdocs/load/render.py +468 -0
  58. fitdocs/load/settings.py +546 -0
  59. fitdocs/load/threshold/__init__.py +23 -0
  60. fitdocs/load/threshold/anchors.py +168 -0
  61. fitdocs/load/threshold/calculator.py +768 -0
  62. fitdocs/load/threshold/discipline.py +117 -0
  63. fitdocs/load/threshold/selection.py +140 -0
  64. fitdocs/load/types.py +451 -0
  65. fitdocs/metrics/__init__.py +160 -0
  66. fitdocs/metrics/aggregates.py +393 -0
  67. fitdocs/metrics/power.py +364 -0
  68. fitdocs/metrics/sources.py +745 -0
  69. fitdocs/metrics/stress.py +203 -0
  70. fitdocs/metrics/types.py +164 -0
  71. fitdocs/metrics/zones.py +75 -0
  72. fitdocs/model.py +241 -0
  73. fitdocs/performance/__init__.py +26 -0
  74. fitdocs/performance/derive.py +787 -0
  75. fitdocs/performance/engine.py +556 -0
  76. fitdocs/performance/models.py +159 -0
  77. fitdocs/performance/sources.py +665 -0
  78. fitdocs/performance/types.py +108 -0
  79. fitdocs/plans/__init__.py +221 -0
  80. fitdocs/plans/aggregate.py +163 -0
  81. fitdocs/plans/block_page.py +351 -0
  82. fitdocs/plans/corpus.py +206 -0
  83. fitdocs/plans/engine.py +577 -0
  84. fitdocs/plans/matching.py +395 -0
  85. fitdocs/plans/model.py +750 -0
  86. fitdocs/plans/page.py +324 -0
  87. fitdocs/plans/placement.py +521 -0
  88. fitdocs/plans/planned_page.py +113 -0
  89. fitdocs/plans/reconcile.py +228 -0
  90. fitdocs/plans/resolution.py +94 -0
  91. fitdocs/plans/settings.py +175 -0
  92. fitdocs/plans/source.py +1130 -0
  93. fitdocs/plugins.py +657 -0
  94. fitdocs/py.typed +0 -0
  95. fitdocs/quarantine.py +245 -0
  96. fitdocs/render/__init__.py +153 -0
  97. fitdocs/render/charts/__init__.py +9 -0
  98. fitdocs/render/charts/calendar.py +482 -0
  99. fitdocs/render/charts/hero.py +421 -0
  100. fitdocs/render/charts/map.py +462 -0
  101. fitdocs/render/charts/palette.py +190 -0
  102. fitdocs/render/charts/series.py +155 -0
  103. fitdocs/render/charts/svg.py +130 -0
  104. fitdocs/render/charts/zones.py +201 -0
  105. fitdocs/render/format.py +106 -0
  106. fitdocs/render/frontmatter.py +159 -0
  107. fitdocs/render/sections.py +618 -0
  108. fitdocs/render/splits.py +340 -0
  109. fitdocs/render/strength.py +180 -0
  110. fitdocs/render/views.py +245 -0
  111. fitdocs/settings.py +71 -0
  112. fitdocs/skills/build-training-block/SKILL.md +249 -0
  113. fitdocs/skills/build-training-block/example-block.toml +105 -0
  114. fitdocs/skills/fitdocs-workouts/SKILL.md +89 -0
  115. fitdocs/sync.py +1515 -0
  116. fitdocs/tiles.py +438 -0
  117. fitdocs/version.py +64 -0
  118. fitdocs-0.1.0.dist-info/METADATA +249 -0
  119. fitdocs-0.1.0.dist-info/RECORD +122 -0
  120. fitdocs-0.1.0.dist-info/WHEEL +4 -0
  121. fitdocs-0.1.0.dist-info/entry_points.txt +2 -0
  122. 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