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 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
@@ -0,0 +1,3 @@
1
+ from chiptime.cli import main
2
+
3
+ raise SystemExit(main())
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)}"