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.
- robotics_runtime_contracts/__init__.py +281 -0
- robotics_runtime_contracts/_mcap_crc.py +101 -0
- robotics_runtime_contracts/_mcap_observations.py +91 -0
- robotics_runtime_contracts/_mcap_summary.py +118 -0
- robotics_runtime_contracts/_merge_patch.py +54 -0
- robotics_runtime_contracts/_qualification.py +1195 -0
- robotics_runtime_contracts/_qualification_checks.py +400 -0
- robotics_runtime_contracts/_qualification_files.py +102 -0
- robotics_runtime_contracts/_qualification_types.py +104 -0
- robotics_runtime_contracts/_resources.py +19 -0
- robotics_runtime_contracts/_timestamps.py +22 -0
- robotics_runtime_contracts/_writer_cli.py +179 -0
- robotics_runtime_contracts/canonical.py +180 -0
- robotics_runtime_contracts/catalog.py +54 -0
- robotics_runtime_contracts/cli.py +438 -0
- robotics_runtime_contracts/document_ops.py +187 -0
- robotics_runtime_contracts/errors.py +25 -0
- robotics_runtime_contracts/extensions.py +281 -0
- robotics_runtime_contracts/providers.py +66 -0
- robotics_runtime_contracts/py.typed +0 -0
- robotics_runtime_contracts/qualification.py +118 -0
- robotics_runtime_contracts/qualification_policy.py +178 -0
- robotics_runtime_contracts/receipts.py +59 -0
- robotics_runtime_contracts/recordings.py +26 -0
- robotics_runtime_contracts/schemas/__init__.py +0 -0
- robotics_runtime_contracts/schemas/acceptance-aggregate.v1.schema.json +157 -0
- robotics_runtime_contracts/schemas/acceptance-observation.v1.schema.json +38 -0
- robotics_runtime_contracts/schemas/acceptance-result-core.v1.schema.json +1027 -0
- robotics_runtime_contracts/schemas/acceptance-result.v1.schema.json +442 -0
- robotics_runtime_contracts/schemas/acceptance-run.v1.schema.json +98 -0
- robotics_runtime_contracts/schemas/acceptance-scenario-core.v1.schema.json +1202 -0
- robotics_runtime_contracts/schemas/acceptance-scenario.v1.schema.json +485 -0
- robotics_runtime_contracts/schemas/artifact-receipt.v1.schema.json +68 -0
- robotics_runtime_contracts/schemas/artifact-verification.v1.schema.json +92 -0
- robotics_runtime_contracts/schemas/campaign-summary.v1.schema.json +167 -0
- robotics_runtime_contracts/schemas/catalog.v1.json +38 -0
- robotics_runtime_contracts/schemas/causal-chain.v1.schema.json +70 -0
- robotics_runtime_contracts/schemas/clock-relation.v1.schema.json +204 -0
- robotics_runtime_contracts/schemas/common.v1.schema.json +243 -0
- robotics_runtime_contracts/schemas/conformance-result.v1.schema.json +117 -0
- robotics_runtime_contracts/schemas/dataset-manifest.v1.schema.json +340 -0
- robotics_runtime_contracts/schemas/evidence-index.v1.schema.json +257 -0
- robotics_runtime_contracts/schemas/execution-permit.v1.schema.json +226 -0
- robotics_runtime_contracts/schemas/execution-trust-policy.v1.schema.json +122 -0
- robotics_runtime_contracts/schemas/execution-verification.v1.schema.json +163 -0
- robotics_runtime_contracts/schemas/model-artifact-manifest.v1.schema.json +262 -0
- robotics_runtime_contracts/schemas/qualification-bundle.v1.schema.json +108 -0
- robotics_runtime_contracts/schemas/qualification-policy.v1.schema.json +94 -0
- robotics_runtime_contracts/schemas/qualification-profile.v1.schema.json +29 -0
- robotics_runtime_contracts/schemas/recording-summary.v1.schema.json +122 -0
- robotics_runtime_contracts/schemas/robot-description.v1.schema.json +212 -0
- robotics_runtime_contracts/schemas/runtime-manifest-core.v1.schema.json +808 -0
- robotics_runtime_contracts/schemas/runtime-manifest.v1.schema.json +126 -0
- robotics_runtime_contracts/schemas/transport-channel-observation.v1.schema.json +157 -0
- robotics_runtime_contracts/schemas/transport-channel.v1.schema.json +274 -0
- robotics_runtime_contracts/schemas/transport-qualification-result-core.v1.schema.json +484 -0
- robotics_runtime_contracts/schemas/transport-qualification-result.v1.schema.json +97 -0
- robotics_runtime_contracts/semantics.py +1274 -0
- robotics_runtime_contracts/serialization.py +244 -0
- robotics_runtime_contracts/statements.py +102 -0
- robotics_runtime_contracts/status.py +37 -0
- robotics_runtime_contracts/writers.py +298 -0
- robotics_runtime_contracts-0.17.0.dist-info/METADATA +351 -0
- robotics_runtime_contracts-0.17.0.dist-info/RECORD +67 -0
- robotics_runtime_contracts-0.17.0.dist-info/WHEEL +4 -0
- robotics_runtime_contracts-0.17.0.dist-info/entry_points.txt +2 -0
- 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
|