From 0601cf5b4d82c63fe8d217dd60c451f6b66b549f Mon Sep 17 00:00:00 2001 From: shaonianche <16186646+shaonianche@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:34:05 +0800 Subject: [PATCH 1/3] chore: small cleanup pass (#81) - drop the unused _KNOWN_COMPONENTS and _register_developer_field aliases - move project_records test scaffolding out of compatibility - keep TIMESTAMP_FIELD_ID in wire.model instead of three modules - share crc16_chunks between FitFile and FitFileBuilder - read STRING fields by field size instead of buffer length Amp-Thread-ID: https://ampcode.com/threads/T-01a180a3-b0f6-711b-bca9-09e4a1f830d0 Co-authored-by: Amp --- docs/FIT_CONFORMANCE_DESIGN.md | 2 +- fit_tool/compatibility.py | 27 ++------------------------- fit_tool/components.py | 4 ---- fit_tool/encode.py | 5 ++--- fit_tool/field.py | 6 +++--- fit_tool/fit_file.py | 11 ++++------- fit_tool/fit_file_builder.py | 7 ++----- fit_tool/tests/test_coverage_edges.py | 20 +++++++++++++++++--- fit_tool/utils/crc.py | 9 +++++++++ fit_tool/wire/decoder.py | 6 ++---- fit_tool/wire/model.py | 3 +++ 11 files changed, 45 insertions(+), 55 deletions(-) diff --git a/docs/FIT_CONFORMANCE_DESIGN.md b/docs/FIT_CONFORMANCE_DESIGN.md index 3382351..ff470d2 100644 --- a/docs/FIT_CONFORMANCE_DESIGN.md +++ b/docs/FIT_CONFORMANCE_DESIGN.md @@ -44,7 +44,7 @@ public-claims pass that keeps this table and `docs/CAPABILITY_BOUNDARY.md` hones | --- | --- | --- | --- | | Docs / status truth vs main | Phase 0 | **A** SHA-13 | **Done** (re-synced at Stage 5 / L) | | Fixture / golden corpus for protocol edges | Phase 0 | **B** SHA-14 | **Done** inventory + constructive goldens; expand as follow-up only | -| Full component / accumulator coverage beyond `_KNOWN_COMPONENTS` | Phase 3 | **C** SHA-15 | **Done** main-field registry + nested + rollover; active-subfield components via D | +| Full component / accumulator coverage beyond `PROFILE_COMPONENTS` | Phase 3 | **C** SHA-15 | **Done** main-field registry + nested + rollover; active-subfield components via D | | Subfield resolution (type / scale / units / components) | Phase 3 | **D** SHA-16 | **Done** runtime match + PROFILE ambiguity ERROR | | Unknown field ids on known messages (decode retain + raw bytes) | Phase 3 | **E** SHA-17 | **Done** `UnknownField` + `raw_bytes` on main path | | Post-edit PRESERVATION (edited files, dirty records) | Phase 4 / PRESERVATION level | **F** SHA-18 | **Done** per-record dirty + mixed encode; opt-in PRESERVATION findings | diff --git a/fit_tool/compatibility.py b/fit_tool/compatibility.py index 936a0d1..b4f62a9 100644 --- a/fit_tool/compatibility.py +++ b/fit_tool/compatibility.py @@ -23,6 +23,7 @@ from fit_tool.profile.messages.field_description_message import FieldDescriptionMessage from fit_tool.record import Record, RecordHeader from fit_tool.wire.model import ( + TIMESTAMP_FIELD_ID, FitDocument, FitSegment, RawDataRecord, @@ -31,8 +32,6 @@ RawRecord, ) -_TIMESTAMP_FIELD_ID = 253 - def project_header(raw: RawFileHeader) -> FitFileHeader: """Project a wire header into the existing FitFileHeader type.""" @@ -157,7 +156,7 @@ def project_data_record( def _apply_resolved_timestamp(message: DataMessage, timestamp: int) -> None: """Inject a reconstructed FIT date_time into field 253 when present.""" - field = message.get_field(_TIMESTAMP_FIELD_ID) + field = message.get_field(TIMESTAMP_FIELD_ID) if field is None: return if field.size == 0: @@ -199,10 +198,6 @@ def register_developer_field( fields_by_id[developer_field.field_id] = developer_field -# Backward-compatible private alias used by earlier Stage 2 code / tests. -_register_developer_field = register_developer_field - - def project_raw_record( raw: RawRecord, developer_fields_by_data_index: dict[int, dict[int, DeveloperField]], @@ -253,21 +248,3 @@ def project_document(document: FitDocument) -> list[Record]: for segment in document.segments: records.extend(project_segment(segment, accumulators=accumulators)) return records - - -def project_records(records: list[RawRecord]) -> list[Record]: - """Project a sequence of raw records (used by unit tests).""" - segment = FitSegment( - header=RawFileHeader( - header_size=14, - protocol_version=0x20, - profile_version=0, - records_size=0, - data_type=b'.FIT', - crc=None, - source_offset=0, - source_bytes=b'', - ), - records=list(records), - ) - return project_segment(segment) diff --git a/fit_tool/components.py b/fit_tool/components.py index f243864..b28f370 100644 --- a/fit_tool/components.py +++ b/fit_tool/components.py @@ -22,9 +22,6 @@ PROFILE_COMPONENTS, ) -# Back-compat alias: historical name used by docs and early tests. -_KNOWN_COMPONENTS = PROFILE_COMPONENTS - def components_for_field( global_id: int, @@ -238,7 +235,6 @@ def expand_field(source: Field) -> None: __all__ = [ 'PROFILE_COMPONENT_SOURCE_COUNT', 'PROFILE_COMPONENTS', - '_KNOWN_COMPONENTS', 'components_for_field', 'expand_message_components', 'registry_coverage', diff --git a/fit_tool/encode.py b/fit_tool/encode.py index 16de07f..28b3a9d 100644 --- a/fit_tool/encode.py +++ b/fit_tool/encode.py @@ -23,12 +23,11 @@ from fit_tool.definition_message import DefinitionMessage from fit_tool.exceptions import FitEncodingError from fit_tool.record import Record, RecordHeader +from fit_tool.wire.model import TIMESTAMP_FIELD_ID if TYPE_CHECKING: from fit_tool.fit_file import FitFile -_TIMESTAMP_FIELD_ID = 253 - class EncodeMode(str, Enum): """How :meth:`~fit_tool.fit_file.FitFile.to_bytes` rebuilds the FIT stream.""" @@ -139,7 +138,7 @@ def expand_compressed_header(record: Record) -> RecordHeader: def can_expand_compressed_to_normal(message: DataMessage) -> bool: """True when field 253 is on the definition so a normal header can carry the ts.""" - return definition_includes_field(message.definition_message, _TIMESTAMP_FIELD_ID) + return definition_includes_field(message.definition_message, TIMESTAMP_FIELD_ID) def encode_record_projected( diff --git a/fit_tool/field.py b/fit_tool/field.py index fb82fc9..2b65dd3 100644 --- a/fit_tool/field.py +++ b/fit_tool/field.py @@ -311,9 +311,9 @@ def calculate_size(self) -> int: def get_encoded_value_from_bytes(self, bytes_buffer: bytes, offset: int = 0, endian: Endian = Endian.LITTLE): if self.base_type == BaseType.STRING: - length = len(bytes_buffer) - 1 - offset - value, = struct.unpack_from(f'{length}s', bytes_buffer, offset) - return value.decode('utf-8') + end = offset + self.size if self.size else len(bytes_buffer) + raw = bytes_buffer[offset:end] + return raw.split(b'\x00', 1)[0].decode('utf-8') endian_symbol = '<' if endian == Endian.LITTLE else '>' diff --git a/fit_tool/fit_file.py b/fit_tool/fit_file.py index 17063a8..11a9ca3 100644 --- a/fit_tool/fit_file.py +++ b/fit_tool/fit_file.py @@ -19,7 +19,7 @@ from fit_tool.exceptions import FitCRCError, FitEncodingError from fit_tool.fit_file_header import FitFileHeader from fit_tool.record import Record -from fit_tool.utils.crc import crc16 +from fit_tool.utils.crc import crc16_chunks from fit_tool.utils.logging import logger from fit_tool.wire.encoder import encode_document, encode_document_mixed from fit_tool.wire.model import FitDocument @@ -311,14 +311,11 @@ def _to_bytes_projected(self, *, options: EncodeOptions) -> bytes: self.header.protocol_version, self.header.profile_version, records_size ) - calculated_crc = 0 - bytes_buffer = bytearray() - buffer = self.header.to_bytes() - calculated_crc = crc16(buffer, crc=calculated_crc) - bytes_buffer.extend(buffer) + header_bytes = self.header.to_bytes() + calculated_crc = crc16_chunks([header_bytes, *record_buffers]) + bytes_buffer = bytearray(header_bytes) for buffer in record_buffers: - calculated_crc = crc16(buffer, crc=calculated_crc) bytes_buffer.extend(buffer) if self._crc is None: diff --git a/fit_tool/fit_file_builder.py b/fit_tool/fit_file_builder.py index e276b00..2205738 100644 --- a/fit_tool/fit_file_builder.py +++ b/fit_tool/fit_file_builder.py @@ -7,7 +7,7 @@ from fit_tool.fit_file_header import FitFileHeader from fit_tool.message import Message from fit_tool.record import Record -from fit_tool.utils.crc import crc16 +from fit_tool.utils.crc import crc16_chunks from fit_tool.validation import ( STRICT_LEVELS, validate_definition, @@ -24,10 +24,7 @@ def calc_records_size(records: list[Record]) -> int: def calc_crc(header: FitFileHeader, records: list[Record]) -> int: - crc = crc16(header.to_bytes()) - for record in records: - crc = crc16(record.to_bytes(), crc=crc) - return crc + return crc16_chunks([header.to_bytes(), *(record.to_bytes() for record in records)]) class FitFileBuilder: diff --git a/fit_tool/tests/test_coverage_edges.py b/fit_tool/tests/test_coverage_edges.py index 793289f..5a5eca4 100644 --- a/fit_tool/tests/test_coverage_edges.py +++ b/fit_tool/tests/test_coverage_edges.py @@ -156,10 +156,24 @@ def test_raw_header_and_file_header_size(self): class TestCompatibilityEdges(unittest.TestCase): - def test_project_records_empty(self): - from fit_tool.compatibility import project_records + def test_project_segment_empty(self): + from fit_tool.compatibility import project_segment + from fit_tool.wire.model import FitSegment - self.assertEqual(project_records([]), []) + segment = FitSegment( + header=RawFileHeader( + header_size=14, + protocol_version=0x20, + profile_version=0, + records_size=0, + data_type=b'.FIT', + crc=None, + source_offset=0, + source_bytes=b'', + ), + records=[], + ) + self.assertEqual(project_segment(segment), []) def test_register_developer_field_missing_metadata(self): from fit_tool.compatibility import register_developer_field diff --git a/fit_tool/utils/crc.py b/fit_tool/utils/crc.py index 68550ea..c6399b4 100644 --- a/fit_tool/utils/crc.py +++ b/fit_tool/utils/crc.py @@ -7,6 +7,8 @@ from __future__ import annotations +from collections.abc import Iterable + CRC_TABLE = ( 0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400, @@ -33,3 +35,10 @@ def crc16(buffer, crc=0): crc = (crc >> 4) & 0x0FFF crc = crc ^ tmp ^ CRC_TABLE[(byte_char >> 4) & 0xF] return crc + + +def crc16_chunks(chunks: Iterable[bytes], crc: int = 0) -> int: + """CRC-16 over a sequence of byte buffers, in order.""" + for chunk in chunks: + crc = crc16(chunk, crc=crc) + return crc diff --git a/fit_tool/wire/decoder.py b/fit_tool/wire/decoder.py index 873fc8d..6bfdb58 100644 --- a/fit_tool/wire/decoder.py +++ b/fit_tool/wire/decoder.py @@ -17,6 +17,7 @@ from fit_tool.exceptions import FitCRCError, FitHeaderError, FitParseError, FitRecordError from fit_tool.wire.crc import crc16 from fit_tool.wire.model import ( + TIMESTAMP_FIELD_ID, FitDocument, FitSegment, RawDataRecord, @@ -29,9 +30,6 @@ ) from fit_tool.wire.timestamp import apply_compressed_time_offset -# Native FIT date_time field number (seconds since 1989-12-31). -_TIMESTAMP_FIELD_ID = 253 - _IS_TIME_COMPRESSED = 0x80 _IS_DEFINITION = 0x40 _HAS_DEVELOPER_FIELDS = 0x20 @@ -314,7 +312,7 @@ def _extract_timestamp_from_payload(raw_record: RawDataRecord) -> int | None: endian_symbol = '<' if definition.architecture == 0 else '>' offset = 0 for field_def in definition.field_definitions: - if field_def.field_id == _TIMESTAMP_FIELD_ID and field_def.size >= 4: + if field_def.field_id == TIMESTAMP_FIELD_ID and field_def.size >= 4: # date_time is uint32; take the first 4 bytes of the field slot. value, = struct.unpack_from(f'{endian_symbol}I', raw_record.payload, offset) # Invalid FIT date_time is 0xFFFFFFFF. diff --git a/fit_tool/wire/model.py b/fit_tool/wire/model.py index c9ed2c4..8f52283 100644 --- a/fit_tool/wire/model.py +++ b/fit_tool/wire/model.py @@ -10,6 +10,9 @@ from dataclasses import dataclass, field from typing import Union +# Native FIT date_time field number (seconds since 1989-12-31). +TIMESTAMP_FIELD_ID = 253 + @dataclass(frozen=True) class RawRecordHeader: From 8dc84b863d208e3e1fc8d34529ca50ea4313ba9c Mon Sep 17 00:00:00 2001 From: shaonianche <16186646+shaonianche@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:34:49 +0800 Subject: [PATCH 2/3] chore: scope mypy to the files CI actually enforces (#79) The config advertised disallow_untyped_defs for every non-generated module while CI relaxed all of those flags and checked three files. List the checked files in [tool.mypy].files and run bare mypy in CI so the config and the gate agree. Expanding the list is tracked in #79. Amp-Thread-ID: https://ampcode.com/threads/T-01a180a3-b0f6-711b-bca9-09e4a1f830d0 Co-authored-by: Amp --- .github/workflows/ci.yml | 4 +--- AGENTS.md | 4 ++-- pyproject.toml | 9 +++++++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37fcb96..1b54de2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,7 @@ jobs: - name: Type-check parser boundaries if: matrix.python-version == '3.12' - run: >- - uv run --frozen mypy fit_tool/fit_file.py fit_tool/fit_file_stream.py fit_tool/exceptions.py - --follow-imports=skip --allow-untyped-defs --allow-incomplete-defs --no-strict-optional + run: uv run --frozen mypy - name: Build package run: uv build diff --git a/AGENTS.md b/AGENTS.md index 4b9d8c0..5de9596 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,11 +40,11 @@ the full suite and `uv run gen-profile`. For a published package, install with The CI baseline is a package build plus the full test suite on Python 3.9 through 3.15. Before finishing a normal code change, run the narrowest relevant tests and then the full suite when practical. Run `uv build` for packaging, entry-point, dependency, or metadata changes. -**Ruff is a CI gate** on the Python 3.12 job (`ruff check fit_tool`). Mypy is configured in `pyproject.toml` and runs in a limited form in CI; core modules are not all under strict mypy yet. Locally: +**Ruff is a CI gate** on the Python 3.12 job (`ruff check fit_tool`). Mypy is configured in `pyproject.toml` and runs in a limited form in CI; core modules are not all under strict mypy yet. The checked set lives in `[tool.mypy].files`; run `uv run mypy` (no path arguments) so the config applies. Locally: ```bash uv run ruff check fit_tool -uv run mypy fit_tool +uv run mypy ``` Do not update `uv.lock` unless dependencies or dependency groups intentionally changed. Use `uv lock` or an appropriate `uv add`/`uv remove` command for intentional dependency changes; never edit the lock file by hand. diff --git a/pyproject.toml b/pyproject.toml index 9557a8b..6cb3632 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,10 +123,19 @@ extend-select = [ known-first-party = ["fit_tool"] [tool.mypy] +# Type checking covers the parser boundary modules only. The rest of the +# handwritten library is not mypy-clean yet (issue #79); add modules to `files` +# as they reach zero errors instead of advertising rules that are not enforced. +files = [ + "fit_tool/fit_file.py", + "fit_tool/fit_file_stream.py", + "fit_tool/exceptions.py", +] follow_imports = "silent" ignore_missing_imports = true disallow_incomplete_defs = true disallow_untyped_defs = true +# Keep generated profile code and tests out of ad-hoc `mypy fit_tool` runs. exclude = "fit_tool/(tests|examples|gen|profile/messages)" [tool.codespell] From 392c21db998c237bc6c31e93db2ec277f603feca Mon Sep 17 00:00:00 2001 From: shaonianche <16186646+shaonianche@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:50:21 +0800 Subject: [PATCH 3/3] fix: let explicit encode kwargs override EncodeOptions (#82) preserve= was ignored whenever options= was supplied, and an explicit strict=False could not turn off options.strict. Merge kwargs over a base EncodeOptions in one path and use None (instead of a sentinel object) to mean "omitted", so explicit values always win. Amp-Thread-ID: https://ampcode.com/threads/T-01a180a3-b0f6-711b-bca9-09e4a1f830d0 Co-authored-by: Amp --- fit_tool/encode.py | 37 +++++++------------------- fit_tool/fit_file.py | 7 +++-- fit_tool/tests/test_encode_policies.py | 35 ++++++++++++++++++++++++ news/82.bugfix | 1 + 4 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 news/82.bugfix diff --git a/fit_tool/encode.py b/fit_tool/encode.py index 28b3a9d..543b8f6 100644 --- a/fit_tool/encode.py +++ b/fit_tool/encode.py @@ -36,11 +36,6 @@ class EncodeMode(str, Enum): CANONICAL = 'canonical' -# Sentinel so an explicit ``check_crc=True`` is distinguishable from an omitted -# kwarg (a bare default of True cannot override ``EncodeOptions(check_crc=False)``). -_CHECK_CRC_UNSET: object = object() - - def _resolve_mode( *, mode: EncodeMode | str | None, @@ -78,8 +73,8 @@ def resolve_encode_options( options: EncodeOptions | None = None, mode: EncodeMode | str | None = None, preserve: bool | None = None, - strict: bool = False, - check_crc: bool | object = _CHECK_CRC_UNSET, + strict: bool | None = None, + check_crc: bool | None = None, ) -> EncodeOptions: """Resolve kwargs and an optional :class:`EncodeOptions` into one value. @@ -90,29 +85,15 @@ def resolve_encode_options( 3. ``options.mode`` when an options object is supplied 4. Default PRESERVE - Explicit kwargs win over the supplied options object; omitted kwargs keep - the options object's values. ``strict=True`` forces CANONICAL (see - :class:`EncodeOptions`). + Explicit ``strict`` / ``check_crc`` values override the supplied options + object; omitted (``None``) values keep it. ``strict=True`` forces CANONICAL + (see :class:`EncodeOptions`). """ - check_crc_explicit = check_crc is not _CHECK_CRC_UNSET - check_crc_value = True if not check_crc_explicit else bool(check_crc) - - if options is None: - return EncodeOptions( - mode=_resolve_mode(mode=mode, preserve=preserve), - strict=strict, - check_crc=check_crc_value, - ) - - if mode is None and preserve is None and not strict and not check_crc_explicit: - return options - - # `preserve` stays a legacy alias for the no-options path; an explicit - # `mode` still wins over it. + base = options or EncodeOptions() return EncodeOptions( - mode=_resolve_mode(mode=mode, preserve=None, default=options.mode), - strict=strict or options.strict, - check_crc=check_crc_value if check_crc_explicit else options.check_crc, + mode=_resolve_mode(mode=mode, preserve=preserve, default=base.mode), + strict=base.strict if strict is None else strict, + check_crc=base.check_crc if check_crc is None else check_crc, ) diff --git a/fit_tool/fit_file.py b/fit_tool/fit_file.py index 11a9ca3..98dfafe 100644 --- a/fit_tool/fit_file.py +++ b/fit_tool/fit_file.py @@ -9,7 +9,6 @@ from fit_tool.decoder import FitDecoder from fit_tool.encode import ( - _CHECK_CRC_UNSET, EncodeMode, EncodeOptions, apply_strict_precheck, @@ -180,11 +179,11 @@ def from_bytes( def to_bytes( self, - check_crc: bool | object = _CHECK_CRC_UNSET, + check_crc: bool | None = None, *, preserve: bool | None = None, mode: EncodeMode | str | None = None, - strict: bool = False, + strict: bool | None = None, options: EncodeOptions | None = None, ) -> bytes: """Serialize this file. @@ -372,7 +371,7 @@ def to_file( *, preserve: bool | None = None, mode: EncodeMode | str | None = None, - strict: bool = False, + strict: bool | None = None, ) -> None: with open(path, 'wb') as file_object: file_object.write( diff --git a/fit_tool/tests/test_encode_policies.py b/fit_tool/tests/test_encode_policies.py index f773f64..2c096d3 100644 --- a/fit_tool/tests/test_encode_policies.py +++ b/fit_tool/tests/test_encode_policies.py @@ -54,6 +54,32 @@ def test_mode_string(self): opts = resolve_encode_options(mode='canonical') self.assertEqual(opts.mode, EncodeMode.CANONICAL) + def test_preserve_overrides_options_mode(self): + opts = resolve_encode_options( + options=EncodeOptions(mode=EncodeMode.CANONICAL), + preserve=True, + ) + self.assertEqual(opts.mode, EncodeMode.PRESERVE) + + def test_mode_overrides_preserve_with_options(self): + opts = resolve_encode_options( + options=EncodeOptions(mode=EncodeMode.PRESERVE), + mode=EncodeMode.CANONICAL, + preserve=True, + ) + self.assertEqual(opts.mode, EncodeMode.CANONICAL) + + def test_explicit_strict_false_overrides_options_strict(self): + opts = resolve_encode_options(options=EncodeOptions(strict=True), strict=False) + self.assertFalse(opts.strict) + + def test_omitted_kwargs_inherit_options(self): + base = EncodeOptions(mode=EncodeMode.CANONICAL, strict=True, check_crc=False) + opts = resolve_encode_options(options=base) + self.assertIs(opts.mode, EncodeMode.CANONICAL) + self.assertTrue(opts.strict) + self.assertFalse(opts.check_crc) + class TestPreserveModeInvariants(unittest.TestCase): def test_untouched_identity(self): @@ -63,6 +89,15 @@ def test_untouched_identity(self): # Legacy alias self.assertEqual(fit.to_bytes(preserve=True), raw) + def test_explicit_preserve_overrides_options_mode(self): + raw = build_record_with_unknown_field(heart_rate=101) + fit = FitFile.from_bytes(raw) + out = fit.to_bytes( + options=EncodeOptions(mode=EncodeMode.CANONICAL), + preserve=True, + ) + self.assertEqual(out, raw) + def test_dirty_unknown_survives_preserve(self): raw = build_record_with_unknown_field( heart_rate=100, unknown_field_id=250, unknown_value=0xBEEF, diff --git a/news/82.bugfix b/news/82.bugfix new file mode 100644 index 0000000..b2e4efb --- /dev/null +++ b/news/82.bugfix @@ -0,0 +1 @@ +`to_bytes(preserve=...)` now overrides `EncodeOptions.mode` instead of being silently ignored, and an explicit `strict=False` or `check_crc=...` overrides an options object. Omitted `strict` / `check_crc` still inherit from `options=`.