chiptime 0.4.0__tar.gz
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.
- chiptime-0.4.0/.gitignore +35 -0
- chiptime-0.4.0/.python-version +1 -0
- chiptime-0.4.0/LICENSE +21 -0
- chiptime-0.4.0/PKG-INFO +60 -0
- chiptime-0.4.0/README.md +33 -0
- chiptime-0.4.0/pyproject.toml +73 -0
- chiptime-0.4.0/src/chiptime/__init__.py +43 -0
- chiptime-0.4.0/src/chiptime/__main__.py +3 -0
- chiptime-0.4.0/src/chiptime/_api.py +394 -0
- chiptime-0.4.0/src/chiptime/canonical.py +128 -0
- chiptime-0.4.0/src/chiptime/cli.py +361 -0
- chiptime-0.4.0/src/chiptime/decode.py +807 -0
- chiptime-0.4.0/src/chiptime/encode.py +306 -0
- chiptime-0.4.0/src/chiptime/errors.py +214 -0
- chiptime-0.4.0/src/chiptime/frames.py +524 -0
- chiptime-0.4.0/src/chiptime/intake.py +116 -0
- chiptime-0.4.0/src/chiptime/message.py +51 -0
- chiptime-0.4.0/src/chiptime/metrics/__init__.py +138 -0
- chiptime-0.4.0/src/chiptime/metrics/_basics.py +95 -0
- chiptime-0.4.0/src/chiptime/metrics/insights.py +374 -0
- chiptime-0.4.0/src/chiptime/metrics/intervals.py +564 -0
- chiptime-0.4.0/src/chiptime/metrics/load.py +254 -0
- chiptime-0.4.0/src/chiptime/metrics/pacing.py +224 -0
- chiptime-0.4.0/src/chiptime/metrics/settings.py +25 -0
- chiptime-0.4.0/src/chiptime/metrics/sports.py +108 -0
- chiptime-0.4.0/src/chiptime/metrics/zones.py +52 -0
- chiptime-0.4.0/src/chiptime/model.py +283 -0
- chiptime-0.4.0/src/chiptime/profile/__init__.py +61 -0
- chiptime-0.4.0/src/chiptime/profile/base_types.py +62 -0
- chiptime-0.4.0/src/chiptime/profile/core.py +454 -0
- chiptime-0.4.0/src/chiptime/profile/generated.py +6136 -0
- chiptime-0.4.0/src/chiptime/profile/registry.py +36 -0
- chiptime-0.4.0/src/chiptime/py.typed +0 -0
- chiptime-0.4.0/src/chiptime/repair.py +244 -0
- chiptime-0.4.0/src/chiptime/result.py +362 -0
- chiptime-0.4.0/src/chiptime/semantics/__init__.py +5 -0
- chiptime-0.4.0/src/chiptime/semantics/build.py +572 -0
- chiptime-0.4.0/src/chiptime/semantics/gaps.py +97 -0
- chiptime-0.4.0/src/chiptime/semantics/plausibility.py +126 -0
- chiptime-0.4.0/src/chiptime/semantics/reconcile.py +286 -0
- chiptime-0.4.0/src/chiptime/semantics/timers.py +125 -0
- chiptime-0.4.0/src/chiptime/validate.py +139 -0
- chiptime-0.4.0/tests/conformance/test_corpus.py +98 -0
- chiptime-0.4.0/tests/conftest.py +5 -0
- chiptime-0.4.0/tests/test_canonical.py +81 -0
- chiptime-0.4.0/tests/test_cli.py +89 -0
- chiptime-0.4.0/tests/test_decode.py +266 -0
- chiptime-0.4.0/tests/test_devfields.py +82 -0
- chiptime-0.4.0/tests/test_encode.py +117 -0
- chiptime-0.4.0/tests/test_hardening.py +88 -0
- chiptime-0.4.0/tests/test_insights_load.py +192 -0
- chiptime-0.4.0/tests/test_intake.py +83 -0
- chiptime-0.4.0/tests/test_intervals.py +187 -0
- chiptime-0.4.0/tests/test_metrics.py +90 -0
- chiptime-0.4.0/tests/test_mining_fixes.py +129 -0
- chiptime-0.4.0/tests/test_plausibility.py +59 -0
- chiptime-0.4.0/tests/test_reconcile.py +57 -0
- chiptime-0.4.0/tests/test_recovery.py +67 -0
- chiptime-0.4.0/tests/test_repair.py +103 -0
- chiptime-0.4.0/tests/test_semantics.py +74 -0
- chiptime-0.4.0/tests/test_sport_pacing.py +174 -0
- chiptime-0.4.0/tests/test_temporal.py +69 -0
- chiptime-0.4.0/tests/test_tier2_depth.py +153 -0
- chiptime-0.4.0/tests/test_validate.py +57 -0
- chiptime-0.4.0/tests/test_version.py +5 -0
- chiptime-0.4.0/uv.lock +1526 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# macOS
|
|
2
|
+
.DS_Store
|
|
3
|
+
|
|
4
|
+
# Python
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
htmlcov/
|
|
17
|
+
|
|
18
|
+
# Node (for the future JS implementation)
|
|
19
|
+
node_modules/
|
|
20
|
+
*.tsbuildinfo
|
|
21
|
+
|
|
22
|
+
# Editors
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
|
|
26
|
+
# Claude workspace — local-only, never committed
|
|
27
|
+
.claude/
|
|
28
|
+
CLAUDE.md
|
|
29
|
+
|
|
30
|
+
# Real-device corpus tier: NEVER committed (ADR-0007 — PII)
|
|
31
|
+
corpus/private/
|
|
32
|
+
.site-build/
|
|
33
|
+
|
|
34
|
+
# Internal-only research (competitive analysis stays private)
|
|
35
|
+
docs/internal/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
chiptime-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maxim Grigoryev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
chiptime-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: chiptime
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Recovery-grade FIT file processing: parse anything, lose nothing silently, explain everything.
|
|
5
|
+
Project-URL: Homepage, https://maxgrgrv.github.io/chiptime/
|
|
6
|
+
Project-URL: Documentation, https://maxgrgrv.github.io/chiptime/
|
|
7
|
+
Project-URL: Repository, https://github.com/MaxGrgrv/chiptime
|
|
8
|
+
Project-URL: Changelog, https://maxgrgrv.github.io/chiptime/changelog/
|
|
9
|
+
Project-URL: Issues, https://github.com/MaxGrgrv/chiptime/issues
|
|
10
|
+
Author: Maxim Grigoryev
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: activity,cycling,fit,fitness,garmin,parser,recovery,repair,running
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Provides-Extra: pandas
|
|
25
|
+
Requires-Dist: pandas>=2.0; extra == 'pandas'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# chiptime
|
|
29
|
+
|
|
30
|
+
**Recovery-grade FIT file processing.** Parse anything, lose nothing silently, explain everything. Zero runtime dependencies.
|
|
31
|
+
|
|
32
|
+
Every other open-source FIT parser stops at the first bad byte. chiptime resynchronizes mid-file, salvages truncated files, rebuilds missing session summaries, and can write the result back out as a valid, uploadable `.fit` — with a machine-readable provenance trail for every repair.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import chiptime
|
|
36
|
+
|
|
37
|
+
result = chiptime.parse("broken.fit") # lenient: recover + annotate
|
|
38
|
+
result.recovery # what was salvaged
|
|
39
|
+
result.activity.sessions[0].records.stream("power") # 0 is real, None is absent
|
|
40
|
+
result.to_canonical_json() # RFC 8785 — byte-identical every run
|
|
41
|
+
|
|
42
|
+
fixed = chiptime.repair("broken.fit") # → a valid .fit
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
chiptime parse ride.fit --json # agent exit codes: 0/2/3/4/64
|
|
47
|
+
chiptime repair broken.fit -o fixed.fit
|
|
48
|
+
chiptime validate fixed.fit --platform garmin-connect
|
|
49
|
+
chiptime inspect weird.fit # wire-level forensics
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- Three modes: `strict` / `lenient` / `forensic` (forensic never drops data)
|
|
53
|
+
- Sentinels → null before any statistics; zero ≠ null, always
|
|
54
|
+
- Deterministic canonical JSON; errors carry stable codes + suggested next steps
|
|
55
|
+
- Conformance corpus: 104-item edge-case taxonomy, golden-file tested
|
|
56
|
+
- Optional extras: `chiptime[pandas]` for DataFrames; `chiptime.metrics` for mean-max curves, zone time, SWOLF
|
|
57
|
+
|
|
58
|
+
Docs, taxonomy, corpus and research: https://github.com/MaxGrgrv/chiptime
|
|
59
|
+
|
|
60
|
+
chiptime is an independent project, not affiliated with or endorsed by Garmin. FIT and Garmin are trademarks of Garmin Ltd. This package ships no Garmin SDK files.
|
chiptime-0.4.0/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# chiptime
|
|
2
|
+
|
|
3
|
+
**Recovery-grade FIT file processing.** Parse anything, lose nothing silently, explain everything. Zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
Every other open-source FIT parser stops at the first bad byte. chiptime resynchronizes mid-file, salvages truncated files, rebuilds missing session summaries, and can write the result back out as a valid, uploadable `.fit` — with a machine-readable provenance trail for every repair.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import chiptime
|
|
9
|
+
|
|
10
|
+
result = chiptime.parse("broken.fit") # lenient: recover + annotate
|
|
11
|
+
result.recovery # what was salvaged
|
|
12
|
+
result.activity.sessions[0].records.stream("power") # 0 is real, None is absent
|
|
13
|
+
result.to_canonical_json() # RFC 8785 — byte-identical every run
|
|
14
|
+
|
|
15
|
+
fixed = chiptime.repair("broken.fit") # → a valid .fit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
chiptime parse ride.fit --json # agent exit codes: 0/2/3/4/64
|
|
20
|
+
chiptime repair broken.fit -o fixed.fit
|
|
21
|
+
chiptime validate fixed.fit --platform garmin-connect
|
|
22
|
+
chiptime inspect weird.fit # wire-level forensics
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- Three modes: `strict` / `lenient` / `forensic` (forensic never drops data)
|
|
26
|
+
- Sentinels → null before any statistics; zero ≠ null, always
|
|
27
|
+
- Deterministic canonical JSON; errors carry stable codes + suggested next steps
|
|
28
|
+
- Conformance corpus: 104-item edge-case taxonomy, golden-file tested
|
|
29
|
+
- Optional extras: `chiptime[pandas]` for DataFrames; `chiptime.metrics` for mean-max curves, zone time, SWOLF
|
|
30
|
+
|
|
31
|
+
Docs, taxonomy, corpus and research: https://github.com/MaxGrgrv/chiptime
|
|
32
|
+
|
|
33
|
+
chiptime is an independent project, not affiliated with or endorsed by Garmin. FIT and Garmin are trademarks of Garmin Ltd. This package ships no Garmin SDK files.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chiptime"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Recovery-grade FIT file processing: parse anything, lose nothing silently, explain everything."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Maxim Grigoryev" }]
|
|
13
|
+
keywords = ["fit", "garmin", "fitness", "activity", "parser", "recovery", "repair", "cycling", "running"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
# Contract: the core has ZERO runtime dependencies. Any addition here is a
|
|
26
|
+
# design decision requiring an ADR (see docs/dependencies/DEPENDENCY_MAP.md).
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://maxgrgrv.github.io/chiptime/"
|
|
31
|
+
Documentation = "https://maxgrgrv.github.io/chiptime/"
|
|
32
|
+
Repository = "https://github.com/MaxGrgrv/chiptime"
|
|
33
|
+
Changelog = "https://maxgrgrv.github.io/chiptime/changelog/"
|
|
34
|
+
Issues = "https://github.com/MaxGrgrv/chiptime/issues"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
pandas = ["pandas>=2.0"]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
chiptime = "chiptime.cli:main"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
docs = [
|
|
44
|
+
"mkdocs-material>=9.5",
|
|
45
|
+
"mkdocstrings[python]>=0.26",
|
|
46
|
+
"mkdocs-llmstxt>=0.2",
|
|
47
|
+
"mkdocs-git-revision-date-localized-plugin>=1.2",
|
|
48
|
+
]
|
|
49
|
+
dev = [
|
|
50
|
+
"pytest>=8.3",
|
|
51
|
+
"hypothesis>=6.112",
|
|
52
|
+
"mypy>=1.11",
|
|
53
|
+
"ruff>=0.6.8",
|
|
54
|
+
]
|
|
55
|
+
# Only for corpus/tools/scoreboard.py (M2) and profile cross-checks.
|
|
56
|
+
# Never installed by default; never imported by chiptime itself.
|
|
57
|
+
baselines = [
|
|
58
|
+
"fitparse>=1.2",
|
|
59
|
+
"fitdecode>=0.10",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.wheel]
|
|
63
|
+
packages = ["src/chiptime"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
[tool.mypy]
|
|
67
|
+
python_version = "3.11"
|
|
68
|
+
strict = true
|
|
69
|
+
files = ["src"]
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
testpaths = ["tests"]
|
|
73
|
+
addopts = "-q"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""chiptime — recovery-grade FIT file processing.
|
|
2
|
+
|
|
3
|
+
Parse anything, lose nothing silently, explain everything.
|
|
4
|
+
|
|
5
|
+
import chiptime
|
|
6
|
+
result = chiptime.parse("ride.fit") # lenient by default
|
|
7
|
+
result.ok, result.file_type, result.recovery
|
|
8
|
+
result.to_canonical_json() # deterministic (RFC 8785)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from chiptime._api import iter_frames, iter_messages, parse
|
|
12
|
+
from chiptime.errors import (
|
|
13
|
+
CrcMismatchError,
|
|
14
|
+
EmptyFileError,
|
|
15
|
+
FitError,
|
|
16
|
+
HeaderError,
|
|
17
|
+
NotFitError,
|
|
18
|
+
ProtocolError,
|
|
19
|
+
TruncatedError,
|
|
20
|
+
)
|
|
21
|
+
from chiptime.repair import NotRepairableError, RepairResult, repair
|
|
22
|
+
from chiptime.result import Mode, ParseResult
|
|
23
|
+
|
|
24
|
+
__version__ = "0.4.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CrcMismatchError",
|
|
28
|
+
"EmptyFileError",
|
|
29
|
+
"FitError",
|
|
30
|
+
"HeaderError",
|
|
31
|
+
"Mode",
|
|
32
|
+
"NotFitError",
|
|
33
|
+
"NotRepairableError",
|
|
34
|
+
"ParseResult",
|
|
35
|
+
"ProtocolError",
|
|
36
|
+
"RepairResult",
|
|
37
|
+
"TruncatedError",
|
|
38
|
+
"__version__",
|
|
39
|
+
"iter_frames",
|
|
40
|
+
"iter_messages",
|
|
41
|
+
"parse",
|
|
42
|
+
"repair",
|
|
43
|
+
]
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""parse() — the one-call entry point; iter_frames/iter_messages streaming layers.
|
|
2
|
+
|
|
3
|
+
Mode policy per ADR-0003: strict raises the first defect; lenient recovers and
|
|
4
|
+
records; forensic is lenient that never drops (divergence begins in F5/F10).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import hashlib
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from os import PathLike
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, BinaryIO
|
|
15
|
+
|
|
16
|
+
from chiptime.decode import Decoder
|
|
17
|
+
from chiptime.errors import (
|
|
18
|
+
Defect,
|
|
19
|
+
Diagnostic,
|
|
20
|
+
FitError,
|
|
21
|
+
ProvenanceEntry,
|
|
22
|
+
defect_to_error,
|
|
23
|
+
)
|
|
24
|
+
from chiptime.frames import (
|
|
25
|
+
CrcFrame,
|
|
26
|
+
DataFrame,
|
|
27
|
+
EndOfStream,
|
|
28
|
+
FileHeader,
|
|
29
|
+
FrameEvent,
|
|
30
|
+
SkippedBytes,
|
|
31
|
+
read_stream,
|
|
32
|
+
)
|
|
33
|
+
from chiptime.intake import unwrap
|
|
34
|
+
from chiptime.message import FieldValue, Message
|
|
35
|
+
from chiptime.result import FitPart, Mode, ParseResult, RecoveryReport, SourceInfo
|
|
36
|
+
from chiptime.semantics import build_activity
|
|
37
|
+
|
|
38
|
+
Source = str | PathLike[str] | bytes | bytearray | BinaryIO
|
|
39
|
+
|
|
40
|
+
# Structural defects that do NOT stop the stream — they surface as warnings
|
|
41
|
+
# in lenient/forensic ("seen and continued").
|
|
42
|
+
_CONTINUE_CODES = {
|
|
43
|
+
"FIT_HEADER_INVALID",
|
|
44
|
+
"FIT_HEADER_CRC_MISMATCH",
|
|
45
|
+
"FIT_CRC_MISMATCH",
|
|
46
|
+
"FIT_CRC_MISSING",
|
|
47
|
+
"FIT_DATA_SIZE_MISMATCH",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_SUGGESTIONS = {
|
|
51
|
+
"NOT_FIT_FORMAT": "route this file to a parser for the named format",
|
|
52
|
+
"FIT_TRUNCATED": 'rerun with mode="lenient" to salvage the decodable prefix',
|
|
53
|
+
"FIT_CRC_MISMATCH": 'rerun with mode="lenient" to decode despite the bad CRC',
|
|
54
|
+
"FIT_HEADER_CRC_MISMATCH": 'rerun with mode="lenient" to decode despite the bad header CRC',
|
|
55
|
+
"FIT_UNDEFINED_LOCAL_TYPE": 'rerun with mode="lenient" to salvage the decodable prefix',
|
|
56
|
+
"FIT_DEFINITION_INVALID": 'rerun with mode="lenient" to salvage the decodable prefix',
|
|
57
|
+
"FIT_DATA_SIZE_MISMATCH": 'rerun with mode="lenient" to parse the actual content',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_PII_MESSAGES = {"user_profile"}
|
|
61
|
+
_PII_FIELDS = {"serial_number"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_source(src: Source) -> tuple[bytes, str | None]:
|
|
65
|
+
if isinstance(src, (bytes, bytearray)):
|
|
66
|
+
return bytes(src), None
|
|
67
|
+
if isinstance(src, (str, PathLike)):
|
|
68
|
+
p = Path(src)
|
|
69
|
+
return p.read_bytes(), str(p)
|
|
70
|
+
data = src.read()
|
|
71
|
+
return bytes(data), getattr(src, "name", None)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def iter_frames(src: Source, *, mode: Mode = "lenient") -> Iterator[FrameEvent]:
|
|
75
|
+
"""Lossless wire-level frame events (forensics layer)."""
|
|
76
|
+
data, _ = _read_source(src)
|
|
77
|
+
offset = 0
|
|
78
|
+
while offset < len(data):
|
|
79
|
+
consumed = offset
|
|
80
|
+
for ev in read_stream(data, offset=offset):
|
|
81
|
+
if isinstance(ev, Defect) and mode == "strict":
|
|
82
|
+
raise defect_to_error(ev, suggestion=_SUGGESTIONS.get(ev.code))
|
|
83
|
+
if isinstance(ev, EndOfStream):
|
|
84
|
+
consumed = ev.consumed
|
|
85
|
+
yield ev
|
|
86
|
+
if consumed <= offset or not _looks_like_header(data, consumed):
|
|
87
|
+
break
|
|
88
|
+
offset = consumed
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def iter_messages(src: Source, *, mode: Mode = "lenient") -> Iterator[Message]:
|
|
92
|
+
"""Profile-applied message stream without building the semantic model."""
|
|
93
|
+
decoder = Decoder()
|
|
94
|
+
for ev in iter_frames(src, mode=mode):
|
|
95
|
+
if isinstance(ev, DataFrame):
|
|
96
|
+
yield decoder.decode(ev)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _looks_like_header(data: bytes, offset: int) -> bool:
|
|
100
|
+
if len(data) - offset < 12:
|
|
101
|
+
return False
|
|
102
|
+
return data[offset + 8 : offset + 12] == b".FIT" or data[offset] in (12, 14)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse(
|
|
106
|
+
src: Source,
|
|
107
|
+
*,
|
|
108
|
+
mode: Mode = "lenient",
|
|
109
|
+
strip_pii: bool = False,
|
|
110
|
+
include_unknown: bool = True,
|
|
111
|
+
include_raw: bool = False,
|
|
112
|
+
) -> ParseResult:
|
|
113
|
+
"""Parse a FIT source. lenient (default) recovers and annotates; strict
|
|
114
|
+
raises the first FitError; forensic maximizes salvage and never drops."""
|
|
115
|
+
raw, path = _read_source(src)
|
|
116
|
+
source_hash = hashlib.sha256(raw).hexdigest()
|
|
117
|
+
intake_result = unwrap(raw)
|
|
118
|
+
data = intake_result.data
|
|
119
|
+
source = SourceInfo(
|
|
120
|
+
path=path,
|
|
121
|
+
size_bytes=len(raw),
|
|
122
|
+
sha256=source_hash,
|
|
123
|
+
unwrapped=intake_result.unwrapped,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
parts: list[FitPart] = []
|
|
127
|
+
provenance: list[ProvenanceEntry] = list(intake_result.provenance)
|
|
128
|
+
warnings: list[Diagnostic] = []
|
|
129
|
+
errors: list[FitError] = []
|
|
130
|
+
|
|
131
|
+
for d in intake_result.defects:
|
|
132
|
+
if mode == "strict":
|
|
133
|
+
raise defect_to_error(d, suggestion=_SUGGESTIONS.get(d.code))
|
|
134
|
+
errors.append(defect_to_error(d, suggestion=_SUGGESTIONS.get(d.code)))
|
|
135
|
+
if any(d.severity == "fatal" for d in intake_result.defects):
|
|
136
|
+
return ParseResult(
|
|
137
|
+
ok=False,
|
|
138
|
+
mode=mode,
|
|
139
|
+
source=source,
|
|
140
|
+
parts=[],
|
|
141
|
+
provenance=provenance,
|
|
142
|
+
warnings=warnings,
|
|
143
|
+
errors=errors,
|
|
144
|
+
recovery=None,
|
|
145
|
+
include_raw=include_raw,
|
|
146
|
+
)
|
|
147
|
+
total_recovered = 0
|
|
148
|
+
total_skipped = 0
|
|
149
|
+
resync_count = 0
|
|
150
|
+
recovery_engaged = False
|
|
151
|
+
est_total: int | None = None
|
|
152
|
+
|
|
153
|
+
offset = 0
|
|
154
|
+
part_index = 0
|
|
155
|
+
while True: # runs at least once so empty input still yields its defect
|
|
156
|
+
decoder = Decoder()
|
|
157
|
+
messages: list[Message] = []
|
|
158
|
+
stream_defects: list[Defect] = []
|
|
159
|
+
skips: list[SkippedBytes] = []
|
|
160
|
+
header: FileHeader | None = None
|
|
161
|
+
consumed = len(data)
|
|
162
|
+
body_bytes_decoded = 0
|
|
163
|
+
|
|
164
|
+
for ev in read_stream(data, offset=offset):
|
|
165
|
+
if isinstance(ev, Defect):
|
|
166
|
+
if mode == "strict":
|
|
167
|
+
raise defect_to_error(ev, suggestion=_SUGGESTIONS.get(ev.code))
|
|
168
|
+
stream_defects.append(ev)
|
|
169
|
+
elif isinstance(ev, SkippedBytes):
|
|
170
|
+
skips.append(ev)
|
|
171
|
+
elif isinstance(ev, DataFrame):
|
|
172
|
+
messages.append(decoder.decode(ev))
|
|
173
|
+
body_bytes_decoded = (
|
|
174
|
+
ev.offset
|
|
175
|
+
+ 1
|
|
176
|
+
+ len(ev.payload)
|
|
177
|
+
- (header.offset + header.size if header else offset)
|
|
178
|
+
)
|
|
179
|
+
elif isinstance(ev, FileHeader):
|
|
180
|
+
header = ev
|
|
181
|
+
elif isinstance(ev, CrcFrame):
|
|
182
|
+
pass # mismatch already surfaced as a Defect
|
|
183
|
+
elif isinstance(ev, EndOfStream):
|
|
184
|
+
consumed = ev.consumed
|
|
185
|
+
|
|
186
|
+
decode_out = decoder.finish()
|
|
187
|
+
messages = decode_out.messages # finish() may rebuild (late dev-field back-fill)
|
|
188
|
+
provenance.extend(decode_out.provenance)
|
|
189
|
+
warnings.extend(decode_out.diagnostics)
|
|
190
|
+
for d in decode_out.defects: # data-severity defects from decoding
|
|
191
|
+
if mode == "strict":
|
|
192
|
+
raise defect_to_error(d, suggestion=_SUGGESTIONS.get(d.code))
|
|
193
|
+
warnings.append(Diagnostic(d.code, d.detail, f"byte {d.offset}"))
|
|
194
|
+
|
|
195
|
+
scope = f"part[{part_index}]"
|
|
196
|
+
skip_offsets = {s.offset for s in skips}
|
|
197
|
+
for skip in skips:
|
|
198
|
+
recovery_engaged = True
|
|
199
|
+
total_skipped += skip.length
|
|
200
|
+
if skip.reason == "preamble-garbage":
|
|
201
|
+
provenance.append(
|
|
202
|
+
ProvenanceEntry(
|
|
203
|
+
"PREAMBLE_GARBAGE_SKIPPED",
|
|
204
|
+
"repaired",
|
|
205
|
+
scope,
|
|
206
|
+
f"skipped {skip.length} garbage byte(s) before the FIT header",
|
|
207
|
+
byte_offset=skip.offset,
|
|
208
|
+
data={"length": skip.length},
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
resync_count += 1
|
|
213
|
+
provenance.append(
|
|
214
|
+
ProvenanceEntry(
|
|
215
|
+
"RESYNC_SKIPPED_BYTES",
|
|
216
|
+
"repaired",
|
|
217
|
+
scope,
|
|
218
|
+
f"skipped {skip.length} undecodable byte(s) after {skip.reason}"
|
|
219
|
+
f" at offset {skip.offset}; decoding resumed",
|
|
220
|
+
byte_offset=skip.offset,
|
|
221
|
+
data={"length": skip.length, "defect_code": skip.reason},
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
for defect in stream_defects:
|
|
225
|
+
if defect.offset in skip_offsets and defect.severity == "structural":
|
|
226
|
+
continue # resynchronized: the SkippedBytes provenance tells the story
|
|
227
|
+
if defect.severity == "fatal":
|
|
228
|
+
errors.append(defect_to_error(defect, suggestion=_SUGGESTIONS.get(defect.code)))
|
|
229
|
+
elif defect.code in _CONTINUE_CODES:
|
|
230
|
+
warnings.append(Diagnostic(defect.code, defect.detail, f"byte {defect.offset}"))
|
|
231
|
+
else:
|
|
232
|
+
# Structural defect that stopped the stream: prefix salvage (F5 → resync).
|
|
233
|
+
recovery_engaged = True
|
|
234
|
+
code = (
|
|
235
|
+
"TRUNCATED_TAIL_SALVAGED"
|
|
236
|
+
if defect.code == "FIT_TRUNCATED"
|
|
237
|
+
else "STREAM_STOPPED_AT_DEFECT"
|
|
238
|
+
)
|
|
239
|
+
provenance.append(
|
|
240
|
+
ProvenanceEntry(
|
|
241
|
+
code,
|
|
242
|
+
"repaired",
|
|
243
|
+
scope,
|
|
244
|
+
f"{defect.detail}; salvaged {len(messages)} complete message(s)",
|
|
245
|
+
byte_offset=defect.offset,
|
|
246
|
+
data={"defect_code": defect.code},
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
if (
|
|
250
|
+
defect.code == "FIT_TRUNCATED"
|
|
251
|
+
and header is not None
|
|
252
|
+
and header.data_size
|
|
253
|
+
and body_bytes_decoded > 0
|
|
254
|
+
):
|
|
255
|
+
est_total = round(len(messages) * header.data_size / body_bytes_decoded)
|
|
256
|
+
|
|
257
|
+
if messages or header is not None:
|
|
258
|
+
part = _build_part(messages)
|
|
259
|
+
if strip_pii:
|
|
260
|
+
_strip_pii(part, provenance, scope)
|
|
261
|
+
if not include_unknown:
|
|
262
|
+
_drop_unknown(part, provenance, scope)
|
|
263
|
+
if part.file_type == "activity":
|
|
264
|
+
part.activity = build_activity(
|
|
265
|
+
part.messages,
|
|
266
|
+
warnings,
|
|
267
|
+
provenance,
|
|
268
|
+
scope,
|
|
269
|
+
skipped_ranges=[(sk.offset, sk.offset + sk.length) for sk in skips],
|
|
270
|
+
forensic=(mode == "forensic"),
|
|
271
|
+
)
|
|
272
|
+
parts.append(part)
|
|
273
|
+
total_recovered += len(messages)
|
|
274
|
+
|
|
275
|
+
part_index += 1
|
|
276
|
+
if consumed <= offset:
|
|
277
|
+
break
|
|
278
|
+
offset = consumed
|
|
279
|
+
if offset >= len(data):
|
|
280
|
+
break
|
|
281
|
+
if not _looks_like_header(data, offset):
|
|
282
|
+
junk = Defect(
|
|
283
|
+
"FIT_TRAILING_JUNK",
|
|
284
|
+
f"{len(data) - offset} byte(s) after the final CRC are not a chained FIT file",
|
|
285
|
+
offset,
|
|
286
|
+
"structural",
|
|
287
|
+
)
|
|
288
|
+
if mode == "strict":
|
|
289
|
+
raise defect_to_error(junk)
|
|
290
|
+
if not any(
|
|
291
|
+
d.severity == "structural" and d.code not in _CONTINUE_CODES for d in stream_defects
|
|
292
|
+
):
|
|
293
|
+
warnings.append(Diagnostic(junk.code, junk.detail, f"byte {offset}"))
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
ok = any(p.messages for p in parts) and not any(
|
|
297
|
+
e.code in ("FIT_EMPTY", "FIT_TOO_SMALL", "NOT_FIT_FORMAT") for e in errors
|
|
298
|
+
)
|
|
299
|
+
if not ok and not errors:
|
|
300
|
+
# Contract #5: ok=false must always be explained. The valid-but-empty
|
|
301
|
+
# shell (taxonomy #16, seen in the wild as 16-byte tool output).
|
|
302
|
+
errors.append(
|
|
303
|
+
FitError(
|
|
304
|
+
"FIT_NO_CONTENT",
|
|
305
|
+
"structurally valid FIT container with no messages — the data is"
|
|
306
|
+
" genuinely absent, not recoverable",
|
|
307
|
+
suggestion="nothing to salvage; check the device/app that wrote it",
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
recovery = (
|
|
311
|
+
RecoveryReport(
|
|
312
|
+
recovered_records=total_recovered,
|
|
313
|
+
estimated_total_records=est_total,
|
|
314
|
+
bytes_read=len(data),
|
|
315
|
+
bytes_skipped=total_skipped,
|
|
316
|
+
resync_count=resync_count,
|
|
317
|
+
)
|
|
318
|
+
if recovery_engaged
|
|
319
|
+
else None
|
|
320
|
+
)
|
|
321
|
+
return ParseResult(
|
|
322
|
+
ok=ok,
|
|
323
|
+
mode=mode,
|
|
324
|
+
source=source,
|
|
325
|
+
parts=parts,
|
|
326
|
+
provenance=provenance,
|
|
327
|
+
warnings=warnings,
|
|
328
|
+
errors=errors,
|
|
329
|
+
recovery=recovery,
|
|
330
|
+
include_raw=include_raw,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _build_part(messages: list[Message]) -> FitPart:
|
|
335
|
+
file_id: dict[str, Any] | None = None
|
|
336
|
+
file_type = "unknown"
|
|
337
|
+
for m in messages:
|
|
338
|
+
if m.global_num == 0:
|
|
339
|
+
file_id = {k: fv.value for k, fv in m.fields.items()}
|
|
340
|
+
t = m.get("type")
|
|
341
|
+
if isinstance(t, str):
|
|
342
|
+
file_type = t
|
|
343
|
+
elif t is not None:
|
|
344
|
+
file_type = f"unknown_{t}"
|
|
345
|
+
break
|
|
346
|
+
return FitPart(file_type=file_type, file_id=file_id, messages=messages)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _strip_pii(part: FitPart, provenance: list[ProvenanceEntry], scope: str) -> None:
|
|
350
|
+
removed_msgs = 0
|
|
351
|
+
nulled_fields = 0
|
|
352
|
+
kept: list[Message] = []
|
|
353
|
+
for m in part.messages:
|
|
354
|
+
if m.name in _PII_MESSAGES:
|
|
355
|
+
removed_msgs += 1
|
|
356
|
+
continue
|
|
357
|
+
if any(f in m.fields for f in _PII_FIELDS):
|
|
358
|
+
fields = dict(m.fields)
|
|
359
|
+
for f in _PII_FIELDS:
|
|
360
|
+
if f in fields:
|
|
361
|
+
fields[f] = FieldValue(None, None, fields[f].units)
|
|
362
|
+
nulled_fields += 1
|
|
363
|
+
m = dataclasses.replace(m, fields=fields)
|
|
364
|
+
kept.append(m)
|
|
365
|
+
part.messages = kept
|
|
366
|
+
if part.file_id and "serial_number" in part.file_id:
|
|
367
|
+
part.file_id["serial_number"] = None
|
|
368
|
+
if removed_msgs or nulled_fields:
|
|
369
|
+
provenance.append(
|
|
370
|
+
ProvenanceEntry(
|
|
371
|
+
"PII_STRIPPED",
|
|
372
|
+
"dropped",
|
|
373
|
+
scope,
|
|
374
|
+
f"removed {removed_msgs} PII message(s), nulled {nulled_fields}"
|
|
375
|
+
f" serial-number field(s) (strip_pii=True)",
|
|
376
|
+
data={"messages_removed": removed_msgs, "fields_nulled": nulled_fields},
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _drop_unknown(part: FitPart, provenance: list[ProvenanceEntry], scope: str) -> None:
|
|
382
|
+
known = [m for m in part.messages if not m.name.startswith("unknown_")]
|
|
383
|
+
dropped = len(part.messages) - len(known)
|
|
384
|
+
part.messages = known
|
|
385
|
+
if dropped:
|
|
386
|
+
provenance.append(
|
|
387
|
+
ProvenanceEntry(
|
|
388
|
+
"UNKNOWN_MESSAGES_OMITTED",
|
|
389
|
+
"ignored",
|
|
390
|
+
scope,
|
|
391
|
+
f"{dropped} unknown message(s) omitted from output (include_unknown=False)",
|
|
392
|
+
data={"count": dropped},
|
|
393
|
+
)
|
|
394
|
+
)
|