umf-core 0.10.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.
- umf/__init__.py +54 -0
- umf/constraint_kernel.py +467 -0
- umf/generated_resources.py +47 -0
- umf/json.py +51 -0
- umf/literal_kernel.py +319 -0
- umf/models.py +123 -0
- umf/py.typed +0 -0
- umf/registry.py +68 -0
- umf/schemas.py +197 -0
- umf/serialization.py +144 -0
- umf/spec/core/extension-package.schema.json +1459 -0
- umf/spec/core/schema.json +1460 -0
- umf/spec/core/semantic-type-reference.schema.json +1459 -0
- umf/spec/extensions/dataset-source/schema.json +172 -0
- umf/spec/extensions/domain-pack/schema.json +681 -0
- umf/spec/metamodel/generated/core-constraints.json +6127 -0
- umf/spec/metamodel/generated/domain-policy.json +585 -0
- umf/spec/metamodel/generated/manifest.json +66 -0
- umf/spec/metamodel/generated/python-shapes.json +2927 -0
- umf/validation.py +22 -0
- umf_core-0.10.0.dist-info/METADATA +74 -0
- umf_core-0.10.0.dist-info/RECORD +26 -0
- umf_core-0.10.0.dist-info/WHEEL +4 -0
- umf_core-0.10.0.dist-info/licenses/LICENSE-APACHE +201 -0
- umf_core-0.10.0.dist-info/licenses/LICENSE-MIT +21 -0
- umf_core-0.10.0.dist-info/licenses/LICENSE.md +239 -0
umf/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT OR Apache-2.0
|
|
2
|
+
"""Official Python UMF support; consumer extensions remain consumer-owned."""
|
|
3
|
+
|
|
4
|
+
from .models import (
|
|
5
|
+
CoreItemTypeReference,
|
|
6
|
+
CoreSemanticTypeReference,
|
|
7
|
+
Document,
|
|
8
|
+
Element,
|
|
9
|
+
Module,
|
|
10
|
+
Reference,
|
|
11
|
+
Vocabulary,
|
|
12
|
+
)
|
|
13
|
+
from .registry import Diagnostic, Extension, Registry, Validation
|
|
14
|
+
from .schemas import (
|
|
15
|
+
core_schema,
|
|
16
|
+
current_core_version,
|
|
17
|
+
dataset_source_schema,
|
|
18
|
+
domain_pack_schema,
|
|
19
|
+
schema_validator,
|
|
20
|
+
semantic_type_reference_schema,
|
|
21
|
+
)
|
|
22
|
+
from .serialization import (
|
|
23
|
+
read_document,
|
|
24
|
+
read_json_value,
|
|
25
|
+
write_document,
|
|
26
|
+
write_json_value,
|
|
27
|
+
)
|
|
28
|
+
from .validation import validate_document
|
|
29
|
+
|
|
30
|
+
__version__ = "0.10.0"
|
|
31
|
+
__all__ = [
|
|
32
|
+
"CoreItemTypeReference",
|
|
33
|
+
"CoreSemanticTypeReference",
|
|
34
|
+
"Diagnostic",
|
|
35
|
+
"Document",
|
|
36
|
+
"Element",
|
|
37
|
+
"Extension",
|
|
38
|
+
"Module",
|
|
39
|
+
"Reference",
|
|
40
|
+
"Registry",
|
|
41
|
+
"Validation",
|
|
42
|
+
"Vocabulary",
|
|
43
|
+
"core_schema",
|
|
44
|
+
"current_core_version",
|
|
45
|
+
"dataset_source_schema",
|
|
46
|
+
"domain_pack_schema",
|
|
47
|
+
"read_document",
|
|
48
|
+
"read_json_value",
|
|
49
|
+
"schema_validator",
|
|
50
|
+
"semantic_type_reference_schema",
|
|
51
|
+
"validate_document",
|
|
52
|
+
"write_document",
|
|
53
|
+
"write_json_value",
|
|
54
|
+
]
|
umf/constraint_kernel.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT OR Apache-2.0
|
|
2
|
+
"""Interpreter for the inert, model-generated UMF constraint program.
|
|
3
|
+
|
|
4
|
+
The kernel contains generic data operations only. Core decisions, diagnostic
|
|
5
|
+
texts and order are supplied by ``spec/metamodel/generated/core-constraints.json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .registry import Diagnostic, Registry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Missing:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
MISSING = _Missing()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class _CanonicalMap:
|
|
28
|
+
values: dict[str, Any]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _canonical(value: Any) -> str:
|
|
32
|
+
"""Canonical, prototype-independent JSON key used for equality and maps."""
|
|
33
|
+
|
|
34
|
+
def normalize(item: Any):
|
|
35
|
+
if item is None or type(item) in (str, bool, int):
|
|
36
|
+
return item
|
|
37
|
+
if type(item) is float:
|
|
38
|
+
if not math.isfinite(item):
|
|
39
|
+
raise ValueError("Non-finite value in constraint program")
|
|
40
|
+
# JavaScript JSON serialization renders integral Numbers without a
|
|
41
|
+
# decimal suffix (for example, 1.0 as "1").
|
|
42
|
+
if item.is_integer():
|
|
43
|
+
return int(item)
|
|
44
|
+
return item
|
|
45
|
+
if type(item) is list:
|
|
46
|
+
return [normalize(child) for child in item]
|
|
47
|
+
if isinstance(item, Mapping):
|
|
48
|
+
if any(type(key) is not str for key in item):
|
|
49
|
+
raise ValueError("Non-text object key in constraint program")
|
|
50
|
+
return {key: normalize(item[key]) for key in sorted(item)}
|
|
51
|
+
raise ValueError("Non-JSON value in constraint program")
|
|
52
|
+
|
|
53
|
+
return json.dumps(normalize(value), ensure_ascii=False, separators=(",", ":"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _equal(left: Any, right: Any) -> bool:
|
|
57
|
+
if left is MISSING or right is MISSING:
|
|
58
|
+
return left is right
|
|
59
|
+
return _canonical(left) == _canonical(right)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _js_string(value: Any) -> str:
|
|
63
|
+
if value is MISSING:
|
|
64
|
+
return "undefined"
|
|
65
|
+
if value is None:
|
|
66
|
+
return "null"
|
|
67
|
+
if value is True:
|
|
68
|
+
return "true"
|
|
69
|
+
if value is False:
|
|
70
|
+
return "false"
|
|
71
|
+
if isinstance(value, (dict, list)):
|
|
72
|
+
return "[object Object]" if isinstance(value, dict) else ",".join(_js_string(item) if item is not None else "" for item in value)
|
|
73
|
+
return str(value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get(value: Any, path: Any) -> Any:
|
|
77
|
+
if not isinstance(path, list):
|
|
78
|
+
raise ValueError("get path must be an array") # noqa: TRY004 -- invalid constraint data uses the public ValueError contract
|
|
79
|
+
for part in path:
|
|
80
|
+
if isinstance(value, Mapping) and type(part) is str:
|
|
81
|
+
if part not in value:
|
|
82
|
+
return MISSING
|
|
83
|
+
value = value[part]
|
|
84
|
+
elif isinstance(value, Diagnostic) and type(part) is str and part in {"code", "path", "message", "severity"}:
|
|
85
|
+
value = getattr(value, part)
|
|
86
|
+
elif isinstance(value, list) and type(part) is int and 0 <= part < len(value):
|
|
87
|
+
value = value[part]
|
|
88
|
+
else:
|
|
89
|
+
return MISSING
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _truthy(value: Any) -> bool:
|
|
94
|
+
if value is MISSING or value is None or value is False:
|
|
95
|
+
return False
|
|
96
|
+
if type(value) in (int, float) and value == 0:
|
|
97
|
+
return False
|
|
98
|
+
if isinstance(value, str) and not value: # noqa: SIM103 -- explicit ordered truthiness cases mirror the portable kernel
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _eval(expr: Any, env: dict[str, Any], registry: Registry | None, charge=lambda: None):
|
|
104
|
+
charge()
|
|
105
|
+
if isinstance(expr, list):
|
|
106
|
+
return [_eval(item, env, registry, charge) for item in expr]
|
|
107
|
+
if isinstance(expr, Mapping):
|
|
108
|
+
if set(expr) == {"var"}:
|
|
109
|
+
name = expr["var"]
|
|
110
|
+
if name not in env:
|
|
111
|
+
raise ValueError(f"Unbound constraint variable: {name}")
|
|
112
|
+
return env[name]
|
|
113
|
+
if "op" in expr:
|
|
114
|
+
if set(expr) - {"op", "args", "as", "index", "value"}:
|
|
115
|
+
raise ValueError("Unknown constraint expression member")
|
|
116
|
+
op = expr["op"]
|
|
117
|
+
args = expr.get("args", [])
|
|
118
|
+
if not isinstance(args, list):
|
|
119
|
+
raise ValueError("Constraint op args must be an array")
|
|
120
|
+
if op == "and":
|
|
121
|
+
for arg in args:
|
|
122
|
+
if not _truthy(_eval(arg, env, registry, charge)):
|
|
123
|
+
return False
|
|
124
|
+
return True
|
|
125
|
+
if op == "or":
|
|
126
|
+
for arg in args:
|
|
127
|
+
if _truthy(_eval(arg, env, registry, charge)):
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
if op in {"filter", "map", "find", "some", "every", "index"}:
|
|
131
|
+
return _collection(expr, env, registry, charge)
|
|
132
|
+
values = [_eval(arg, env, registry, charge) for arg in args]
|
|
133
|
+
return _apply(op, values, registry)
|
|
134
|
+
return {key: _eval(value, env, registry, charge) for key, value in expr.items()}
|
|
135
|
+
return expr
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _collection(expr: Mapping[str, Any], env: dict[str, Any], registry: Registry | None, charge):
|
|
139
|
+
op = expr["op"]
|
|
140
|
+
args = expr.get("args", [])
|
|
141
|
+
if len(args) != 1:
|
|
142
|
+
raise ValueError(f"{op} expects one collection argument")
|
|
143
|
+
source = _eval(args[0], env, registry, charge)
|
|
144
|
+
if source is MISSING or source is None:
|
|
145
|
+
source = []
|
|
146
|
+
if not isinstance(source, (list, tuple)):
|
|
147
|
+
raise ValueError(f"{op} input must be a sequence") # noqa: TRY004 -- invalid constraint data uses the public ValueError contract
|
|
148
|
+
name = expr.get("as")
|
|
149
|
+
index_name = expr.get("index")
|
|
150
|
+
if type(name) is not str or not name:
|
|
151
|
+
raise ValueError(f"{op} requires a local binding")
|
|
152
|
+
projected = []
|
|
153
|
+
if op in {"find", "some", "every"}:
|
|
154
|
+
for i, item in enumerate(source):
|
|
155
|
+
local = dict(env)
|
|
156
|
+
local[name] = item
|
|
157
|
+
if index_name is not None:
|
|
158
|
+
if type(index_name) is not str or not index_name:
|
|
159
|
+
raise ValueError(f"{op} index binding must be a name")
|
|
160
|
+
local[index_name] = i
|
|
161
|
+
matches = _truthy(_eval(expr.get("value", {"var": name}), local, registry, charge))
|
|
162
|
+
if op == "find" and matches:
|
|
163
|
+
return item
|
|
164
|
+
if op == "some" and matches:
|
|
165
|
+
return True
|
|
166
|
+
if op == "every" and not matches:
|
|
167
|
+
return False
|
|
168
|
+
return op == "every" if op != "find" else MISSING
|
|
169
|
+
for i, item in enumerate(source):
|
|
170
|
+
local = dict(env)
|
|
171
|
+
local[name] = item
|
|
172
|
+
if index_name is not None:
|
|
173
|
+
if type(index_name) is not str or not index_name:
|
|
174
|
+
raise ValueError(f"{op} index binding must be a name")
|
|
175
|
+
local[index_name] = i
|
|
176
|
+
value = _eval(expr.get("value", {"var": name}), local, registry, charge)
|
|
177
|
+
projected.append((item, value))
|
|
178
|
+
if op == "filter":
|
|
179
|
+
return [item for item, predicate in projected if _truthy(predicate)]
|
|
180
|
+
if op == "map":
|
|
181
|
+
return [value for _, value in projected]
|
|
182
|
+
result: dict[str, Any] = {}
|
|
183
|
+
for item, projected_key in projected:
|
|
184
|
+
token = _canonical(projected_key)
|
|
185
|
+
result[token] = item
|
|
186
|
+
return _CanonicalMap(result)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _apply(op: str, a: list[Any], registry: Registry | None):
|
|
190
|
+
if op == "get":
|
|
191
|
+
if len(a) != 2:
|
|
192
|
+
raise ValueError("get expects [value,path]")
|
|
193
|
+
return _get(a[0], a[1])
|
|
194
|
+
if op == "has":
|
|
195
|
+
if len(a) != 2 or not isinstance(a[1], list):
|
|
196
|
+
raise ValueError("has expects [value,path]")
|
|
197
|
+
return _get(a[0], a[1]) is not MISSING
|
|
198
|
+
if op == "keys":
|
|
199
|
+
if len(a) != 1 or not isinstance(a[0], Mapping):
|
|
200
|
+
raise ValueError("keys expects an object")
|
|
201
|
+
return list(a[0])
|
|
202
|
+
if op == "values":
|
|
203
|
+
if len(a) != 1 or not isinstance(a[0], Mapping):
|
|
204
|
+
raise ValueError("values expects an object")
|
|
205
|
+
return list(a[0].values())
|
|
206
|
+
if op == "entries":
|
|
207
|
+
if len(a) != 1 or not isinstance(a[0], Mapping):
|
|
208
|
+
raise ValueError("entries expects an object")
|
|
209
|
+
return [[key, value] for key, value in a[0].items()]
|
|
210
|
+
if op == "length":
|
|
211
|
+
if len(a) != 1:
|
|
212
|
+
raise ValueError("length expects one value")
|
|
213
|
+
if isinstance(a[0], _CanonicalMap):
|
|
214
|
+
return len(a[0].values)
|
|
215
|
+
if a[0] is MISSING or a[0] is None:
|
|
216
|
+
return 0
|
|
217
|
+
if isinstance(a[0], (str, list, tuple)):
|
|
218
|
+
return len(a[0])
|
|
219
|
+
return getattr(a[0], "length", 0)
|
|
220
|
+
if op == "without":
|
|
221
|
+
if len(a) != 2 or not isinstance(a[0], Mapping) or not isinstance(a[1], list):
|
|
222
|
+
raise ValueError("without expects [object,keys]")
|
|
223
|
+
return {key: value for key, value in a[0].items() if key not in a[1]}
|
|
224
|
+
if op in ("eq", "ne"):
|
|
225
|
+
if len(a) != 2:
|
|
226
|
+
raise ValueError(f"{op} expects two values")
|
|
227
|
+
equal = _equal(a[0], a[1])
|
|
228
|
+
return equal if op == "eq" else not equal
|
|
229
|
+
if op in ("lt", "lte", "gt", "gte"):
|
|
230
|
+
if len(a) != 2 or type(a[0]) not in (int, float) or type(a[1]) not in (int, float):
|
|
231
|
+
raise ValueError(f"{op} expects numeric operands")
|
|
232
|
+
return {"lt": a[0] < a[1], "lte": a[0] <= a[1], "gt": a[0] > a[1], "gte": a[0] >= a[1]}[op]
|
|
233
|
+
if op == "in":
|
|
234
|
+
if len(a) != 2:
|
|
235
|
+
raise ValueError("in expects [value,collection]")
|
|
236
|
+
if isinstance(a[1], _CanonicalMap):
|
|
237
|
+
return _canonical(a[0]) in a[1].values
|
|
238
|
+
if isinstance(a[1], (list, tuple)):
|
|
239
|
+
return any(_equal(a[0], value) for value in a[1])
|
|
240
|
+
raise ValueError("in collection must be a sequence or canonical Map")
|
|
241
|
+
if op == "not":
|
|
242
|
+
if len(a) != 1:
|
|
243
|
+
raise ValueError("not expects one value")
|
|
244
|
+
return not _truthy(a[0])
|
|
245
|
+
if op in ("and", "or"):
|
|
246
|
+
return all(_truthy(value) for value in a) if op == "and" else any(_truthy(value) for value in a)
|
|
247
|
+
if op == "concat":
|
|
248
|
+
return "".join("" if value is None or value is MISSING else _js_string(value) for value in a)
|
|
249
|
+
if op == "pointer":
|
|
250
|
+
if len(a) != 1:
|
|
251
|
+
raise ValueError("pointer expects one value")
|
|
252
|
+
value = _js_string(a[0])
|
|
253
|
+
return value.replace("~", "~0").replace("/", "~1")
|
|
254
|
+
if op == "tuple":
|
|
255
|
+
return a
|
|
256
|
+
if op == "flatten":
|
|
257
|
+
if len(a) != 1 or not isinstance(a[0], list) or any(not isinstance(item, list) for item in a[0]):
|
|
258
|
+
raise ValueError("flatten expects an array of arrays")
|
|
259
|
+
return [child for group in a[0] for child in group]
|
|
260
|
+
if op == "canonical":
|
|
261
|
+
if len(a) != 1:
|
|
262
|
+
raise ValueError("canonical expects one value")
|
|
263
|
+
return _canonical(a[0])
|
|
264
|
+
if op == "lookup":
|
|
265
|
+
if len(a) != 2 or not isinstance(a[0], _CanonicalMap):
|
|
266
|
+
raise ValueError("lookup expects [index,key]")
|
|
267
|
+
return a[0].values.get(_canonical(a[1]), MISSING)
|
|
268
|
+
if op == "registered":
|
|
269
|
+
if len(a) != 2:
|
|
270
|
+
raise ValueError("registered expects [extensionId,version]")
|
|
271
|
+
return bool(registry and registry.get(a[0], a[1]))
|
|
272
|
+
if op == "sort":
|
|
273
|
+
if len(a) != 1 or not isinstance(a[0], list):
|
|
274
|
+
raise ValueError("sort expects a sequence")
|
|
275
|
+
return sorted(a[0], key=_js_string)
|
|
276
|
+
if op == "set":
|
|
277
|
+
return _CanonicalMap({})
|
|
278
|
+
if op == "literal-errors":
|
|
279
|
+
from .literal_kernel import literal_errors
|
|
280
|
+
|
|
281
|
+
return literal_errors(*a)
|
|
282
|
+
if op == "literal-identity":
|
|
283
|
+
from .literal_kernel import literal_identity
|
|
284
|
+
|
|
285
|
+
value = literal_identity(*a)
|
|
286
|
+
return MISSING if value is None else value
|
|
287
|
+
if op == "numeric-interval":
|
|
288
|
+
from .literal_kernel import numeric_interval
|
|
289
|
+
|
|
290
|
+
return numeric_interval(*a)
|
|
291
|
+
if op == "extension-errors":
|
|
292
|
+
return _extension_errors(a, registry)
|
|
293
|
+
raise ValueError(f"Unknown constraint operation: {op}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _extension_errors(args: list[Any], registry: Registry | None):
|
|
297
|
+
if len(args) != 5:
|
|
298
|
+
raise ValueError("extension-errors expects five arguments")
|
|
299
|
+
_document, payloads, scope, base_path, policy = args
|
|
300
|
+
if registry is None:
|
|
301
|
+
registry = Registry()
|
|
302
|
+
result = []
|
|
303
|
+
if not isinstance(payloads, Mapping):
|
|
304
|
+
return []
|
|
305
|
+
# Policy is data supplied by the generated model; the registered callback is
|
|
306
|
+
# the sole executable boundary and receives copied source context.
|
|
307
|
+
def add(key: str, at: str, detail: str | None = None):
|
|
308
|
+
rule = policy[key]
|
|
309
|
+
result.append(Diagnostic(rule["code"], at, detail if detail is not None else rule["message"], rule["severity"]))
|
|
310
|
+
|
|
311
|
+
for extension_id, content in payloads.items():
|
|
312
|
+
declaration = _get(_document, ["vocabularies", extension_id])
|
|
313
|
+
at = str(base_path) + "/extensions/" + str(extension_id).replace("~", "~0").replace("/", "~1")
|
|
314
|
+
if declaration is MISSING:
|
|
315
|
+
add("undeclared", at)
|
|
316
|
+
continue
|
|
317
|
+
entry = registry.get(extension_id, declaration.get("version"))
|
|
318
|
+
if entry is None:
|
|
319
|
+
continue
|
|
320
|
+
definition, validator = entry
|
|
321
|
+
if scope not in definition.scopes:
|
|
322
|
+
add("scope", at)
|
|
323
|
+
continue
|
|
324
|
+
errors = list(validator.iter_errors(content))
|
|
325
|
+
for error in errors:
|
|
326
|
+
suffix = "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in error.path)
|
|
327
|
+
add("structure", at + suffix, error.message)
|
|
328
|
+
if not errors and definition.semantics is not None:
|
|
329
|
+
from .json import copy_json
|
|
330
|
+
|
|
331
|
+
document = copy_json(_document)
|
|
332
|
+
try:
|
|
333
|
+
returned = definition.semantics(copy_json(content), {"document": document, "scope": scope, "path": at})
|
|
334
|
+
if not isinstance(returned, (list, tuple)) or any(not isinstance(d, Diagnostic) or d.severity not in ("error", "warning") for d in returned):
|
|
335
|
+
raise ValueError("Extension callback must return Diagnostic objects")
|
|
336
|
+
result.extend(returned)
|
|
337
|
+
except Exception as error: # noqa: BLE001 -- consumer callback failures become scoped diagnostics
|
|
338
|
+
add("failure", at, str(error))
|
|
339
|
+
elif not errors:
|
|
340
|
+
add("unchecked", at)
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _run_steps(steps: list[Any], env: dict[str, Any], registry: Registry | None, out: list[Diagnostic], charge):
|
|
345
|
+
for step in steps:
|
|
346
|
+
charge()
|
|
347
|
+
if not isinstance(step, Mapping):
|
|
348
|
+
raise ValueError("Constraint step must be an object") # noqa: TRY004 -- invalid constraint data uses the public ValueError contract
|
|
349
|
+
op = step.get("op")
|
|
350
|
+
if op == "let":
|
|
351
|
+
name = step.get("name")
|
|
352
|
+
if type(name) is not str or not name:
|
|
353
|
+
raise ValueError("let requires a name")
|
|
354
|
+
env[name] = _eval(step.get("value"), env, registry, charge)
|
|
355
|
+
elif op == "for":
|
|
356
|
+
values = _eval(step.get("each"), env, registry, charge)
|
|
357
|
+
if values is MISSING or values is None:
|
|
358
|
+
values = []
|
|
359
|
+
if not isinstance(values, (list, tuple)):
|
|
360
|
+
raise ValueError("for each must evaluate to a sequence")
|
|
361
|
+
name = step.get("as")
|
|
362
|
+
index_name = step.get("index")
|
|
363
|
+
if type(name) is not str or not name:
|
|
364
|
+
raise ValueError("for requires a local binding")
|
|
365
|
+
for index, value in enumerate(values):
|
|
366
|
+
local = dict(env)
|
|
367
|
+
local[name] = value
|
|
368
|
+
if index_name is not None:
|
|
369
|
+
if type(index_name) is not str or not index_name:
|
|
370
|
+
raise ValueError("for index must be a local name")
|
|
371
|
+
local[index_name] = index
|
|
372
|
+
_run_steps(step.get("body", []), local, registry, out, charge)
|
|
373
|
+
elif op == "if":
|
|
374
|
+
condition = _eval(step.get("test"), env, registry, charge)
|
|
375
|
+
_run_steps(step.get("then", []) if _truthy(condition) else step.get("else", []), dict(env), registry, out, charge)
|
|
376
|
+
elif op == "emit":
|
|
377
|
+
if "when" in step and not _truthy(_eval(step["when"], env, registry, charge)):
|
|
378
|
+
continue
|
|
379
|
+
diagnostic = _eval(step.get("diagnostic"), env, registry, charge)
|
|
380
|
+
if isinstance(diagnostic, Diagnostic):
|
|
381
|
+
if diagnostic.severity not in ("error", "warning"):
|
|
382
|
+
raise ValueError("Malformed diagnostic")
|
|
383
|
+
out.append(diagnostic)
|
|
384
|
+
continue
|
|
385
|
+
if not isinstance(diagnostic, Mapping):
|
|
386
|
+
raise ValueError("emit requires a diagnostic object")
|
|
387
|
+
values = {key: diagnostic.get(key) for key in ("code", "path", "message", "severity")}
|
|
388
|
+
if any(type(value) is not str for value in values.values()):
|
|
389
|
+
raise ValueError("Diagnostic fields must evaluate to text")
|
|
390
|
+
out.append(Diagnostic(**values))
|
|
391
|
+
elif op == "remember":
|
|
392
|
+
seen = _eval(step.get("set"), env, registry, charge)
|
|
393
|
+
if not isinstance(seen, _CanonicalMap):
|
|
394
|
+
raise ValueError("remember set must be a generated canonical Map")
|
|
395
|
+
key = _canonical(_eval(step.get("key"), env, registry, charge))
|
|
396
|
+
if key in seen.values:
|
|
397
|
+
if "diagnostic" in step:
|
|
398
|
+
diagnostic = _eval(step["diagnostic"], env, registry, charge)
|
|
399
|
+
if isinstance(diagnostic, Diagnostic):
|
|
400
|
+
if diagnostic.severity not in ("error", "warning"):
|
|
401
|
+
raise ValueError("Malformed diagnostic")
|
|
402
|
+
out.append(diagnostic)
|
|
403
|
+
continue
|
|
404
|
+
if not isinstance(diagnostic, Mapping):
|
|
405
|
+
raise ValueError("remember diagnostic must be an object")
|
|
406
|
+
values = {field: diagnostic.get(field) for field in ("code", "path", "message", "severity")}
|
|
407
|
+
if any(type(value) is not str for value in values.values()):
|
|
408
|
+
raise ValueError("Diagnostic fields must evaluate to text")
|
|
409
|
+
out.append(Diagnostic(**values))
|
|
410
|
+
else:
|
|
411
|
+
seen.values[key] = _eval(step["value"], env, registry, charge) if "value" in step else True
|
|
412
|
+
else:
|
|
413
|
+
raise ValueError(f"Unknown constraint step: {op}")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _check_program(program: Mapping[str, Any]) -> None:
|
|
417
|
+
operations = {"get", "has", "keys", "values", "entries", "without", "length", "eq", "ne", "lt", "lte", "gt", "gte", "in", "not", "and", "or", "concat", "pointer", "tuple", "flatten", "canonical", "filter", "map", "find", "some", "every", "sort", "index", "lookup", "registered", "set", "literal-errors", "literal-identity", "numeric-interval", "extension-errors"}
|
|
418
|
+
steps = {"let", "for", "if", "emit", "remember"}
|
|
419
|
+
|
|
420
|
+
def check_expr(expr: Any):
|
|
421
|
+
if isinstance(expr, list):
|
|
422
|
+
for item in expr:
|
|
423
|
+
check_expr(item)
|
|
424
|
+
elif isinstance(expr, Mapping):
|
|
425
|
+
if "op" in expr and expr["op"] not in operations:
|
|
426
|
+
raise ValueError(f"Unknown constraint operation: {expr['op']}")
|
|
427
|
+
for value in expr.values():
|
|
428
|
+
check_expr(value)
|
|
429
|
+
|
|
430
|
+
def check_steps(sequence: Any):
|
|
431
|
+
if not isinstance(sequence, list):
|
|
432
|
+
raise ValueError("Constraint step body must be an array") # noqa: TRY004 -- invalid constraint data uses the public ValueError contract
|
|
433
|
+
for step in sequence:
|
|
434
|
+
if not isinstance(step, Mapping) or step.get("op") not in steps:
|
|
435
|
+
raise ValueError(f"Unknown constraint step: {step.get('op') if isinstance(step, Mapping) else None}")
|
|
436
|
+
for key, value in step.items():
|
|
437
|
+
if key in {"body", "then", "else"}:
|
|
438
|
+
check_steps(value)
|
|
439
|
+
elif key != "op":
|
|
440
|
+
check_expr(value)
|
|
441
|
+
|
|
442
|
+
check_steps(program.get("steps"))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def run_core_constraints(document: dict, registry: Registry | None = None, *, program=None, domain_policy=None):
|
|
446
|
+
"""Execute model-authored current-core constraints in their declared order."""
|
|
447
|
+
if program is None or domain_policy is None:
|
|
448
|
+
from .generated_resources import generated_resource
|
|
449
|
+
|
|
450
|
+
program = generated_resource("constraints") if program is None else program
|
|
451
|
+
domain_policy = generated_resource("domain_policy") if domain_policy is None else domain_policy
|
|
452
|
+
if not isinstance(program, Mapping) or program.get("profile") != "umf.constraints/1":
|
|
453
|
+
raise ValueError("Unsupported UMF constraint program")
|
|
454
|
+
_check_program(program)
|
|
455
|
+
registry = registry or Registry()
|
|
456
|
+
diagnostics: list[Diagnostic] = []
|
|
457
|
+
visits = 0
|
|
458
|
+
|
|
459
|
+
def charge():
|
|
460
|
+
nonlocal visits
|
|
461
|
+
visits += 1
|
|
462
|
+
if visits > 5_000_000:
|
|
463
|
+
raise ValueError("Constraint operation limit exceeded")
|
|
464
|
+
|
|
465
|
+
_run_steps(program["steps"], {"doc": document, "policy": domain_policy}, registry, diagnostics, charge)
|
|
466
|
+
return tuple(diagnostics)
|
|
467
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT OR Apache-2.0
|
|
2
|
+
"""Read-only access to compiler-owned UMF resources in source and wheel builds."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from functools import cache
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
_RESOURCE_PATHS = {
|
|
13
|
+
"schema": "spec/core/schema.json",
|
|
14
|
+
"semantic_reference": "spec/core/semantic-type-reference.schema.json",
|
|
15
|
+
"constraints": "spec/metamodel/generated/core-constraints.json",
|
|
16
|
+
"domain_policy": "spec/metamodel/generated/domain-policy.json",
|
|
17
|
+
"python_shapes": "spec/metamodel/generated/python-shapes.json",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_json(relative: str) -> Any:
|
|
22
|
+
"""Resolve an installed resource first, then the repository checkout."""
|
|
23
|
+
resource = files("umf").joinpath(*relative.split("/"))
|
|
24
|
+
if resource.is_file():
|
|
25
|
+
return json.loads(resource.read_text(encoding="utf-8"))
|
|
26
|
+
package_root = Path(__file__).resolve().parents[2]
|
|
27
|
+
source = package_root / relative
|
|
28
|
+
if not source.is_file():
|
|
29
|
+
source = package_root.parent / relative
|
|
30
|
+
if not source.is_file():
|
|
31
|
+
raise FileNotFoundError(f"Generated UMF resource missing: {relative}")
|
|
32
|
+
return json.loads(source.read_text(encoding="utf-8"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@cache
|
|
36
|
+
def _cached(name: str) -> Any:
|
|
37
|
+
try:
|
|
38
|
+
relative = _RESOURCE_PATHS[name]
|
|
39
|
+
except KeyError as error:
|
|
40
|
+
raise ValueError(f"Unknown generated UMF resource: {name}") from error
|
|
41
|
+
return _load_json(relative)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def generated_resource(name: str) -> Any:
|
|
45
|
+
"""Return an isolated copy of one compiler-owned generated resource."""
|
|
46
|
+
return json.loads(json.dumps(_cached(name), ensure_ascii=False))
|
|
47
|
+
|
umf/json.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT OR Apache-2.0
|
|
2
|
+
"""Portable JSON admission matching UMF's interoperable numeric boundary."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
MAX_DEPTH = 128
|
|
7
|
+
MAX_VALUES = 100_000
|
|
8
|
+
MAX_TEXT_LENGTH = 4_000_000
|
|
9
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def copy_json(value):
|
|
13
|
+
active = set()
|
|
14
|
+
count = 0
|
|
15
|
+
|
|
16
|
+
def visit(item, depth):
|
|
17
|
+
nonlocal count
|
|
18
|
+
count += 1
|
|
19
|
+
if count > MAX_VALUES or depth > MAX_DEPTH:
|
|
20
|
+
raise ValueError("LIMIT: JSON structural limit exceeded")
|
|
21
|
+
if item is None or type(item) in (str, bool):
|
|
22
|
+
return item
|
|
23
|
+
if type(item) is int:
|
|
24
|
+
if abs(item) > MAX_SAFE_INTEGER:
|
|
25
|
+
raise ValueError("NUMBER: use an exact integerToken carrier")
|
|
26
|
+
return item
|
|
27
|
+
if type(item) is float:
|
|
28
|
+
if (
|
|
29
|
+
not math.isfinite(item)
|
|
30
|
+
or item == 0
|
|
31
|
+
and math.copysign(1, item) < 0
|
|
32
|
+
or item.is_integer()
|
|
33
|
+
and abs(item) > MAX_SAFE_INTEGER
|
|
34
|
+
):
|
|
35
|
+
raise ValueError("NUMBER: outside the interoperable numeric profile")
|
|
36
|
+
return item
|
|
37
|
+
if type(item) not in (list, dict):
|
|
38
|
+
raise ValueError("NON_JSON: plain JSON values required")
|
|
39
|
+
if id(item) in active:
|
|
40
|
+
raise ValueError("CYCLE: cyclic JSON value")
|
|
41
|
+
active.add(id(item))
|
|
42
|
+
try:
|
|
43
|
+
if type(item) is list:
|
|
44
|
+
return [visit(child, depth + 1) for child in item]
|
|
45
|
+
if any(type(key) is not str for key in item):
|
|
46
|
+
raise ValueError("KEY: JSON object keys must be strings")
|
|
47
|
+
return {key: visit(child, depth + 1) for key, child in item.items()}
|
|
48
|
+
finally:
|
|
49
|
+
active.remove(id(item))
|
|
50
|
+
|
|
51
|
+
return visit(value, 0)
|