Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ from fit_tool import (
| `FitFile` | Load, inspect, stream, serialize, and validate FIT files |
| `FitFileBuilder` | Build FIT files from messages |
| `EncodeMode`, `EncodeOptions` | Explicit encode policies (PRESERVE vs CANONICAL) for `to_bytes` |
| `validate_fit_file`, `ConformanceLevel`, `ValidationReport` | Composable validation (independent of Builder) |
| `validate_fit_file`, `ConformanceLevel`, `ProfileScope`, `ValidationReport`, `profile_rule_coverage` | Composable validation (independent of Builder) |
| `FitError` and subclasses | Typed errors for parse, CRC, encode, and validation failures |
| `PROTOCOL_VERSION`, `SDK_VERSION`, `FIT_DATA_TYPE` | Bundled protocol/profile version metadata |

Expand Down Expand Up @@ -220,7 +220,7 @@ Validation is a first-class API. Levels match the design doc
| Level | What it checks | Status |
| --- | --- | --- |
| `ConformanceLevel.WIRE` | Local IDs, definition field layout/sizes, data records vs active definition | Implemented |
| `ConformanceLevel.PROFILE` | Developer field declarations (`developer_data_id` / `field_description`) and base-type consistency; **ambiguous native subfields** (more than one Profile match) as ERROR | **CORE scope today** (+ ambiguous-subfield ERROR). Roadmap: DOMAIN then FULL rules from Profile.xlsx; FULL is opt-in, not default `strict` — design doc §3.1 (O1). Subfield *resolution* for decode/encode is separate and supported |
| `ConformanceLevel.PROFILE` | Scoped Profile semantics via `profile_scope=` / `ProfileScope` | **CORE (default):** developer-field declarations + **ambiguous native subfields** as ERROR. **DOMAIN (opt-in):** CORE + native base-type and closed-enum checks on high-frequency Activity/Workout messages. **FULL (opt-in):** same native rules for the entire gen-exported catalog from Profile.xlsx `21.205.0`. FULL is never default `strict` — design doc §3.1 (O1). Open/ranged enums (e.g. `activity_class`) are excluded from closed-enum checks. |
| `ConformanceLevel.FILE_TYPE` | `file_id` first/unique + required fields; Activity, Workout, and Course required messages and fields | **Activity + Workout + Course**; other `file_id.type` values **fail closed** (intentional until more validators exist) |
| `ConformanceLevel.PRESERVATION` | Post-edit rewrite loss (e.g. `UnknownField.raw_bytes` cleared by mutation) | **Opt-in** — not in default levels / Builder `strict=True` |

Expand Down
4 changes: 4 additions & 0 deletions fit_tool/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@
FitParseError,
FitRecordError,
FitValidationError,
ProfileScope,
Severity,
ValidationFinding,
ValidationReport,
profile_rule_coverage,
validate_fit_file,
)

Expand All @@ -51,8 +53,10 @@
'EncodeMode',
'EncodeOptions',
'ConformanceLevel',
'ProfileScope',
'Severity',
'ValidationFinding',
'ValidationReport',
'profile_rule_coverage',
'validate_fit_file',
]
4 changes: 4 additions & 0 deletions fit_tool/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
from fit_tool.fit_file_builder import FitFileBuilder
from fit_tool.validation import (
ConformanceLevel,
ProfileScope,
Severity,
ValidationFinding,
ValidationReport,
profile_rule_coverage,
validate_fit_file,
)

Expand All @@ -49,8 +51,10 @@
'EncodeMode',
'EncodeOptions',
'ConformanceLevel',
'ProfileScope',
'Severity',
'ValidationFinding',
'ValidationReport',
'profile_rule_coverage',
'validate_fit_file',
]
11 changes: 9 additions & 2 deletions fit_tool/fit_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from fit_tool.wire.model import FitDocument

if TYPE_CHECKING:
from fit_tool.validation import ConformanceLevel, ValidationReport
from fit_tool.validation import ConformanceLevel, ProfileScope, ValidationReport

# Sentinel so explicit ``check_crc=True`` can override ``options.check_crc=False``
# (a bare default of True is indistinguishable from "caller omitted the kwarg").
Expand Down Expand Up @@ -407,6 +407,7 @@ def validate(
self,
levels: Iterable[ConformanceLevel] | None = None,
*,
profile_scope: ProfileScope | None = None,
raise_on_error: bool = False,
) -> ValidationReport:
"""Validate this file at selected conformance levels.
Expand All @@ -417,8 +418,14 @@ def validate(
or include :attr:`~fit_tool.validation.ConformanceLevel.PRESERVATION`
for opt-in post-edit rewrite-loss findings.

``profile_scope`` selects PROFILE depth (CORE default; DOMAIN/FULL opt-in).
See :func:`~fit_tool.validation.validate_fit_file` for details.
"""
from fit_tool.validation import validate_fit_file

