euler-dataset-contract 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*" # This triggers the workflow only when you push a tag starting with 'v'
7
+
8
+ jobs:
9
+ build-and-publish:
10
+ name: Build and Publish
11
+ runs-on: ubuntu-latest
12
+ environment: pypi # This matches the environment we set up in Step 2
13
+
14
+ permissions:
15
+ id-token: write # CRITICAL: This is what allows OIDC (passwordless) auth to PyPI
16
+ contents: read # Required to check out the code
17
+
18
+ steps:
19
+ - name: Checkout code
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v5 # The official Astral action
24
+
25
+ - name: Build package
26
+ run: uv build
27
+
28
+ - name: Publish to PyPI
29
+ # uv publish automatically detects it is in GitHub Actions and uses OIDC
30
+ run: uv publish
@@ -0,0 +1 @@
1
+ __pycache__
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: euler-dataset-contract
3
+ Version: 0.2.0
4
+ Summary: Shared dataset head contracts and namespace validators
5
+ Requires-Python: >=3.9
6
+ Provides-Extra: dev
7
+ Requires-Dist: pytest; extra == 'dev'
@@ -0,0 +1,16 @@
1
+ # euler-dataset-contract
2
+
3
+ Shared dataset-head contract processing for Euler ecosystem packages.
4
+
5
+ The package owns the cross-package contract layer:
6
+
7
+ - a `DatasetHeadContract` object
8
+ - modality-specific `meta` contracts
9
+ - namespaced addon sections such as `euler_train` and `euler_loading`
10
+ - contract versioning
11
+ - JSON Schema generation for modality metadata
12
+
13
+ Producer packages like `ds-crawler` should emit the contract. Consumer
14
+ packages like `euler-loading` or `euler-train` can parse the shared head
15
+ here, inspect namespaces via `get_namespace(...)`, and register their
16
+ own namespace validators.
@@ -0,0 +1,64 @@
1
+ """Shared dataset contract processing for Euler packages."""
2
+
3
+ from .contract import DatasetHeadContract
4
+ from .registry import (
5
+ DATASET_CONTRACT_VERSION,
6
+ DATASET_HEAD_KIND,
7
+ MODALITY_META_SCHEMAS,
8
+ SHARED_META_FIELD_DEFINITIONS,
9
+ MetaFieldDefinition,
10
+ build_default_meta,
11
+ get_modality_meta_fields,
12
+ iter_modality_meta_fields,
13
+ register_modality_meta_fields,
14
+ )
15
+ from .schema import build_dataset_head_schema, build_meta_schema
16
+ from .validation import (
17
+ get_registered_addon_validators,
18
+ get_registered_namespace_validators,
19
+ normalize_meta_dict,
20
+ parse_dataset_head,
21
+ register_addon_validator,
22
+ register_namespace_validator,
23
+ validate_addon_version,
24
+ validate_addons,
25
+ validate_contract_kind,
26
+ validate_contract_version,
27
+ validate_dataset_head,
28
+ validate_dimensions_dict,
29
+ validate_meta_dict,
30
+ validate_slot,
31
+ validate_string_list,
32
+ validate_token,
33
+ )
34
+
35
+ __all__ = [
36
+ "DATASET_CONTRACT_VERSION",
37
+ "DATASET_HEAD_KIND",
38
+ "DatasetHeadContract",
39
+ "MODALITY_META_SCHEMAS",
40
+ "SHARED_META_FIELD_DEFINITIONS",
41
+ "MetaFieldDefinition",
42
+ "build_dataset_head_schema",
43
+ "build_default_meta",
44
+ "build_meta_schema",
45
+ "get_modality_meta_fields",
46
+ "get_registered_addon_validators",
47
+ "get_registered_namespace_validators",
48
+ "iter_modality_meta_fields",
49
+ "normalize_meta_dict",
50
+ "parse_dataset_head",
51
+ "register_addon_validator",
52
+ "register_modality_meta_fields",
53
+ "register_namespace_validator",
54
+ "validate_addon_version",
55
+ "validate_addons",
56
+ "validate_contract_kind",
57
+ "validate_contract_version",
58
+ "validate_dataset_head",
59
+ "validate_dimensions_dict",
60
+ "validate_meta_dict",
61
+ "validate_slot",
62
+ "validate_string_list",
63
+ "validate_token",
64
+ ]
@@ -0,0 +1,173 @@
1
+ """Structured dataset-head contract model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Iterable
8
+
9
+ from .registry import DATASET_CONTRACT_VERSION, DATASET_HEAD_KIND
10
+ from .validation import (
11
+ normalize_meta_dict,
12
+ validate_addons,
13
+ validate_contract_kind,
14
+ validate_contract_version,
15
+ validate_token,
16
+ )
17
+
18
+
19
+ def _require_mapping(value: Any, context: str) -> dict[str, Any]:
20
+ if not isinstance(value, dict):
21
+ raise ValueError(f"{context} must be an object")
22
+ return value
23
+
24
+
25
+ def _require_non_empty_string(value: Any, context: str) -> str:
26
+ if not isinstance(value, str) or not value:
27
+ raise ValueError(f"{context} must be a non-empty string")
28
+ return value
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class DatasetHeadContract:
33
+ """Normalized dataset-head contract with namespaced addon sections."""
34
+
35
+ contract_version: str = DATASET_CONTRACT_VERSION
36
+ dataset_id: str = ""
37
+ dataset_name: str = ""
38
+ dataset_attributes: dict[str, Any] = field(default_factory=dict)
39
+ modality_key: str = ""
40
+ modality_meta: dict[str, Any] | None = None
41
+ addons: dict[str, dict[str, Any]] = field(default_factory=dict)
42
+
43
+ @property
44
+ def name(self) -> str:
45
+ return self.dataset_name
46
+
47
+ @property
48
+ def type(self) -> str:
49
+ return self.modality_key
50
+
51
+ @property
52
+ def meta(self) -> dict[str, Any] | None:
53
+ return self.modality_meta
54
+
55
+ @property
56
+ def namespace_names(self) -> tuple[str, ...]:
57
+ return tuple(sorted(self.addons))
58
+
59
+ @property
60
+ def attributes(self) -> dict[str, Any]:
61
+ return self.dataset_attributes
62
+
63
+ def has_addon(self, name: str) -> bool:
64
+ return name in self.addons
65
+
66
+ def has_namespace(self, name: str) -> bool:
67
+ return self.has_addon(name)
68
+
69
+ def get_addon(self, name: str, default: Any = None) -> Any:
70
+ return self.addons.get(name, default)
71
+
72
+ def get_namespace(self, name: str, default: Any = None) -> Any:
73
+ return self.get_addon(name, default)
74
+
75
+ def get_addon_contract(self, name: str, default: Any = None) -> Any:
76
+ return self.get_addon(name, default)
77
+
78
+ def require_addon(self, name: str) -> dict[str, Any]:
79
+ if name not in self.addons:
80
+ raise KeyError(f"Dataset contract has no addon {name!r}")
81
+ return self.addons[name]
82
+
83
+ def require_namespace(self, name: str) -> dict[str, Any]:
84
+ return self.require_addon(name)
85
+
86
+ def to_properties_dict(self) -> dict[str, Any]:
87
+ result = deepcopy(self.dataset_attributes)
88
+ if self.modality_meta is not None:
89
+ result["meta"] = deepcopy(self.modality_meta)
90
+ for name, value in self.addons.items():
91
+ result[name] = deepcopy(value)
92
+ return result
93
+
94
+ def to_mapping(self) -> dict[str, Any]:
95
+ result: dict[str, Any] = {
96
+ "contract": {
97
+ "kind": DATASET_HEAD_KIND,
98
+ "version": self.contract_version,
99
+ },
100
+ "dataset": {
101
+ "id": self.dataset_id,
102
+ "name": self.dataset_name,
103
+ },
104
+ "modality": {
105
+ "key": self.modality_key,
106
+ },
107
+ }
108
+ if self.dataset_attributes:
109
+ result["dataset"]["attributes"] = deepcopy(self.dataset_attributes)
110
+ if self.modality_meta is not None:
111
+ result["modality"]["meta"] = deepcopy(self.modality_meta)
112
+ if self.addons:
113
+ result["addons"] = deepcopy(self.addons)
114
+ return result
115
+
116
+ @classmethod
117
+ def from_mapping(
118
+ cls,
119
+ data: dict[str, Any],
120
+ *,
121
+ context: str = "dataset_head",
122
+ required_addons: Iterable[str] = (),
123
+ ) -> "DatasetHeadContract":
124
+ if not isinstance(data, dict):
125
+ raise ValueError(f"{context} must be an object")
126
+
127
+ contract = _require_mapping(data.get("contract"), f"{context}.contract")
128
+ kind = contract.get("kind")
129
+ validate_contract_kind(kind, f"{context}.contract.kind")
130
+ version = contract.get("version", DATASET_CONTRACT_VERSION)
131
+ validate_contract_version(version, f"{context}.contract.version")
132
+
133
+ dataset = _require_mapping(data.get("dataset"), f"{context}.dataset")
134
+ dataset_id = _require_non_empty_string(dataset.get("id"), f"{context}.dataset.id")
135
+ validate_token(dataset_id, f"{context}.dataset.id")
136
+ dataset_name = _require_non_empty_string(
137
+ dataset.get("name"),
138
+ f"{context}.dataset.name",
139
+ )
140
+ dataset_attributes = dataset.get("attributes", {})
141
+ if dataset_attributes is None:
142
+ dataset_attributes = {}
143
+ dataset_attributes = _require_mapping(
144
+ dataset_attributes,
145
+ f"{context}.dataset.attributes",
146
+ )
147
+
148
+ modality = _require_mapping(data.get("modality"), f"{context}.modality")
149
+ modality_key = _require_non_empty_string(
150
+ modality.get("key"),
151
+ f"{context}.modality.key",
152
+ )
153
+ validate_token(modality_key, f"{context}.modality.key")
154
+ modality_meta = normalize_meta_dict(
155
+ modality.get("meta"),
156
+ modality_key,
157
+ f"{context}.modality.meta",
158
+ )
159
+
160
+ addons = validate_addons(data.get("addons"), f"{context}.addons")
161
+ for addon in required_addons:
162
+ if addon not in addons:
163
+ raise ValueError(f"{context}.addons.{addon} is required")
164
+
165
+ return cls(
166
+ contract_version=version,
167
+ dataset_id=dataset_id,
168
+ dataset_name=dataset_name,
169
+ dataset_attributes=deepcopy(dataset_attributes),
170
+ modality_key=modality_key,
171
+ modality_meta=modality_meta,
172
+ addons=addons,
173
+ )
@@ -0,0 +1,298 @@
1
+ """Registry of shared dataset-contract definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable
8
+
9
+
10
+ Validator = Callable[[Any], str | None]
11
+ _MISSING = object()
12
+
13
+ DATASET_HEAD_KIND = "dataset_head"
14
+ DATASET_CONTRACT_VERSION = "1.0"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class MetaFieldDefinition:
19
+ accepted_type: type | tuple[type, ...]
20
+ type_label: str
21
+ description: str
22
+ validator: Validator | None = None
23
+ default: Any = _MISSING
24
+ json_schema: dict[str, Any] | None = None
25
+
26
+ @property
27
+ def has_default(self) -> bool:
28
+ return self.default is not _MISSING
29
+
30
+
31
+ def _validate_rgb_array(value: Any) -> str | None:
32
+ if (
33
+ not isinstance(value, list)
34
+ or len(value) != 3
35
+ or not all(isinstance(v, int) and 0 <= v <= 255 for v in value)
36
+ ):
37
+ return "an array of 3 integers (0-255)"
38
+ return None
39
+
40
+
41
+ def _validate_numeric_range(value: Any) -> str | None:
42
+ if (
43
+ not isinstance(value, list)
44
+ or len(value) != 2
45
+ or not all(isinstance(v, (int, float)) for v in value)
46
+ ):
47
+ return "an array of 2 numbers [min, max]"
48
+ if value[0] > value[1]:
49
+ return "an array of 2 numbers [min, max] where min <= max"
50
+ return None
51
+
52
+
53
+ def _validate_positive_int(value: Any) -> str | None:
54
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
55
+ return "a positive integer"
56
+ return None
57
+
58
+
59
+ def validate_dimensions_dict(value: Any) -> str | None:
60
+ if not isinstance(value, dict) or not value:
61
+ return "a non-empty object"
62
+
63
+ for axis, size in value.items():
64
+ if not isinstance(axis, str) or not axis:
65
+ return "keys must be non-empty strings"
66
+ if not (
67
+ axis[0].isalpha() or axis[0] == "_"
68
+ ) or any(not (ch.isalnum() or ch == "_") for ch in axis):
69
+ return "keys must contain only letters, digits, or underscores"
70
+ err = _validate_positive_int(size)
71
+ if err is not None:
72
+ return f"{axis!r} must be {err}"
73
+ return None
74
+
75
+
76
+ def _validate_file_types(value: Any) -> str | None:
77
+ if not isinstance(value, list) or not value:
78
+ return "a non-empty array of unique file type strings"
79
+
80
+ normalized: set[str] = set()
81
+ for item in value:
82
+ if not isinstance(item, str):
83
+ return "a non-empty array of unique file type strings"
84
+ token = item.strip().lower().lstrip(".")
85
+ if not token:
86
+ return "a non-empty array of unique file type strings"
87
+ if "/" in token or "\\" in token or any(ch.isspace() for ch in token):
88
+ return "a non-empty array of unique file type strings"
89
+ normalized.add(token)
90
+
91
+ if len(normalized) != len(value):
92
+ return "a non-empty array of unique file type strings"
93
+ return None
94
+
95
+
96
+ SHARED_META_FIELD_DEFINITIONS: dict[str, MetaFieldDefinition] = {
97
+ "dimensions": MetaFieldDefinition(
98
+ accepted_type=dict,
99
+ type_label="a non-empty object",
100
+ description=(
101
+ "Dataset-wide nominal sample dimensions keyed by semantic axis "
102
+ "names (for example {'height': 375, 'width': 1242, 'channels': 3}). "
103
+ "Omit this field when there is no single dataset-wide shape."
104
+ ),
105
+ validator=validate_dimensions_dict,
106
+ json_schema={
107
+ "type": "object",
108
+ "propertyNames": {
109
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
110
+ },
111
+ "minProperties": 1,
112
+ "additionalProperties": {
113
+ "type": "integer",
114
+ "minimum": 1,
115
+ },
116
+ "examples": [
117
+ {"height": 375, "width": 1242, "channels": 3},
118
+ {"time": 16, "features": 512},
119
+ {"x": 256, "y": 256, "z": 64},
120
+ ],
121
+ "x-ui": {
122
+ "widget": "keyValueTable",
123
+ "keyLabel": "Axis",
124
+ "valueLabel": "Size",
125
+ "allowCustomKeys": True,
126
+ "suggestedKeys": [
127
+ "height",
128
+ "width",
129
+ "channels",
130
+ "depth",
131
+ "time",
132
+ "features",
133
+ ],
134
+ },
135
+ },
136
+ ),
137
+ "file_types": MetaFieldDefinition(
138
+ accepted_type=list,
139
+ type_label="a non-empty array of unique file type strings",
140
+ description=(
141
+ "Observed data file types for this modality, stored as lowercase "
142
+ "extensions without leading dots (for example ['jpg', 'png'])."
143
+ ),
144
+ validator=_validate_file_types,
145
+ json_schema={
146
+ "type": "array",
147
+ "items": {"type": "string", "pattern": "^[^./\\\\\\s][^/\\\\\\s]*$"},
148
+ "minItems": 1,
149
+ "uniqueItems": True,
150
+ "examples": [["png"], ["jpg", "png"], ["npy"]],
151
+ },
152
+ ),
153
+ }
154
+
155
+ _SEMANTIC_SEGMENTATION_FIELDS: dict[str, MetaFieldDefinition] = {
156
+ "skyclass": MetaFieldDefinition(
157
+ accepted_type=list,
158
+ type_label="an array of 3 integers (0-255)",
159
+ description=(
160
+ "RGB colour value identifying the sky class in the segmentation map."
161
+ ),
162
+ validator=_validate_rgb_array,
163
+ default=[0, 0, 0],
164
+ json_schema={
165
+ "type": "array",
166
+ "items": {"type": "integer", "minimum": 0, "maximum": 255},
167
+ "minItems": 3,
168
+ "maxItems": 3,
169
+ },
170
+ ),
171
+ }
172
+
173
+ _DEFAULT_MODALITY_META_FIELD_DEFINITIONS: dict[str, dict[str, MetaFieldDefinition]] = {
174
+ "depth": {
175
+ "radial_depth": MetaFieldDefinition(
176
+ accepted_type=bool,
177
+ type_label="a bool",
178
+ description=(
179
+ "Whether the depth values represent radial (euclidean) distance "
180
+ "from the camera rather than perpendicular (z-buffer) depth."
181
+ ),
182
+ default=False,
183
+ ),
184
+ "scale_to_meters": MetaFieldDefinition(
185
+ accepted_type=(int, float),
186
+ type_label="a number",
187
+ description=(
188
+ "Factor that converts raw depth values to meters "
189
+ "(e.g. 0.001 when stored in millimetres)."
190
+ ),
191
+ default=1.0,
192
+ ),
193
+ "range": MetaFieldDefinition(
194
+ accepted_type=list,
195
+ type_label="an array of 2 numbers [min, max]",
196
+ description=(
197
+ "Value range of the depth values in meters "
198
+ "(e.g. [0, 65535] for VKITTI2)."
199
+ ),
200
+ validator=_validate_numeric_range,
201
+ default=[0, 65535],
202
+ json_schema={
203
+ "type": "array",
204
+ "items": {"type": "number"},
205
+ "minItems": 2,
206
+ "maxItems": 2,
207
+ },
208
+ ),
209
+ },
210
+ "rgb": {
211
+ "range": MetaFieldDefinition(
212
+ accepted_type=list,
213
+ type_label="an array of 2 numbers [min, max]",
214
+ description=(
215
+ "Value range of the colour channels (e.g. [0, 255] for 8-bit "
216
+ "or [0, 1] for normalised data)."
217
+ ),
218
+ validator=_validate_numeric_range,
219
+ default=[0, 255],
220
+ json_schema={
221
+ "type": "array",
222
+ "items": {"type": "number"},
223
+ "minItems": 2,
224
+ "maxItems": 2,
225
+ },
226
+ ),
227
+ },
228
+ "segmentation": deepcopy(_SEMANTIC_SEGMENTATION_FIELDS),
229
+ "semantic_segmentation": deepcopy(_SEMANTIC_SEGMENTATION_FIELDS),
230
+ }
231
+ _MODALITY_META_FIELD_DEFINITIONS = deepcopy(_DEFAULT_MODALITY_META_FIELD_DEFINITIONS)
232
+
233
+
234
+ def iter_modality_meta_fields() -> dict[str, dict[str, MetaFieldDefinition]]:
235
+ return deepcopy(_MODALITY_META_FIELD_DEFINITIONS)
236
+
237
+
238
+ def get_modality_meta_fields(
239
+ modality_key: str,
240
+ ) -> dict[str, MetaFieldDefinition] | None:
241
+ fields = _MODALITY_META_FIELD_DEFINITIONS.get(modality_key)
242
+ if fields is None:
243
+ return None
244
+ return deepcopy(fields)
245
+
246
+
247
+ def register_modality_meta_fields(
248
+ modality_key: str,
249
+ fields: dict[str, MetaFieldDefinition],
250
+ *,
251
+ overwrite: bool = False,
252
+ ) -> None:
253
+ if modality_key in _MODALITY_META_FIELD_DEFINITIONS and not overwrite:
254
+ raise ValueError(
255
+ f"Modality {modality_key!r} is already registered; "
256
+ "pass overwrite=True to replace it"
257
+ )
258
+ _MODALITY_META_FIELD_DEFINITIONS[modality_key] = deepcopy(fields)
259
+
260
+
261
+ def build_default_meta(modality_key: str) -> dict[str, Any] | None:
262
+ fields = _MODALITY_META_FIELD_DEFINITIONS.get(modality_key)
263
+ if fields is None:
264
+ return None
265
+
266
+ defaults: dict[str, Any] = {}
267
+ for name, definition in fields.items():
268
+ if definition.has_default:
269
+ defaults[name] = deepcopy(definition.default)
270
+ return defaults
271
+
272
+
273
+ def legacy_modality_meta_schemas() -> dict[str, dict[str, tuple]]:
274
+ result: dict[str, dict[str, tuple]] = {}
275
+ for modality_key, fields in _MODALITY_META_FIELD_DEFINITIONS.items():
276
+ result[modality_key] = {}
277
+ for name, definition in fields.items():
278
+ entry: tuple[Any, ...]
279
+ if definition.has_default:
280
+ entry = (
281
+ definition.accepted_type,
282
+ definition.type_label,
283
+ definition.description,
284
+ definition.validator,
285
+ deepcopy(definition.default),
286
+ )
287
+ else:
288
+ entry = (
289
+ definition.accepted_type,
290
+ definition.type_label,
291
+ definition.description,
292
+ definition.validator,
293
+ )
294
+ result[modality_key][name] = entry
295
+ return result
296
+
297
+
298
+ MODALITY_META_SCHEMAS = legacy_modality_meta_schemas()
@@ -0,0 +1,152 @@
1
+ """JSON Schema builders for dataset contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from typing import Any
7
+
8
+ from .registry import (
9
+ DATASET_CONTRACT_VERSION,
10
+ DATASET_HEAD_KIND,
11
+ SHARED_META_FIELD_DEFINITIONS,
12
+ iter_modality_meta_fields,
13
+ )
14
+
15
+ _TYPE_MAP: dict[type, str] = {
16
+ bool: "boolean",
17
+ int: "number",
18
+ float: "number",
19
+ str: "string",
20
+ list: "array",
21
+ dict: "object",
22
+ }
23
+
24
+
25
+ def _json_schema_type(accepted: type | tuple[type, ...]) -> dict[str, Any]:
26
+ if isinstance(accepted, tuple):
27
+ types = sorted({_TYPE_MAP[t] for t in accepted})
28
+ else:
29
+ types = [_TYPE_MAP[accepted]]
30
+
31
+ if len(types) == 1:
32
+ return {"type": types[0]}
33
+ return {"type": types}
34
+
35
+
36
+ def build_meta_schema() -> dict[str, Any]:
37
+ modality_schemas: dict[str, dict[str, Any]] = {}
38
+
39
+ for modality_key, fields in sorted(iter_modality_meta_fields().items()):
40
+ properties: dict[str, Any] = {}
41
+ for name, definition in SHARED_META_FIELD_DEFINITIONS.items():
42
+ type_clause = deepcopy(
43
+ definition.json_schema
44
+ if definition.json_schema is not None
45
+ else _json_schema_type(definition.accepted_type)
46
+ )
47
+ properties[name] = {
48
+ **type_clause,
49
+ "description": definition.description,
50
+ }
51
+
52
+ required: list[str] = []
53
+ for field_name, definition in sorted(fields.items()):
54
+ type_clause = deepcopy(
55
+ definition.json_schema
56
+ if definition.json_schema is not None
57
+ else _json_schema_type(definition.accepted_type)
58
+ )
59
+ prop = {
60
+ **type_clause,
61
+ "description": definition.description,
62
+ }
63
+ if definition.has_default:
64
+ prop["default"] = deepcopy(definition.default)
65
+ properties[field_name] = prop
66
+ required.append(field_name)
67
+
68
+ modality_schemas[modality_key] = {
69
+ "type": "object",
70
+ "description": (
71
+ f"Required meta fields when modality.key is {modality_key!r}."
72
+ ),
73
+ "properties": properties,
74
+ "required": required,
75
+ "additionalProperties": True,
76
+ }
77
+
78
+ return {
79
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
80
+ "title": "Euler dataset modality meta schemas",
81
+ "description": (
82
+ "Defines shared and modality-specific meta fields for "
83
+ "each modality.key value."
84
+ ),
85
+ "type": "object",
86
+ "properties": modality_schemas,
87
+ }
88
+
89
+
90
+ def build_dataset_head_schema() -> dict[str, Any]:
91
+ meta_schema = build_meta_schema()
92
+ return {
93
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
94
+ "title": "Euler dataset head schema",
95
+ "description": "Common cross-package dataset head contract.",
96
+ "type": "object",
97
+ "required": ["contract", "dataset", "modality"],
98
+ "properties": {
99
+ "contract": {
100
+ "type": "object",
101
+ "required": ["kind", "version"],
102
+ "properties": {
103
+ "kind": {
104
+ "type": "string",
105
+ "const": DATASET_HEAD_KIND,
106
+ },
107
+ "version": {
108
+ "type": "string",
109
+ "default": DATASET_CONTRACT_VERSION,
110
+ },
111
+ },
112
+ "additionalProperties": False,
113
+ },
114
+ "dataset": {
115
+ "type": "object",
116
+ "required": ["id", "name"],
117
+ "properties": {
118
+ "id": {"type": "string"},
119
+ "name": {"type": "string"},
120
+ "attributes": {
121
+ "type": "object",
122
+ "additionalProperties": True,
123
+ },
124
+ },
125
+ "additionalProperties": False,
126
+ },
127
+ "modality": {
128
+ "type": "object",
129
+ "required": ["key"],
130
+ "properties": {
131
+ "key": {"type": "string"},
132
+ "meta": meta_schema,
133
+ },
134
+ "additionalProperties": False,
135
+ },
136
+ "addons": {
137
+ "type": "object",
138
+ "propertyNames": {
139
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
140
+ },
141
+ "additionalProperties": {
142
+ "type": "object",
143
+ "required": ["version"],
144
+ "properties": {
145
+ "version": {"type": "string"},
146
+ },
147
+ "additionalProperties": True,
148
+ },
149
+ },
150
+ },
151
+ "additionalProperties": False,
152
+ }
@@ -0,0 +1,301 @@
1
+ """Validation and normalization helpers for dataset contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from copy import deepcopy
7
+ from typing import Any, Callable
8
+
9
+ from .registry import (
10
+ DATASET_CONTRACT_VERSION,
11
+ DATASET_HEAD_KIND,
12
+ SHARED_META_FIELD_DEFINITIONS,
13
+ get_modality_meta_fields,
14
+ )
15
+
16
+
17
+ AddonValidator = Callable[[Any, str], None]
18
+
19
+ _CONTRACT_VERSION_PATTERN = re.compile(r"^\d+\.\d+(?:\.\d+)?$")
20
+ _TOKEN_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
21
+ _SLOT_PATTERN = re.compile(r"^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+){1,}$")
22
+ _REGISTERED_ADDON_VALIDATORS: dict[str, AddonValidator] = {}
23
+
24
+
25
+ def validate_contract_version(
26
+ value: Any,
27
+ context: str = "contract.version",
28
+ ) -> None:
29
+ if not isinstance(value, str) or not value:
30
+ raise ValueError(f"{context} must be a non-empty string")
31
+ if not _CONTRACT_VERSION_PATTERN.match(value):
32
+ raise ValueError(
33
+ f"{context} must look like 'major.minor' or 'major.minor.patch'"
34
+ )
35
+
36
+
37
+ def validate_token(value: Any, context: str) -> None:
38
+ if not isinstance(value, str) or not value:
39
+ raise ValueError(f"{context} must be a non-empty string")
40
+ if not _TOKEN_PATTERN.match(value):
41
+ raise ValueError(
42
+ f"{context} must contain only letters, digits, or underscores "
43
+ "and may not start with a digit"
44
+ )
45
+
46
+
47
+ def validate_slot(value: Any, context: str) -> None:
48
+ if not isinstance(value, str) or not value:
49
+ raise ValueError(f"{context} must be a non-empty string")
50
+ if not _SLOT_PATTERN.match(value):
51
+ raise ValueError(
52
+ f"{context} must match 'segment.segment' or deeper "
53
+ "(alphanumeric/underscore only)"
54
+ )
55
+
56
+
57
+ def validate_dimensions_dict(value: Any, context: str) -> None:
58
+ definition = SHARED_META_FIELD_DEFINITIONS["dimensions"]
59
+ err = definition.validator(value) if definition.validator is not None else None
60
+ if err is None:
61
+ return
62
+ if err == "a non-empty object":
63
+ raise ValueError(f"{context} must be a non-empty object")
64
+ if err == "keys must be non-empty strings":
65
+ raise ValueError(f"{context} keys must be non-empty strings")
66
+ if err == "keys must contain only letters, digits, or underscores":
67
+ raise ValueError(
68
+ f"{context} keys must contain only letters, digits, or underscores"
69
+ )
70
+ axis, _, detail = err.partition(" must be ")
71
+ if detail:
72
+ raise ValueError(f"{context}[{axis}] must be {detail}")
73
+ raise ValueError(f"{context} must be {err}")
74
+
75
+
76
+ def validate_string_list(
77
+ value: Any,
78
+ context: str,
79
+ *,
80
+ allow_wildcard: bool = False,
81
+ allow_empty: bool = False,
82
+ ) -> list[str]:
83
+ if not isinstance(value, list):
84
+ raise ValueError(f"{context} must be a list of strings")
85
+ if not value and not allow_empty:
86
+ raise ValueError(f"{context} cannot be empty")
87
+
88
+ result: list[str] = []
89
+ for index, item in enumerate(value):
90
+ if not isinstance(item, str) or not item:
91
+ raise ValueError(f"{context}[{index}] must be a non-empty string")
92
+ if item != "*" or not allow_wildcard:
93
+ validate_token(item, f"{context}[{index}]")
94
+ result.append(item)
95
+ return result
96
+
97
+
98
+ def _normalize_file_types(value: Any, context: str) -> list[str]:
99
+ definition = SHARED_META_FIELD_DEFINITIONS["file_types"]
100
+ if not isinstance(value, list):
101
+ raise ValueError(
102
+ f"{context} must be a non-empty array of unique file type strings"
103
+ )
104
+
105
+ normalized: list[str] = []
106
+ for item in value:
107
+ if not isinstance(item, str):
108
+ raise ValueError(
109
+ f"{context} must be a non-empty array of unique file type strings"
110
+ )
111
+ token = item.strip().lower().lstrip(".")
112
+ if (
113
+ not token
114
+ or "/" in token
115
+ or "\\" in token
116
+ or any(ch.isspace() for ch in token)
117
+ ):
118
+ raise ValueError(
119
+ f"{context} must be a non-empty array of unique file type strings"
120
+ )
121
+ normalized.append(token)
122
+
123
+ if len(set(normalized)) != len(normalized) or not normalized:
124
+ raise ValueError(
125
+ f"{context} must be a non-empty array of unique file type strings"
126
+ )
127
+
128
+ err = definition.validator(normalized) if definition.validator is not None else None
129
+ if err is not None:
130
+ raise ValueError(f"{context} must be {err}")
131
+ return sorted(normalized)
132
+
133
+
134
+ def validate_meta_dict(value: Any, modality_key: str, context: str) -> None:
135
+ normalize_meta_dict(value, modality_key, context)
136
+
137
+
138
+ def normalize_meta_dict(
139
+ value: Any,
140
+ modality_key: str,
141
+ context: str,
142
+ ) -> dict[str, Any] | None:
143
+ schema = get_modality_meta_fields(modality_key)
144
+
145
+ if value is None:
146
+ if schema is None:
147
+ return None
148
+ required_keys = ", ".join(sorted(schema))
149
+ raise ValueError(
150
+ f"{context} is required for modality.key={modality_key!r} "
151
+ f"and must contain: {required_keys}"
152
+ )
153
+ if not isinstance(value, dict):
154
+ raise ValueError(f"{context} must be an object")
155
+
156
+ normalized = deepcopy(value)
157
+ if "fileTypes" in normalized:
158
+ if "file_types" in normalized:
159
+ raise ValueError(
160
+ f"{context}.fileTypes cannot be used together with "
161
+ f"{context}.file_types"
162
+ )
163
+ normalized["file_types"] = normalized.pop("fileTypes")
164
+
165
+ if "dimensions" in normalized:
166
+ validate_dimensions_dict(normalized["dimensions"], f"{context}.dimensions")
167
+ if "file_types" in normalized:
168
+ normalized["file_types"] = _normalize_file_types(
169
+ normalized["file_types"], f"{context}.file_types"
170
+ )
171
+
172
+ if schema is not None:
173
+ for key, definition in schema.items():
174
+ if key not in normalized:
175
+ raise ValueError(
176
+ f"{context}.{key} is required for modality.key={modality_key!r}"
177
+ )
178
+ item = normalized[key]
179
+ if definition.validator is not None:
180
+ err = definition.validator(item)
181
+ if err is not None:
182
+ raise ValueError(f"{context}.{key} must be {err}")
183
+ elif not isinstance(item, definition.accepted_type):
184
+ raise ValueError(f"{context}.{key} must be {definition.type_label}")
185
+
186
+ return normalized
187
+
188
+
189
+ def validate_addon_version(value: Any, context: str = "version") -> None:
190
+ validate_contract_version(value, context)
191
+
192
+
193
+ def register_addon_validator(
194
+ name: str,
195
+ validator: AddonValidator,
196
+ *,
197
+ overwrite: bool = False,
198
+ ) -> None:
199
+ validate_token(name, "addon")
200
+ if name in _REGISTERED_ADDON_VALIDATORS and not overwrite:
201
+ raise ValueError(
202
+ f"Addon validator for {name!r} already exists; "
203
+ "pass overwrite=True to replace it"
204
+ )
205
+ _REGISTERED_ADDON_VALIDATORS[name] = validator
206
+
207
+
208
+ def get_registered_addon_validators() -> dict[str, AddonValidator]:
209
+ return dict(_REGISTERED_ADDON_VALIDATORS)
210
+
211
+
212
+ def validate_addons(value: Any, context: str = "addons") -> dict[str, dict[str, Any]]:
213
+ if value is None:
214
+ return {}
215
+ if not isinstance(value, dict):
216
+ raise ValueError(f"{context} must be an object")
217
+
218
+ validators = get_registered_addon_validators()
219
+ normalized: dict[str, dict[str, Any]] = {}
220
+ for name, payload in value.items():
221
+ validate_token(name, f"{context} key")
222
+ if not isinstance(payload, dict):
223
+ raise ValueError(f"{context}.{name} must be an object")
224
+ version = payload.get("version")
225
+ validate_addon_version(version, f"{context}.{name}.version")
226
+ copied = deepcopy(payload)
227
+ validator = validators.get(name)
228
+ if validator is not None:
229
+ validator(copied, f"{context}.{name}")
230
+ normalized[name] = copied
231
+ return normalized
232
+
233
+
234
+ def parse_dataset_head(
235
+ value: Any,
236
+ *,
237
+ context: str = "dataset_head",
238
+ required_addons: tuple[str, ...] = (),
239
+ ) -> "DatasetHeadContract":
240
+ from .contract import DatasetHeadContract
241
+
242
+ return DatasetHeadContract.from_mapping(
243
+ value,
244
+ context=context,
245
+ required_addons=required_addons,
246
+ )
247
+
248
+
249
+ def validate_dataset_head(
250
+ value: Any,
251
+ context: str = "dataset_head",
252
+ *,
253
+ required_addons: tuple[str, ...] = (),
254
+ ) -> None:
255
+ parse_dataset_head(
256
+ value,
257
+ context=context,
258
+ required_addons=required_addons,
259
+ )
260
+
261
+
262
+ def validate_contract_kind(value: Any, context: str = "contract.kind") -> None:
263
+ if value != DATASET_HEAD_KIND:
264
+ raise ValueError(
265
+ f"{context} must be {DATASET_HEAD_KIND!r}, got {value!r}"
266
+ )
267
+
268
+
269
+ def register_namespace_validator(
270
+ name: str,
271
+ validator: AddonValidator,
272
+ *,
273
+ overwrite: bool = False,
274
+ ) -> None:
275
+ register_addon_validator(name, validator, overwrite=overwrite)
276
+
277
+
278
+ def get_registered_namespace_validators() -> dict[str, AddonValidator]:
279
+ return get_registered_addon_validators()
280
+
281
+
282
+ __all__ = [
283
+ "DATASET_CONTRACT_VERSION",
284
+ "DATASET_HEAD_KIND",
285
+ "get_registered_addon_validators",
286
+ "get_registered_namespace_validators",
287
+ "normalize_meta_dict",
288
+ "parse_dataset_head",
289
+ "register_addon_validator",
290
+ "register_namespace_validator",
291
+ "validate_addon_version",
292
+ "validate_addons",
293
+ "validate_contract_kind",
294
+ "validate_contract_version",
295
+ "validate_dataset_head",
296
+ "validate_dimensions_dict",
297
+ "validate_meta_dict",
298
+ "validate_slot",
299
+ "validate_string_list",
300
+ "validate_token",
301
+ ]
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "euler-dataset-contract"
3
+ version = "0.2.0"
4
+ description = "Shared dataset head contracts and namespace validators"
5
+ requires-python = ">=3.9"
6
+ dependencies = []
7
+
8
+ [project.optional-dependencies]
9
+ dev = ["pytest"]
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [tool.hatch.build.targets.wheel]
16
+ packages = ["euler_dataset_contract"]
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from euler_dataset_contract import (
6
+ DATASET_CONTRACT_VERSION,
7
+ DATASET_HEAD_KIND,
8
+ DatasetHeadContract,
9
+ build_default_meta,
10
+ build_meta_schema,
11
+ parse_dataset_head,
12
+ register_addon_validator,
13
+ validate_dataset_head,
14
+ )
15
+
16
+
17
+ def _sample_head() -> dict:
18
+ return {
19
+ "contract": {
20
+ "kind": DATASET_HEAD_KIND,
21
+ "version": DATASET_CONTRACT_VERSION,
22
+ },
23
+ "dataset": {
24
+ "id": "demo_rgb",
25
+ "name": "Demo RGB",
26
+ "attributes": {"gt": False},
27
+ },
28
+ "modality": {
29
+ "key": "rgb",
30
+ "meta": {"range": [0, 255]},
31
+ },
32
+ "addons": {
33
+ "euler_train": {
34
+ "version": "1.0",
35
+ "used_as": "input",
36
+ "slot": "demo.input.rgb",
37
+ },
38
+ },
39
+ }
40
+
41
+
42
+ def test_build_default_meta_depth() -> None:
43
+ assert build_default_meta("depth") == {
44
+ "radial_depth": False,
45
+ "scale_to_meters": 1.0,
46
+ "range": [0, 65535],
47
+ }
48
+
49
+
50
+ def test_validate_dataset_head_requires_core_sections() -> None:
51
+ with pytest.raises(ValueError, match=r"dataset_head\.modality"):
52
+ validate_dataset_head({
53
+ "contract": {"kind": DATASET_HEAD_KIND, "version": "1.0"},
54
+ "dataset": {"id": "demo", "name": "Demo"},
55
+ })
56
+
57
+
58
+ def test_dataset_head_contract_reads_addons_and_attributes() -> None:
59
+ contract = DatasetHeadContract.from_mapping(_sample_head())
60
+
61
+ assert contract.dataset_id == "demo_rgb"
62
+ assert contract.name == "Demo RGB"
63
+ assert contract.type == "rgb"
64
+ assert contract.attributes == {"gt": False}
65
+ assert contract.get_addon("euler_train") == {
66
+ "version": "1.0",
67
+ "used_as": "input",
68
+ "slot": "demo.input.rgb",
69
+ }
70
+ assert contract.to_properties_dict()["meta"]["range"] == [0, 255]
71
+
72
+
73
+ def test_parse_dataset_head_can_require_addon() -> None:
74
+ with pytest.raises(ValueError, match=r"dataset_head\.addons\.euler_loading is required"):
75
+ parse_dataset_head(_sample_head(), required_addons=("euler_loading",))
76
+
77
+
78
+ def test_registered_addon_validator_is_applied() -> None:
79
+ def _validator(value, context):
80
+ if value.get("slot") != "demo.input.rgb":
81
+ raise ValueError(f"{context}.slot mismatch")
82
+
83
+ register_addon_validator("euler_train", _validator, overwrite=True)
84
+ validate_dataset_head(_sample_head())
85
+
86
+
87
+ def test_build_meta_schema_contains_shared_file_types() -> None:
88
+ schema = build_meta_schema()
89
+ file_types = schema["properties"]["rgb"]["properties"]["file_types"]
90
+
91
+ assert file_types["type"] == "array"
92
+ assert file_types["uniqueItems"] is True