chiptime 0.4.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.
- chiptime/__init__.py +43 -0
- chiptime/__main__.py +3 -0
- chiptime/_api.py +394 -0
- chiptime/canonical.py +128 -0
- chiptime/cli.py +361 -0
- chiptime/decode.py +807 -0
- chiptime/encode.py +306 -0
- chiptime/errors.py +214 -0
- chiptime/frames.py +524 -0
- chiptime/intake.py +116 -0
- chiptime/message.py +51 -0
- chiptime/metrics/__init__.py +138 -0
- chiptime/metrics/_basics.py +95 -0
- chiptime/metrics/insights.py +374 -0
- chiptime/metrics/intervals.py +564 -0
- chiptime/metrics/load.py +254 -0
- chiptime/metrics/pacing.py +224 -0
- chiptime/metrics/settings.py +25 -0
- chiptime/metrics/sports.py +108 -0
- chiptime/metrics/zones.py +52 -0
- chiptime/model.py +283 -0
- chiptime/profile/__init__.py +61 -0
- chiptime/profile/base_types.py +62 -0
- chiptime/profile/core.py +454 -0
- chiptime/profile/generated.py +6136 -0
- chiptime/profile/registry.py +36 -0
- chiptime/py.typed +0 -0
- chiptime/repair.py +244 -0
- chiptime/result.py +362 -0
- chiptime/semantics/__init__.py +5 -0
- chiptime/semantics/build.py +572 -0
- chiptime/semantics/gaps.py +97 -0
- chiptime/semantics/plausibility.py +126 -0
- chiptime/semantics/reconcile.py +286 -0
- chiptime/semantics/timers.py +125 -0
- chiptime/validate.py +139 -0
- chiptime-0.4.0.dist-info/METADATA +60 -0
- chiptime-0.4.0.dist-info/RECORD +41 -0
- chiptime-0.4.0.dist-info/WHEEL +4 -0
- chiptime-0.4.0.dist-info/entry_points.txt +2 -0
- chiptime-0.4.0.dist-info/licenses/LICENSE +21 -0
chiptime/__init__.py
ADDED
|
@@ -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
|
+
]
|
chiptime/__main__.py
ADDED
chiptime/_api.py
ADDED
|
@@ -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
|
+
)
|
chiptime/canonical.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""RFC 8785 (JCS) canonical JSON serialization — the determinism contract.
|
|
2
|
+
|
|
3
|
+
See ADR-0002. Accepts only None/bool/int/float/str/list/dict trees. Refuses
|
|
4
|
+
NaN/Infinity and integers beyond +/-(2**53 - 1): those must be handled by the
|
|
5
|
+
shaping layer (null with diagnostic, or decimal string) before serialization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
MAX_SAFE_INT = 2**53 - 1
|
|
13
|
+
|
|
14
|
+
_ESCAPES = {
|
|
15
|
+
"\\": "\\\\",
|
|
16
|
+
'"': '\\"',
|
|
17
|
+
"\b": "\\b",
|
|
18
|
+
"\f": "\\f",
|
|
19
|
+
"\n": "\\n",
|
|
20
|
+
"\r": "\\r",
|
|
21
|
+
"\t": "\\t",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CanonicalizationError(ValueError):
|
|
26
|
+
"""A value that must never reach serialization did (bug guard, ADR-0002)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def dumps(obj: Any) -> bytes:
|
|
30
|
+
"""Serialize to canonical JSON bytes (UTF-8, JCS rules)."""
|
|
31
|
+
parts: list[str] = []
|
|
32
|
+
_write(obj, parts)
|
|
33
|
+
return "".join(parts).encode("utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write(obj: Any, out: list[str]) -> None:
|
|
37
|
+
if obj is None:
|
|
38
|
+
out.append("null")
|
|
39
|
+
elif obj is True:
|
|
40
|
+
out.append("true")
|
|
41
|
+
elif obj is False:
|
|
42
|
+
out.append("false")
|
|
43
|
+
elif isinstance(obj, str):
|
|
44
|
+
out.append(_string(obj))
|
|
45
|
+
elif isinstance(obj, int): # bool handled above
|
|
46
|
+
if abs(obj) > MAX_SAFE_INT:
|
|
47
|
+
raise CanonicalizationError(
|
|
48
|
+
f"integer {obj} exceeds 2**53-1; shape layer must serialize it as a string"
|
|
49
|
+
)
|
|
50
|
+
out.append(str(obj))
|
|
51
|
+
elif isinstance(obj, float):
|
|
52
|
+
out.append(number(obj))
|
|
53
|
+
elif isinstance(obj, list):
|
|
54
|
+
out.append("[")
|
|
55
|
+
for i, item in enumerate(obj):
|
|
56
|
+
if i:
|
|
57
|
+
out.append(",")
|
|
58
|
+
_write(item, out)
|
|
59
|
+
out.append("]")
|
|
60
|
+
elif isinstance(obj, dict):
|
|
61
|
+
out.append("{")
|
|
62
|
+
keys = list(obj.keys())
|
|
63
|
+
for k in keys:
|
|
64
|
+
if not isinstance(k, str):
|
|
65
|
+
raise CanonicalizationError(f"non-string key {k!r}")
|
|
66
|
+
# JCS: sort by UTF-16 code units, not code points.
|
|
67
|
+
keys.sort(key=lambda s: s.encode("utf-16-be"))
|
|
68
|
+
for i, k in enumerate(keys):
|
|
69
|
+
if i:
|
|
70
|
+
out.append(",")
|
|
71
|
+
out.append(_string(k))
|
|
72
|
+
out.append(":")
|
|
73
|
+
_write(obj[k], out)
|
|
74
|
+
out.append("}")
|
|
75
|
+
else:
|
|
76
|
+
raise CanonicalizationError(f"unserializable type {type(obj).__name__}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _string(s: str) -> str:
|
|
80
|
+
out = ['"']
|
|
81
|
+
for ch in s:
|
|
82
|
+
esc = _ESCAPES.get(ch)
|
|
83
|
+
if esc is not None:
|
|
84
|
+
out.append(esc)
|
|
85
|
+
elif ch < "\x20":
|
|
86
|
+
out.append(f"\\u{ord(ch):04x}")
|
|
87
|
+
else:
|
|
88
|
+
out.append(ch)
|
|
89
|
+
out.append('"')
|
|
90
|
+
return "".join(out)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def number(x: float) -> str:
|
|
94
|
+
"""Format a float per ECMAScript Number::toString (JCS requirement)."""
|
|
95
|
+
if x != x or x in (float("inf"), float("-inf")):
|
|
96
|
+
raise CanonicalizationError(
|
|
97
|
+
"NaN/Infinity must be nulled (with a diagnostic) before serialization"
|
|
98
|
+
)
|
|
99
|
+
if x == 0.0:
|
|
100
|
+
return "0" # covers -0.0 too
|
|
101
|
+
if x < 0:
|
|
102
|
+
return "-" + number(-x)
|
|
103
|
+
|
|
104
|
+
# Python repr is shortest-round-trip, same digit selection as ES6;
|
|
105
|
+
# only the presentation rules differ. Parse repr into digits + exponent.
|
|
106
|
+
r = repr(x)
|
|
107
|
+
mantissa, _, exp_s = r.partition("e")
|
|
108
|
+
exp = int(exp_s) if exp_s else 0
|
|
109
|
+
int_part, _, frac_part = mantissa.partition(".")
|
|
110
|
+
digits = (int_part + frac_part).lstrip("0")
|
|
111
|
+
exp10 = exp - len(frac_part) # value == int(digits) * 10**exp10 (pre-strip)
|
|
112
|
+
stripped = len(digits) - len(digits.rstrip("0"))
|
|
113
|
+
digits = digits.rstrip("0")
|
|
114
|
+
exp10 += stripped
|
|
115
|
+
|
|
116
|
+
k = len(digits)
|
|
117
|
+
n = exp10 + k # value == 0.digits * 10**n
|
|
118
|
+
|
|
119
|
+
if k <= n <= 21:
|
|
120
|
+
return digits + "0" * (n - k)
|
|
121
|
+
if 0 < n <= 21:
|
|
122
|
+
return digits[:n] + "." + digits[n:]
|
|
123
|
+
if -6 < n <= 0:
|
|
124
|
+
return "0." + "0" * (-n) + digits
|
|
125
|
+
# exponential form: d[.ddd]e±(n-1)
|
|
126
|
+
head = digits[0] if k == 1 else digits[0] + "." + digits[1:]
|
|
127
|
+
e = n - 1
|
|
128
|
+
return f"{head}e{'+' if e >= 0 else '-'}{abs(e)}"
|