return validate_fit_file(self, levels=levels, raise_on_error=raise_on_error)
return validate_fit_file(
self,
levels=levels,
profile_scope=profile_scope,
raise_on_error=raise_on_error,
)
220 changes: 220 additions & 0 deletions fit_tool/gen/field_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""Extract native field and enum tables from Profile.xlsx for PROFILE validation.

Metadata flow (design doc §3.1 O1): Profile.xlsx → gen artifacts → validation.
Runtime validation imports the generated module under ``fit_tool.profile`` and
never loads openpyxl.
"""

from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from fit_tool.base_type import BaseType


@dataclass(frozen=True)
class FieldSpec:
"""One main (non-subfield) Profile field."""

message_name: str
message_number: int
field_id: int
field_name: str
base_type: BaseType
type_name: str
units: str
scale: float
offset: float


@dataclass(frozen=True)
class EnumTypeSpec:
"""Closed enum type from the Profile Types sheet (base type ENUM only)."""

type_name: str
values: frozenset[int]


@dataclass(frozen=True)
class FieldCatalogData:
"""Full native-field + closed-enum catalog for a Profile version."""

fields: tuple[FieldSpec, ...]
enum_types: tuple[EnumTypeSpec, ...]


def load_field_catalog_from_profile(profile: Any) -> FieldCatalogData:
"""Build catalog data from a loaded :class:`~fit_tool.gen.profile.Profile`."""
fields: list[FieldSpec] = []
for message in sorted(profile.messages_by_id.values(), key=lambda m: int(m.id)):
for field in sorted(
message.get_fields(),
key=lambda f: int(f.field_id),
):
type_name = field.type_name or ''
units = field.units or ''
scale = float(field.scale) if field.scale is not None else 1.0
offset = float(field.offset) if field.offset is not None else 0.0
fields.append(
FieldSpec(
message_name=str(message.name),
message_number=int(message.id),
field_id=int(field.field_id),
field_name=str(field.name),
base_type=field.base_type,
type_name=str(type_name),
units=str(units),
scale=scale,
offset=offset,
)
)

# ENUM base types that are ranges / bitfields, not closed value sets.
# Example: activity_class uses LEVEL mask 0x7f and ATHLETE flag 0x80.
_OPEN_OR_RANGED_ENUM_TYPES = frozenset({
'activity_class',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude the manufacturer file-type range from closed enums

The earlier ranged-enum concern has fresh evidence in FileType: the generated profile defines MFG_RANGE_MIN = 247 and MFG_RANGE_MAX = 254, so intermediate manufacturer-defined file types are valid, yet only activity_class is excluded here and the catalog stores just the endpoints. Under DOMAIN/FULL, _collect_closed_enum_findings consequently reports FileIdMessage.type = 250 (and other values from 248 through 253) as outside file; add file to the ranged handling or encode its interval, then regenerate the catalog.

AGENTS.md reference: AGENTS.md:L53-L58

Useful? React with 👍 / 👎.

})

enum_types: list[EnumTypeSpec] = []
for type_name in sorted(profile.types_by_name.keys()):
type_ = profile.types_by_name[type_name]
if type_.base_type is not BaseType.ENUM:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude ranged enum types from the closed-enum catalog

When FULL scope validates user_profile.activity_class, this condition treats the type as a closed set merely because its base type is ENUM. However, ActivityClass.LEVEL is a 0x7f mask, LEVEL_MAX is 100, and ATHLETE is a combinable 0x80 flag, so valid encoded levels such as 50 or ATHLETE | 50 are absent from PROFILE_ENUM_VALUES; _collect_closed_enum_findings consequently reports them as errors. Exclude ranged/bitfield enum families such as activity_class or represent their masks and ranges explicitly.

Useful? React with 👍 / 👎.

continue
if type_name in _OPEN_OR_RANGED_ENUM_TYPES:
continue
values = getattr(type_, 'values_by_name', None) or {}
if not values:
continue
enum_types.append(
EnumTypeSpec(
type_name=str(type_name),
values=frozenset(int(v) for v in values.values()),
)
)

return FieldCatalogData(fields=tuple(fields), enum_types=tuple(enum_types))


def load_field_catalog(xlsx_path: str | Path) -> FieldCatalogData:
"""Load field/enum tables from a Garmin Profile spreadsheet."""
from fit_tool.gen.profile import Profile

profile = Profile.load(str(xlsx_path))
return load_field_catalog_from_profile(profile)


def _py_str(value: str) -> str:
return repr(value)


def render_field_catalog(data: FieldCatalogData, *, sdk_version: str) -> str:
"""Render a Python module defining PROFILE field/enum tables."""
lines: list[str] = [
'# Autogenerated. Do not modify.',
'#',
f'# Profile: {sdk_version}',
'"""Native field and closed-enum catalog for PROFILE validation.',
'',
'Generated from the bundled Garmin Profile spreadsheet by ``gen-profile``.',
'',
'* ``PROFILE_FIELDS`` — ``(global_message_number, field_id)`` → field metadata',
'* ``PROFILE_ENUM_VALUES`` — Profile type name → allowed integer values',
' (Types sheet entries with base type ``enum`` only)',
'',
'Used by :mod:`fit_tool.validation` DOMAIN/FULL scopes (design doc §3.1 O1).',
'"""',
'',
'from __future__ import annotations',
'',
'from fit_tool.base_type import BaseType',
'',
f'PROFILE_SDK_VERSION = {sdk_version!r}',
f'PROFILE_MESSAGE_COUNT = {len({f.message_number for f in data.fields})}',
f'PROFILE_FIELD_COUNT = {len(data.fields)}',
f'PROFILE_ENUM_TYPE_COUNT = {len(data.enum_types)}',
'',
'# (global_message_number, field_id) → '
'(name, BaseType, type_name, units, scale, offset)',
'PROFILE_FIELDS: dict[tuple[int, int], tuple[str, BaseType, str, str, float, float]] = {',
]

