robotics-runtime-contracts 0.17.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.
Files changed (67) hide show
  1. robotics_runtime_contracts/__init__.py +281 -0
  2. robotics_runtime_contracts/_mcap_crc.py +101 -0
  3. robotics_runtime_contracts/_mcap_observations.py +91 -0
  4. robotics_runtime_contracts/_mcap_summary.py +118 -0
  5. robotics_runtime_contracts/_merge_patch.py +54 -0
  6. robotics_runtime_contracts/_qualification.py +1195 -0
  7. robotics_runtime_contracts/_qualification_checks.py +400 -0
  8. robotics_runtime_contracts/_qualification_files.py +102 -0
  9. robotics_runtime_contracts/_qualification_types.py +104 -0
  10. robotics_runtime_contracts/_resources.py +19 -0
  11. robotics_runtime_contracts/_timestamps.py +22 -0
  12. robotics_runtime_contracts/_writer_cli.py +179 -0
  13. robotics_runtime_contracts/canonical.py +180 -0
  14. robotics_runtime_contracts/catalog.py +54 -0
  15. robotics_runtime_contracts/cli.py +438 -0
  16. robotics_runtime_contracts/document_ops.py +187 -0
  17. robotics_runtime_contracts/errors.py +25 -0
  18. robotics_runtime_contracts/extensions.py +281 -0
  19. robotics_runtime_contracts/providers.py +66 -0
  20. robotics_runtime_contracts/py.typed +0 -0
  21. robotics_runtime_contracts/qualification.py +118 -0
  22. robotics_runtime_contracts/qualification_policy.py +178 -0
  23. robotics_runtime_contracts/receipts.py +59 -0
  24. robotics_runtime_contracts/recordings.py +26 -0
  25. robotics_runtime_contracts/schemas/__init__.py +0 -0
  26. robotics_runtime_contracts/schemas/acceptance-aggregate.v1.schema.json +157 -0
  27. robotics_runtime_contracts/schemas/acceptance-observation.v1.schema.json +38 -0
  28. robotics_runtime_contracts/schemas/acceptance-result-core.v1.schema.json +1027 -0
  29. robotics_runtime_contracts/schemas/acceptance-result.v1.schema.json +442 -0
  30. robotics_runtime_contracts/schemas/acceptance-run.v1.schema.json +98 -0
  31. robotics_runtime_contracts/schemas/acceptance-scenario-core.v1.schema.json +1202 -0
  32. robotics_runtime_contracts/schemas/acceptance-scenario.v1.schema.json +485 -0
  33. robotics_runtime_contracts/schemas/artifact-receipt.v1.schema.json +68 -0
  34. robotics_runtime_contracts/schemas/artifact-verification.v1.schema.json +92 -0
  35. robotics_runtime_contracts/schemas/campaign-summary.v1.schema.json +167 -0
  36. robotics_runtime_contracts/schemas/catalog.v1.json +38 -0
  37. robotics_runtime_contracts/schemas/causal-chain.v1.schema.json +70 -0
  38. robotics_runtime_contracts/schemas/clock-relation.v1.schema.json +204 -0
  39. robotics_runtime_contracts/schemas/common.v1.schema.json +243 -0
  40. robotics_runtime_contracts/schemas/conformance-result.v1.schema.json +117 -0
  41. robotics_runtime_contracts/schemas/dataset-manifest.v1.schema.json +340 -0
  42. robotics_runtime_contracts/schemas/evidence-index.v1.schema.json +257 -0
  43. robotics_runtime_contracts/schemas/execution-permit.v1.schema.json +226 -0
  44. robotics_runtime_contracts/schemas/execution-trust-policy.v1.schema.json +122 -0
  45. robotics_runtime_contracts/schemas/execution-verification.v1.schema.json +163 -0
  46. robotics_runtime_contracts/schemas/model-artifact-manifest.v1.schema.json +262 -0
  47. robotics_runtime_contracts/schemas/qualification-bundle.v1.schema.json +108 -0
  48. robotics_runtime_contracts/schemas/qualification-policy.v1.schema.json +94 -0
  49. robotics_runtime_contracts/schemas/qualification-profile.v1.schema.json +29 -0
  50. robotics_runtime_contracts/schemas/recording-summary.v1.schema.json +122 -0
  51. robotics_runtime_contracts/schemas/robot-description.v1.schema.json +212 -0
  52. robotics_runtime_contracts/schemas/runtime-manifest-core.v1.schema.json +808 -0
  53. robotics_runtime_contracts/schemas/runtime-manifest.v1.schema.json +126 -0
  54. robotics_runtime_contracts/schemas/transport-channel-observation.v1.schema.json +157 -0
  55. robotics_runtime_contracts/schemas/transport-channel.v1.schema.json +274 -0
  56. robotics_runtime_contracts/schemas/transport-qualification-result-core.v1.schema.json +484 -0
  57. robotics_runtime_contracts/schemas/transport-qualification-result.v1.schema.json +97 -0
  58. robotics_runtime_contracts/semantics.py +1274 -0
  59. robotics_runtime_contracts/serialization.py +244 -0
  60. robotics_runtime_contracts/statements.py +102 -0
  61. robotics_runtime_contracts/status.py +37 -0
  62. robotics_runtime_contracts/writers.py +298 -0
  63. robotics_runtime_contracts-0.17.0.dist-info/METADATA +351 -0
  64. robotics_runtime_contracts-0.17.0.dist-info/RECORD +67 -0
  65. robotics_runtime_contracts-0.17.0.dist-info/WHEEL +4 -0
  66. robotics_runtime_contracts-0.17.0.dist-info/entry_points.txt +2 -0
  67. robotics_runtime_contracts-0.17.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from copy import deepcopy
