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.
- euler_dataset_contract-0.2.0/.github/workflows/workflow.yml +30 -0
- euler_dataset_contract-0.2.0/.gitignore +1 -0
- euler_dataset_contract-0.2.0/PKG-INFO +7 -0
- euler_dataset_contract-0.2.0/README.md +16 -0
- euler_dataset_contract-0.2.0/euler_dataset_contract/__init__.py +64 -0
- euler_dataset_contract-0.2.0/euler_dataset_contract/contract.py +173 -0
- euler_dataset_contract-0.2.0/euler_dataset_contract/registry.py +298 -0
- euler_dataset_contract-0.2.0/euler_dataset_contract/schema.py +152 -0
- euler_dataset_contract-0.2.0/euler_dataset_contract/validation.py +301 -0
- euler_dataset_contract-0.2.0/pyproject.toml +16 -0
- euler_dataset_contract-0.2.0/tests/test_validation.py +92 -0
|
@@ -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,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
|