for field in data.fields:
lines.append(
f' ({field.message_number}, {field.field_id}): ('
f'{_py_str(field.field_name)}, '
f'BaseType.{field.base_type.name}, '
f'{_py_str(field.type_name)}, '
f'{_py_str(field.units)}, '
f'{field.scale!r}, '
f'{field.offset!r}'
f'), # {field.message_name}'
)

lines.append('}')
lines.append('')
lines.append(
'PROFILE_FIELD_KEYS: frozenset[tuple[int, int]] = frozenset(PROFILE_FIELDS)'
)
lines.append('')
lines.append('# Closed enum types only (base type enum). Bitfields / open lists omitted.')
lines.append('PROFILE_ENUM_VALUES: dict[str, frozenset[int]] = {')

for enum_type in data.enum_types:
values_repr = ', '.join(str(v) for v in sorted(enum_type.values))
lines.append(
f' {_py_str(enum_type.type_name)}: frozenset({{{values_repr}}}),'
)

lines.append('}')
lines.append('')

# Precompute enum-typed field keys for coverage metrics.
enum_field_count = sum(
1
for field in data.fields
if field.type_name and field.type_name in {e.type_name for e in data.enum_types}
)
lines.append(f'PROFILE_ENUM_FIELD_COUNT = {enum_field_count}')
lines.append('')
return '\n'.join(lines) + '\n'


def write_field_catalog(
output_path: str | Path,
*,
xlsx_path: str | Path,
sdk_version: str,
) -> FieldCatalogData:
"""Extract catalog from *xlsx_path* and write *output_path*."""
data = load_field_catalog(xlsx_path)
text = render_field_catalog(data, sdk_version=sdk_version)
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding='utf-8')
return data


def coverage_summary(data: FieldCatalogData) -> dict[str, Any]:
"""High-level coverage stats for docs / tests."""
messages = sorted({f.message_name for f in data.fields})
enum_type_names = {e.type_name for e in data.enum_types}
enum_fields = sum(1 for f in data.fields if f.type_name in enum_type_names)
return {
'messages': len(messages),
'fields': len(data.fields),
'enum_types': len(data.enum_types),
'enum_fields': enum_fields,
'message_names': messages,
}


def domain_message_field_count(
data: FieldCatalogData,
message_numbers: Iterable[int],
) -> int:
"""Count main fields belonging to *message_numbers*."""
wanted = set(message_numbers)
return sum(1 for f in data.fields if f.message_number in wanted)
9 changes: 9 additions & 0 deletions fit_tool/gen/gen_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from fit_tool.base_type import BaseType
from fit_tool.field import Field
from fit_tool.gen.component_registry import write_component_registry
from fit_tool.gen.field_catalog import write_field_catalog
from fit_tool.gen.profile import Message, Profile

DEFAULT_BUILD_PATH = str(Path(__file__).resolve().parents[1])
Expand Down Expand Up @@ -318,6 +319,14 @@ def main():
sdk_version=SDK_VERSION,
)

# Native field + closed-enum catalog for PROFILE DOMAIN/FULL (Stage 4 H / O1).
field_catalog_path = os.path.join(profile_path, 'field_catalog.py')
write_field_catalog(
field_catalog_path,
xlsx_path=xlsx_filename,
sdk_version=SDK_VERSION,
)


if __name__ == "__main__":
main()
Loading
Loading