6
+ from functools import cache
7
+ from hashlib import sha256
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+
11
+ from jsonschema import Draft202012Validator, FormatChecker
12
+ from jsonschema.exceptions import ValidationError, best_match
13
+ from referencing import Registry
14
+ from referencing.exceptions import Unresolvable
15
+ from referencing.jsonschema import DRAFT202012, SchemaRegistry
16
+
17
+ from robotics_runtime_contracts._resources import schema_directory
18
+ from robotics_runtime_contracts.canonical import dumps_canonical, file_sha256
19
+ from robotics_runtime_contracts.catalog import (
20
+ UnknownContractRoleError,
21
+ contract_roles,
22
+ contract_set,
23
+ internal_schema_names,
24
+ role_schemas,
25
+ schema_for_role,
26
+ )
27
+ from robotics_runtime_contracts.errors import ContractError
28
+ from robotics_runtime_contracts.extensions import (
29
+ ExtensionValidationError,
30
+ )
31
+ from robotics_runtime_contracts.extensions import (
32
+ validate_extensions as _validate_extensions,
33
+ )
34
+ from robotics_runtime_contracts.providers import (
35
+ ProviderRequirementError,
36
+ scene_satisfies,
37
+ validate_provider_requirements,
38
+ )
39
+ from robotics_runtime_contracts.qualification_policy import (
40
+ CHANNEL_ERROR_VIOLATIONS,
41
+ CHANNEL_INCOMPLETE_VIOLATIONS,
42
+ RESERVED_ASSERTION_IDS,
43
+ RESERVED_METRIC_NAMES,
44
+ ChannelObservationStatus,
45
+ ClockEvidenceValidationError,
46
+ channel_observation_status,
47
+ derive_channel_violations,
48
+ hardware_clock_within_policy,
49
+ validate_clock_relation_evidence,
50
+ )
51
+ from robotics_runtime_contracts.receipts import (
52
+ ArtifactReceiptValidationError,
53
+ validate_artifact_receipt,
54
+ )
55
+ from robotics_runtime_contracts.semantics import (
56
+ SemanticValidationError,
57
+ )
58
+ from robotics_runtime_contracts.semantics import (
59
+ validate_semantics as _validate_semantics,
60
+ )
61
+ from robotics_runtime_contracts.serialization import (
62
+ DocumentParseError,
63
+ NonFiniteNumberError,
64
+ ensure_finite_numbers,
65
+ load_mapping,
66
+ loads_mapping,
67
+ )
68
+ from robotics_runtime_contracts.status import OutcomeStatus, worst_status
69
+
70
+ _PUBLIC_SCHEMA_FILES = {name: f"{name}.schema.json" for name in role_schemas().values()}
71
+ _INTERNAL_SCHEMA_FILES = {name: f"{name}.schema.json" for name in internal_schema_names()}
72
+ _SCHEMA_FILES = _PUBLIC_SCHEMA_FILES | _INTERNAL_SCHEMA_FILES
73
+
74
+
75
+ class ContractValidationError(ContractError):
76
+ """Raised when a document does not satisfy a public contract."""
77
+
78
+ error_id = "schema.validation_failed"
79
+
80
+ def __init__(self, schema_name: str, error: ValidationError) -> None:
81
+ self.schema_name = schema_name
82
+ self.json_path = error.json_path
83
+ self.validation_message = error.message
84
+ super().__init__(f"{self.json_path}: {self.validation_message}", json_path=error.json_path)
85
+
86
+
87
+ class UnknownSchemaError(ContractError):
88
+ """Raised when a requested schema version or identifier is not published."""
89
+
90
+ error_id = "schema.unknown"
91
+
92
+
93
+ def schema_names() -> tuple[str, ...]:
94
+ """Return canonical public schemas in stable role order."""
95
+
96
+ return tuple(_PUBLIC_SCHEMA_FILES)
97
+
98
+
99
+ def schema_resource_names() -> tuple[str, ...]:
100
+ """Return public and internal resources used by the offline registry."""
101
+
102
+ return tuple(_SCHEMA_FILES)
103
+
104
+
105
+ @cache
106
+ def _schema_ids() -> dict[str, str]:
107
+ return {
108
+ cast(str, json.loads((schema_dir() / file_name).read_text(encoding="utf-8"))["$id"]): name
109
+ for name, file_name in _SCHEMA_FILES.items()
110
+ }
111
+
112
+
113
+ def resolve_schema_name(schema: str) -> str:
114
+ """Resolve a schema version, file name, or canonical identifier."""
115
+
116
+ if schema in _SCHEMA_FILES:
117
+ return schema
118
+ if schema in _schema_ids():
119
+ return _schema_ids()[schema]
120
+ for schema_name, file_name in _SCHEMA_FILES.items():
121
+ if schema == file_name:
122
+ return schema_name
123
+ raise UnknownSchemaError(f"Unknown schema: {schema}")
124
+
125
+
126
+ def schema_dir() -> Path:
127
+ """Return a resource directory that remains available until process exit."""
128
+
129
+ return schema_directory()
130
+
131
+
132
+ def schema_path(schema: str) -> Path:
133
+ schema_name = resolve_schema_name(schema)
134
+ path = schema_dir() / _SCHEMA_FILES[schema_name]
135
+ if not path.is_file():
136
+ raise FileNotFoundError(path)
137
+ return path
138
+
139
+
140
+ def schema_digest(schema: str) -> str:
141
+ """Return the SHA-256 digest of the packaged schema resource."""
142
+
143
+ return sha256(schema_path(schema).read_bytes()).hexdigest()
144
+
145
+
146
+ @cache
147
+ def _load_schema(schema: str) -> dict[str, Any]:
148
+ schema_name = resolve_schema_name(schema)
149
+ return cast(
150
+ dict[str, Any],
151
+ json.loads(schema_path(schema_name).read_text(encoding="utf-8")),
152
+ )
153
+
154
+
155
+ def load_schema(schema: str) -> dict[str, Any]:
156
+ """Return an isolated copy of a published schema."""
157
+
158
+ return deepcopy(_load_schema(resolve_schema_name(schema)))
159
+
160
+
161
+ @cache
162
+ def _validator(schema: str) -> Draft202012Validator:
163
+ schema_name = resolve_schema_name(schema)
164
+ contract = _load_schema(schema_name)
165
+ Draft202012Validator.check_schema(contract)
166
+ return Draft202012Validator(
167
+ contract,
168
+ format_checker=FormatChecker(),
169
+ registry=_schema_registry(),
170
+ )
171
+
172
+
173
+ @cache
174
+ def _schema_registry() -> SchemaRegistry:
175
+ return Registry().with_resources(
176
+ (
177
+ schema["$id"],
178
+ DRAFT202012.create_resource(schema),
179
+ )
180
+ for name in _SCHEMA_FILES
181
+ for schema in (_load_schema(name),)
182
+ )
183
+
184
+
185
+ def schema_registry() -> SchemaRegistry:
186
+ """Return an isolated offline registry for all published schema IDs."""
187
+
188
+ return Registry().with_resources(
189
+ (
190
+ schema["$id"],
191
+ DRAFT202012.create_resource(schema),
192
+ )
193
+ for name in _SCHEMA_FILES
194
+ for schema in (load_schema(name),)
195
+ )
196
+
197
+
198
+ def validate_document(
199
+ document: Mapping[str, Any],
200
+ schema: str | None = None,
201
+ *,
202
+ extension_schemas: Mapping[str, bytes | str] | None = None,
203
+ ) -> None:
204
+ """Validate a document against an explicit or declared schema version."""
205
+
206
+ ensure_finite_numbers(document)
207
+ declared_schema = document.get("schema_version")
208
+ selected_schema = schema if schema is not None else declared_schema
209
+ if not isinstance(selected_schema, str):
210
+ raise UnknownSchemaError("Document must declare schema_version")
211
+ schema_name = resolve_schema_name(selected_schema)
212
+ try:
213
+ error = best_match(_validator(schema_name).iter_errors(document))
214
+ except Unresolvable as cause:
215
+ raise ContractError(
216
+ f"unresolvable schema reference: {cause.ref}",
217
+ error_id="schema.reference_invalid",
218
+ ) from cause
219
+ if error is not None:
220
+ raise ContractValidationError(schema_name, error) from error
221
+ _validate_semantics(schema_name, document)
222
+ _validate_extensions(schema_name, document, extension_schemas)
223
+
224
+
225
+ def validate_role(
226
+ document: Mapping[str, Any],
227
+ role: str,
228
+ *,
229
+ extension_schemas: Mapping[str, bytes | str] | None = None,
230
+ ) -> None:
231
+ """Validate a document against the canonical schema for its role."""
232
+
233
+ validate_document(document, schema_for_role(role), extension_schemas=extension_schemas)
234
+
235
+
236
+ __all__ = [
237
+ "CHANNEL_ERROR_VIOLATIONS",
238
+ "CHANNEL_INCOMPLETE_VIOLATIONS",
239
+ "OutcomeStatus",
240
+ "RESERVED_ASSERTION_IDS",
241
+ "RESERVED_METRIC_NAMES",
242
+ "ChannelObservationStatus",
243
+ "ClockEvidenceValidationError",
244
+ "ContractValidationError",
245
+ "ContractError",
246
+ "ArtifactReceiptValidationError",
247
+ "DocumentParseError",
248
+ "ExtensionValidationError",
249
+ "NonFiniteNumberError",
250
+ "ProviderRequirementError",
251
+ "SemanticValidationError",
252
+ "UnknownContractRoleError",
253
+ "UnknownSchemaError",
254
+ "contract_roles",
255
+ "contract_set",
256
+ "dumps_canonical",
257
+ "ensure_finite_numbers",
258
+ "file_sha256",
259
+ "load_schema",
260
+ "load_mapping",
261
+ "loads_mapping",
262
+ "resolve_schema_name",
263
+ "role_schemas",
264
+ "schema_digest",
265
+ "schema_dir",
266
+ "schema_for_role",
267
+ "schema_names",
268
+ "schema_path",
269
+ "schema_registry",
270
+ "schema_resource_names",
271
+ "scene_satisfies",
272
+ "channel_observation_status",
273
+ "derive_channel_violations",
274
+ "hardware_clock_within_policy",
275
+ "validate_clock_relation_evidence",
276
+ "validate_artifact_receipt",
277
+ "validate_document",
278
+ "validate_provider_requirements",
279
+ "validate_role",
280
+ "worst_status",
281
+ ]
@@ -0,0 +1,101 @@
1
+ """Verify the two CRC kinds not covered by MCAP 1.4's public StreamReader.
2
+
3
+ This is a checksum-range locator, not an MCAP decoder. It skips top-level
4
+ records using their fixed nine-byte envelopes and reads only attachment length
5
+ prefixes and the fixed footer. Decoding, decompression and data/chunk CRCs remain
6
+ upstream responsibilities. Attachment extension bytes after its CRC are allowed.
7
+ Ranges cover original bytes, never reserialized records, in bounded read blocks.
8
+ See https://mcap.dev/spec (Attachment and Footer).
9
+ """
10
+
11
+ from typing import IO
12
+ from zlib import crc32
13
+
14
+ from mcap.opcode import Opcode
15
+ from mcap.stream_reader import MAGIC_SIZE
16
+
17
+ from robotics_runtime_contracts.writers import WriterError
18
+
19
+
20
+ def _invalid(message: str) -> WriterError:
21
+ return WriterError(message, error_id="writer.invalid_mcap")
22
+
23
+
24
+ def _read_integer(stream: IO[bytes], size: int, end: int) -> int:
25
+ if stream.tell() + size > end:
26
+ raise _invalid("truncated MCAP checksum fields")
27
+ raw = stream.read(size)
28
+ if len(raw) != size:
29
+ raise _invalid("truncated MCAP checksum fields")
30
+ return int.from_bytes(raw, "little")
31
+
32
+
33
+ def _skip(stream: IO[bytes], size: int, end: int) -> None:
34
+ destination = stream.tell() + size
35
+ if destination > end:
36
+ raise _invalid("MCAP checksum field exceeds its record")
37
+ stream.seek(destination)
38
+
39
+
40
+ def _check_crc(stream: IO[bytes], start: int, end: int, label: str) -> None:
41
+ stream.seek(end)
42
+ expected = _read_integer(stream, 4, end + 4)
43
+ if expected == 0:
44
+ return # The format explicitly permits an unavailable checksum.
45
+ stream.seek(start)
46
+ actual = 0
47
+ remaining = end - start
48
+ while remaining:
49
+ block = stream.read(min(remaining, 64 * 1024))
50
+ if not block:
51
+ raise _invalid(f"truncated MCAP {label} checksum range")
52
+ actual = crc32(block, actual)
53
+ remaining -= len(block)
54
+ if actual != expected:
55
+ raise _invalid(f"MCAP {label} CRC mismatch")
56
+
57
+
58
+ def _attachment_crc(stream: IO[bytes], start: int, end: int) -> None:
59
+ # Two timestamps, two length-prefixed strings, then uint64-prefixed data.
60
+ stream.seek(start)
61
+ _skip(stream, 16, end)
62
+ for _ in range(2):
63
+ _skip(stream, _read_integer(stream, 4, end), end)
64
+ _skip(stream, _read_integer(stream, 8, end), end)
65
+ crc_offset = stream.tell()
66
+ if crc_offset + 4 > end:
67
+ raise _invalid("truncated MCAP attachment CRC")
68
+ _check_crc(stream, start, crc_offset, "attachment")
69
+
70
+
71
+ def _footer_crc(stream: IO[bytes], start: int, end: int) -> None:
72
+ if end - start != 20:
73
+ raise _invalid("invalid MCAP footer length")
74
+ stream.seek(start)
75
+ summary_start = _read_integer(stream, 8, end)
76
+ if summary_start and not MAGIC_SIZE <= summary_start <= start - 9:
77
+ raise _invalid("invalid MCAP summary checksum range")
78
+ # With no summary records the checksum covers only the footer prefix.
79
+ _check_crc(stream, summary_start or start - 9, end - 4, "summary")
80
+
81
+
82
+ def verify_extra_crcs(stream: IO[bytes]) -> None:
83
+ """Check attachment/summary CRCs in the same seekable snapshot as the reader."""
84
+ limit = stream.seek(0, 2) - MAGIC_SIZE
85
+ position = MAGIC_SIZE
86
+ while position < limit:
87
+ stream.seek(position)
88
+ opcode = _read_integer(stream, 1, limit)
89
+ length = _read_integer(stream, 8, limit)
90
+ start = stream.tell()
91
+ end = start + length
92
+ if end > limit:
93
+ raise _invalid("MCAP record extends past the trailing magic")
94
+ if opcode == Opcode.ATTACHMENT:
95
+ _attachment_crc(stream, start, end)
96
+ elif opcode == Opcode.FOOTER:
97
+ if end != limit:
98
+ raise _invalid("MCAP footer must be the last record")
99
+ _footer_crc(stream, start, end)
100
+ position = end
101
+ stream.seek(0)
@@ -0,0 +1,91 @@
1
+ """Observed MCAP facts, with data definitions authoritative over summary copies."""
2
+
3
+ from collections import Counter
4
+ from dataclasses import dataclass, field
5
+
6
+ from mcap.records import Attachment, Channel, DataEnd, McapRecord, Message, Metadata, Schema
7
+
8
+ from robotics_runtime_contracts.writers import WriterError
9
+
10
+
11
+ def _invalid(message: str) -> WriterError:
12
+ return WriterError(message, error_id="writer.invalid_mcap")
13
+
14
+
15
+ def _definition[RecordT: (Schema, Channel)](
16
+ definitions: dict[int, RecordT], record: RecordT, in_summary: bool
17
+ ) -> None:
18
+ previous = definitions.get(record.id)
19
+ if previous is not None:
20
+ if record != previous:
21
+ raise _invalid(f"conflicting MCAP {type(record).__name__} ID {record.id}")
22
+ elif in_summary:
23
+ raise _invalid(f"MCAP summary {type(record).__name__} lacks a data definition")
24
+ else:
25
+ definitions[record.id] = record
26
+
27
+
28
+ @dataclass
29
+ class Observations:
30
+ schemas: dict[int, Schema] = field(default_factory=dict)
31
+ channels: dict[int, Channel] = field(default_factory=dict)
32
+ messages: Counter[int] = field(default_factory=Counter)
33
+ attachments: int = 0
34
+ metadata: int = 0
35
+ start: int | None = None
36
+ end: int = 0
37
+ in_summary: bool = False
38
+
39
+ def definition(self, record: Schema | Channel) -> None:
40
+ if isinstance(record, Schema):
41
+ if record.id == 0:
42
+ raise _invalid("MCAP schema ID must be nonzero")
43
+ _definition(self.schemas, record, self.in_summary)
44
+ else:
45
+ if record.schema_id == 0:
46
+ raise _invalid("recording-summary.v1 requires a named schema for every channel")
47
+ if record.schema_id not in self.schemas:
48
+ raise _invalid(f"MCAP channel refers to undefined schema {record.schema_id}")
49
+ _definition(self.channels, record, self.in_summary)
50
+
51
+ def message(self, record: Message) -> None:
52
+ if self.in_summary:
53
+ raise _invalid("MCAP message occurs after DataEnd")
54
+ if record.channel_id not in self.channels:
55
+ raise _invalid(f"MCAP message refers to undefined channel {record.channel_id}")
56
+ self.messages[record.channel_id] += 1
57
+ self.start = record.log_time if self.start is None else min(self.start, record.log_time)
58
+ self.end = max(self.end, record.log_time)
59
+
60
+ def auxiliary(self, record: Attachment | Metadata) -> None:
61
+ if self.in_summary:
62
+ raise _invalid("MCAP attachment/metadata occurs after DataEnd")
63
+ if isinstance(record, Attachment):
64
+ self.attachments += 1
65
+ else:
66
+ self.metadata += 1
67
+
68
+ def observe(self, record: McapRecord) -> None:
69
+ if isinstance(record, DataEnd):
70
+ if self.in_summary:
71
+ raise _invalid("duplicate MCAP DataEnd")
72
+ self.in_summary = True
73
+ elif isinstance(record, (Schema, Channel)):
74
+ self.definition(record)
75
+ elif isinstance(record, Message):
76
+ self.message(record)
77
+ elif isinstance(record, (Attachment, Metadata)):
78
+ self.auxiliary(record)
79
+
80
+ def statistics(self) -> dict[str, int]:
81
+ if not self.in_summary:
82
+ raise _invalid("MCAP requires DataEnd before its summary")
83
+ return {
84
+ "message_count": sum(self.messages.values()),
85
+ "schema_count": len(self.schemas),
86
+ "channel_count": len(self.channels),
87
+ "attachment_count": self.attachments,
88
+ "metadata_count": self.metadata,
89
+ "message_start_time_ns": 0 if self.start is None else self.start,
90
+ "message_end_time_ns": self.end,
91
+ }
@@ -0,0 +1,118 @@
1
+ """MCAP-library implementation kept behind the optional dependency boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import shutil
7
+ from collections import Counter
8
+ from pathlib import Path
9
+ from tempfile import TemporaryFile
10
+ from typing import IO, Any
11
+
12
+ from mcap.reader import make_reader
13
+ from mcap.records import Chunk, Statistics
14
+ from mcap.stream_reader import StreamReader
15
+ from mcap.summary import Summary
16
+
17
+ from robotics_runtime_contracts._mcap_crc import verify_extra_crcs
18
+ from robotics_runtime_contracts._mcap_observations import Observations
19
+ from robotics_runtime_contracts.writers import WriterError
20
+
21
+
22
+ def _statistics(summary: Summary) -> dict[str, int]:
23
+ stats = summary.statistics
24
+ if stats is None:
25
+ raise WriterError(
26
+ "MCAP requires finalized summary Statistics", error_id="writer.invalid_mcap"
27
+ )
28
+ return {
29
+ "message_count": stats.message_count,
30
+ "schema_count": stats.schema_count,
31
+ "channel_count": stats.channel_count,
32
+ "attachment_count": stats.attachment_count,
33
+ "metadata_count": stats.metadata_count,
34
+ "chunk_count": stats.chunk_count,
35
+ "message_start_time_ns": stats.message_start_time,
36
+ "message_end_time_ns": stats.message_end_time,
37
+ }
38
+
39
+
40
+ def _observed_statistics(stream: IO[bytes]) -> Observations:
41
+ observed = Observations()
42
+ stream.seek(0)
43
+ for record in StreamReader(stream, validate_crcs=True).records:
44
+ observed.observe(record)
45
+ return observed
46
+
47
+
48
+ def _compressions(stream: IO[bytes]) -> tuple[list[str], int]:
49
+ stream.seek(0)
50
+ compressions: set[str] = set()
51
+ chunks = 0
52
+ for record in StreamReader(stream, emit_chunks=True, validate_crcs=True).records:
53
+ if isinstance(record, Chunk):
54
+ chunks += 1
55
+ compressions.add(record.compression or "none")
56
+ return sorted(compressions or {"none"}), chunks
57
+
58
+
59
+ def _channels(observed: Observations) -> list[dict[str, Any]]:
60
+ channels = []
61
+ for channel in sorted(observed.channels.values(), key=lambda item: (item.topic, item.id)):
62
+ schema = observed.schemas[channel.schema_id]
63
+ channels.append(
64
+ {
65
+ "topic": channel.topic,
66
+ "message_encoding": channel.message_encoding,
67
+ "schema_name": schema.name,
68
+ "message_count": observed.messages[channel.id],
69
+ }
70
+ )
71
+ return channels
72
+
73
+
74
+ def _check_channel_counts(statistics: Statistics, observed: Observations) -> None:
75
+ counts = statistics.channel_message_counts
76
+ if counts and (
77
+ +Counter(counts) != observed.messages or not counts.keys() <= observed.channels.keys()
78
+ ):
79
+ raise WriterError(
80
+ "MCAP per-channel Statistics contradict its messages", error_id="writer.invalid_mcap"
81
+ )
82
+
83
+
84
+ def _summarize_stream(stream: IO[bytes], digest: str) -> dict[str, Any]:
85
+ verify_extra_crcs(stream)
86
+ summary = make_reader(stream, validate_crcs=True).get_summary()
87
+ if summary is None:
88
+ raise WriterError("MCAP requires a finalized summary", error_id="writer.invalid_mcap")
89
+ expected = _statistics(summary)
90
+ observed = _observed_statistics(stream)
91
+ statistics = observed.statistics()
92
+ compressions, statistics["chunk_count"] = _compressions(stream)
93
+ if expected != statistics:
94
+ raise WriterError("MCAP Statistics contradict its records", error_id="writer.invalid_mcap")
95
+ assert summary.statistics is not None
96
+ _check_channel_counts(summary.statistics, observed)
97
+ return {
98
+ "schema_version": "recording-summary.v1",
99
+ "source_sha256": digest,
100
+ "statistics": statistics,
101
+ "compressions": compressions,
102
+ "channels": _channels(observed),
103
+ }
104
+
105
+
106
+ def summarize(source: Path) -> dict[str, Any]:
107
+ # Parse/hash the same private byte snapshot; source replacement cannot mix passes.
108
+ with source.open("rb") as original, TemporaryFile() as snapshot:
109
+ shutil.copyfileobj(original, snapshot)
110
+ snapshot.seek(0)
111
+ digest = hashlib.file_digest(snapshot, "sha256").hexdigest()
112
+ snapshot.seek(0)
113
+ try:
114
+ return _summarize_stream(snapshot, digest)
115
+ except WriterError:
116
+ raise
117
+ except Exception as error:
118
+ raise WriterError(f"invalid MCAP: {error}", error_id="writer.invalid_mcap") from error
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+
5
+ type JSONValue = None | bool | int | float | str | list[JSONValue] | dict[str, JSONValue]
6
+
7
+
8
+ def json_equal(left: JSONValue, right: JSONValue) -> bool:
9
+ """Compare JSON trees without Python's bool/int/float equality coercions."""
10
+
11
+ if type(left) is not type(right):
12
+ return False
13
+ if isinstance(left, dict) and isinstance(right, dict):
14
+ return left.keys() == right.keys() and all(
15
+ json_equal(value, right[key]) for key, value in left.items()
16
+ )
17
+ if isinstance(left, list) and isinstance(right, list):
18
+ return len(left) == len(right) and all(
19
+ json_equal(a, b) for a, b in zip(left, right, strict=True)
20
+ )
21
+ return left == right
22
+
23
+
24
+ def merge_patch(target: JSONValue, patch: JSONValue) -> JSONValue:
25
+ """Apply RFC 7396 section 2 without modifying or sharing input containers."""
26
+
27
+ # https://www.rfc-editor.org/rfc/rfc7396#section-2
28
+ if not isinstance(patch, dict):
29
+ return deepcopy(patch)
30
+ result = deepcopy(target) if isinstance(target, dict) else {}
31
+ for key, value in patch.items():
32
+ if value is None:
33
+ result.pop(key, None)
34
+ else:
35
+ result[key] = merge_patch(result.get(key), value)
36
+ return result
37
+
38
+
39
+ def create_patch(
40
+ source: dict[str, JSONValue], target: dict[str, JSONValue]
41
+ ) -> dict[str, JSONValue]:
42
+ """Build an object patch; callers must check representability by round-trip."""
43
+
44
+ patch: dict[str, JSONValue] = {key: None for key in source if key not in target}
45
+ for key, value in target.items():
46
+ if key in source and json_equal(source[key], value):
47
+ continue
48
+ if isinstance(value, dict):
49
+ previous = source.get(key)
50
+ # Keep even an empty patch when adding an object or replacing a scalar.
51
+ patch[key] = create_patch(previous if isinstance(previous, dict) else {}, value)
52
+ else:
53
+ patch[key] = deepcopy(value)
54
+ return patch