mosvera 0.1.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.
mosvera/__init__.py ADDED
@@ -0,0 +1,115 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from .compile import compile, compile_contract
4
+ from .errors import RegistryProjectError, ResolutionError
5
+ from .merge import merge
6
+ from .parser import parse
7
+ from .project import (
8
+ delete_project_document,
9
+ load_project,
10
+ save_project_document,
11
+ write_merge_strategies,
12
+ )
13
+ from .registry import (
14
+ collect_references,
15
+ compose_strategies,
16
+ create_composition,
17
+ create_modifier,
18
+ create_palette,
19
+ create_template,
20
+ get_registry_document,
21
+ list_registry_entries,
22
+ merge_registry,
23
+ remove_registry_document,
24
+ upsert_registry_document,
25
+ validate_registry,
26
+ )
27
+ from .resolve import (
28
+ resolve_aesthetic,
29
+ resolve_composition,
30
+ resolve_named_composition,
31
+ resolve_palette,
32
+ resolve_template,
33
+ )
34
+ from .strategies import derive_strategies
35
+ from .tokens import compile_design_tokens, to_css_variables
36
+ from .types import (
37
+ CapabilityManifest,
38
+ CompileResult,
39
+ Criticality,
40
+ CssVariableMap,
41
+ DesignTokens,
42
+ DocumentKind,
43
+ Json,
44
+ JsonObject,
45
+ LoadedProject,
46
+ LoweringAction,
47
+ MergeStrategies,
48
+ MergeStrategy,
49
+ Registry,
50
+ RegistryDiagnostic,
51
+ RegistryDiagnosticCode,
52
+ RegistryEntrySummary,
53
+ RegistryKind,
54
+ RegistryReference,
55
+ ResolutionErrorKind,
56
+ ValidationIssue,
57
+ ValidationResult,
58
+ )
59
+ from .validator import Validator, create_validator
60
+
61
+ __all__ = [
62
+ "CapabilityManifest",
63
+ "CompileResult",
64
+ "Criticality",
65
+ "CssVariableMap",
66
+ "DesignTokens",
67
+ "DocumentKind",
68
+ "Json",
69
+ "JsonObject",
70
+ "LoadedProject",
71
+ "LoweringAction",
72
+ "MergeStrategies",
73
+ "MergeStrategy",
74
+ "Registry",
75
+ "RegistryDiagnostic",
76
+ "RegistryDiagnosticCode",
77
+ "RegistryEntrySummary",
78
+ "RegistryKind",
79
+ "RegistryProjectError",
80
+ "RegistryReference",
81
+ "ResolutionError",
82
+ "ResolutionErrorKind",
83
+ "ValidationIssue",
84
+ "ValidationResult",
85
+ "Validator",
86
+ "collect_references",
87
+ "compile",
88
+ "compile_contract",
89
+ "compile_design_tokens",
90
+ "compose_strategies",
91
+ "create_composition",
92
+ "create_modifier",
93
+ "create_palette",
94
+ "create_template",
95
+ "create_validator",
96
+ "delete_project_document",
97
+ "derive_strategies",
98
+ "get_registry_document",
99
+ "list_registry_entries",
100
+ "load_project",
101
+ "merge",
102
+ "merge_registry",
103
+ "parse",
104
+ "remove_registry_document",
105
+ "resolve_aesthetic",
106
+ "resolve_composition",
107
+ "resolve_named_composition",
108
+ "resolve_palette",
109
+ "resolve_template",
110
+ "save_project_document",
111
+ "to_css_variables",
112
+ "upsert_registry_document",
113
+ "validate_registry",
114
+ "write_merge_strategies",
115
+ ]
mosvera/compile.py ADDED
@@ -0,0 +1,38 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ from .types import CapabilityManifest, CompileResult, Criticality, JsonObject
6
+
7
+
8
+ def compile_contract(
9
+ canonical: JsonObject,
10
+ manifest: CapabilityManifest,
11
+ criticality: dict[str, Criticality] | None = None,
12
+ ) -> CompileResult:
13
+ crit = criticality or {}
14
+ constructs = manifest.get("constructs", {})
15
+ warnings: list[dict[str, str]] = []
16
+
17
+ for name in sorted(canonical):
18
+ entry = constructs.get(name, {}) if isinstance(constructs, dict) else {}
19
+ action = (
20
+ entry.get("lowering_action", "unsupported")
21
+ if isinstance(entry, dict)
22
+ else "unsupported"
23
+ )
24
+ required = crit.get(name, "optional")
25
+
26
+ if action == "native":
27
+ continue
28
+ if action == "unsupported":
29
+ if required == "required":
30
+ return {"status": "error", "error": "required_unsupported", "construct": name}
31
+ warnings.append({"construct": name, "action": "unsupported"})
32
+ else:
33
+ warnings.append({"construct": name, "action": action})
34
+
35
+ return {"status": "compiled", "warnings": warnings}
36
+
37
+
38
+ compile = compile_contract
mosvera/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ from .types import RegistryDiagnostic, ResolutionErrorKind
6
+
7
+
8
+ class ResolutionError(Exception):
9
+ def __init__(self, kind: ResolutionErrorKind, message: str | None = None) -> None:
10
+ super().__init__(message or kind)
11
+ self.kind = kind
12
+
13
+
14
+ class RegistryProjectError(Exception):
15
+ def __init__(self, message: str, diagnostics: list[RegistryDiagnostic]) -> None:
16
+ super().__init__(message)
17
+ self.diagnostics = diagnostics
mosvera/merge.py ADDED
@@ -0,0 +1,81 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from typing import Any, TypeGuard
7
+
8
+ from .types import Json, JsonObject, MergeStrategies, MergeStrategy
9
+
10
+ STRUCTURAL = {"id", "$schema", "$extends", "base", "modifiers", "overrides"}
11
+
12
+
13
+ def _is_object(value: Any) -> TypeGuard[JsonObject]:
14
+ return isinstance(value, dict)
15
+
16
+
17
+ def _string_list(value: Any) -> list[str]:
18
+ return [x for x in value if isinstance(x, str)] if isinstance(value, list) else []
19
+
20
+
21
+ def _merge_list(
22
+ base: list[Json], overlay: list[Json], strategy: MergeStrategy | None
23
+ ) -> list[Json]:
24
+ if strategy is None or strategy.get("strategy") == "replace":
25
+ return deepcopy(overlay)
26
+ if strategy.get("strategy") == "append":
27
+ return [*deepcopy(base), *deepcopy(overlay)]
28
+
29
+ key = strategy.get("key")
30
+ if key is None:
31
+ return deepcopy(overlay)
32
+
33
+ result = deepcopy(base)
34
+ index: dict[Any, int] = {}
35
+ for i, element in enumerate(result):
36
+ if isinstance(element, dict) and key in element:
37
+ index[element[key]] = i
38
+
39
+ for element in overlay:
40
+ if isinstance(element, dict) and key in element and element[key] in index:
41
+ i = index[element[key]]
42
+ target = result[i]
43
+ if isinstance(target, dict):
44
+ result[i] = merge(target, element, {})
45
+ else:
46
+ result.append(deepcopy(element))
47
+ return result
48
+
49
+
50
+ def merge(acc: JsonObject, layer: JsonObject, strategies: MergeStrategies) -> JsonObject:
51
+ result = deepcopy(acc)
52
+
53
+ revert = _string_list(layer.get("$revert"))
54
+ pre: dict[str, tuple[bool, Json]] = {}
55
+ for field in revert:
56
+ pre[field] = (field in result, deepcopy(result.get(field)))
57
+
58
+ for key, value in layer.items():
59
+ if key in STRUCTURAL or key.startswith("$"):
60
+ continue
61
+ current = result.get(key)
62
+ if key not in result:
63
+ result[key] = deepcopy(value)
64
+ elif _is_object(current) and _is_object(value):
65
+ result[key] = merge(current, value, strategies)
66
+ elif isinstance(current, list) and isinstance(value, list):
67
+ result[key] = _merge_list(current, value, strategies.get(key))
68
+ else:
69
+ result[key] = deepcopy(value)
70
+
71
+ for field in _string_list(layer.get("$unset")):
72
+ result.pop(field, None)
73
+
74
+ for field in revert:
75
+ present, value = pre[field]
76
+ if present:
77
+ result[field] = value
78
+ else:
79
+ result.pop(field, None)
80
+
81
+ return result
mosvera/parser.py ADDED
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, cast
7
+
8
+ import yaml
9
+
10
+ from .types import JsonObject
11
+
12
+
13
+ def parse(source: str | Mapping[str, Any]) -> JsonObject:
14
+ doc: Any = yaml.safe_load(source) if isinstance(source, str) else source
15
+ if not isinstance(doc, dict):
16
+ raise TypeError("Mosvera document must be a mapping/object at the top level")
17
+ return cast(JsonObject, doc)
mosvera/project.py ADDED
@@ -0,0 +1,326 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Literal, cast
10
+
11
+ from .errors import RegistryProjectError
12
+ from .parser import parse
13
+ from .types import (
14
+ DocumentKind,
15
+ JsonObject,
16
+ LoadedProject,
17
+ MergeStrategies,
18
+ Registry,
19
+ RegistryDiagnostic,
20
+ RegistryDiagnosticCode,
21
+ RegistryKind,
22
+ RegistryReference,
23
+ )
24
+ from .validator import Validator, create_validator
25
+
26
+ DOC_EXT_RE = re.compile(r"\.(json|ya?ml)$", re.IGNORECASE)
27
+ SAFE_ID_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
28
+
29
+ ID_TO_KIND: dict[str, DocumentKind] = {
30
+ "https://mosvera.io/schema/0.1/template": "template",
31
+ "https://mosvera.io/schema/0.1/modifier": "modifier",
32
+ "https://mosvera.io/schema/0.1/palette": "palette",
33
+ "https://mosvera.io/schema/0.1/composition": "composition",
34
+ "https://mosvera.io/schema/0.1/capability-manifest": "capability-manifest",
35
+ }
36
+
37
+
38
+ def _classify(doc: JsonObject, file: str) -> DocumentKind:
39
+ schema_id = doc.get("$schema")
40
+ if isinstance(schema_id, str) and schema_id in ID_TO_KIND:
41
+ return ID_TO_KIND[schema_id]
42
+ if re.search(r"(^|/)template\.", file):
43
+ return "template"
44
+ if re.search(r"(^|/)modifier\.", file):
45
+ return "modifier"
46
+ if re.search(r"(^|/)palette\.", file):
47
+ return "palette"
48
+ if re.search(r"(^|/)composition\.", file):
49
+ return "composition"
50
+ if re.search(r"\.manifest\.(json|ya?ml)$", file, re.IGNORECASE):
51
+ return "capability-manifest"
52
+ raise ValueError(f'cannot classify document "{file}"')
53
+
54
+
55
+ DiagnosticKind = RegistryKind | Literal["capability-manifest"]
56
+
57
+
58
+ def _diagnostic(
59
+ code: RegistryDiagnosticCode,
60
+ message: str,
61
+ kind: DiagnosticKind | None = None,
62
+ id: str | None = None,
63
+ path: str | None = None,
64
+ reference: RegistryReference | None = None,
65
+ errors: list[JsonObject] | None = None,
66
+ ) -> RegistryDiagnostic:
67
+ return RegistryDiagnostic(
68
+ code=code,
69
+ message=message,
70
+ kind=kind,
71
+ id=id,
72
+ path=path,
73
+ reference=reference,
74
+ errors=errors,
75
+ )
76
+
77
+
78
+ def _ensure_safe_file(file: str, path_for_message: str) -> None:
79
+ if file.startswith(".") or "/" in file or "\\" in file or Path(file).is_absolute():
80
+ raise RegistryProjectError(
81
+ f'unsafe registry filename "{path_for_message}"',
82
+ [
83
+ _diagnostic(
84
+ "unsafe_filename",
85
+ f'unsafe registry filename "{path_for_message}"',
86
+ path=path_for_message,
87
+ )
88
+ ],
89
+ )
90
+
91
+
92
+ def _ensure_safe_id(id: str) -> None:
93
+ if SAFE_ID_RE.fullmatch(id) is None:
94
+ raise RegistryProjectError(
95
+ f'unsafe registry id "{id}"',
96
+ [
97
+ _diagnostic(
98
+ "unsafe_filename",
99
+ f'registry id "{id}" is not a valid Mosvera reference id',
100
+ id=id,
101
+ )
102
+ ],
103
+ )
104
+
105
+
106
+ def _read_doc(path: Path) -> JsonObject:
107
+ return parse(path.read_text(encoding="utf8"))
108
+
109
+
110
+ def _validate_or_throw(
111
+ validator: Validator, doc: JsonObject, kind: DocumentKind, file: str
112
+ ) -> None:
113
+ result = validator.validate(doc, kind)
114
+ if result.valid:
115
+ return
116
+ raise RegistryProjectError(
117
+ f'invalid {kind} document "{file}"',
118
+ [
119
+ _diagnostic(
120
+ "schema_failure",
121
+ f'invalid {kind} document "{file}"',
122
+ kind=kind,
123
+ path=file,
124
+ errors=[{"path": issue.path, "message": issue.message} for issue in result.errors],
125
+ )
126
+ ],
127
+ )
128
+
129
+
130
+ def _require_id(doc: JsonObject, kind: RegistryKind, path_for_message: str) -> str:
131
+ doc_id = doc.get("id")
132
+ if not isinstance(doc_id, str):
133
+ raise RegistryProjectError(
134
+ f'{kind} document "{path_for_message}" is missing a string id',
135
+ [
136
+ _diagnostic(
137
+ "invalid_document",
138
+ f'{kind} document "{path_for_message}" is missing a string id',
139
+ kind=kind,
140
+ path=path_for_message,
141
+ )
142
+ ],
143
+ )
144
+ _ensure_safe_id(doc_id)
145
+ return doc_id
146
+
147
+
148
+ def _provider_id(doc: JsonObject, path_for_message: str) -> str:
149
+ provider = doc.get("provider")
150
+ if not isinstance(provider, str):
151
+ raise RegistryProjectError(
152
+ f'manifest "{path_for_message}" is missing provider',
153
+ [
154
+ _diagnostic(
155
+ "invalid_document",
156
+ f'manifest "{path_for_message}" is missing provider',
157
+ kind="capability-manifest",
158
+ path=path_for_message,
159
+ )
160
+ ],
161
+ )
162
+ _ensure_safe_id(provider)
163
+ return provider
164
+
165
+
166
+ def _registry_collection(registry: Registry, kind: RegistryKind) -> dict[str, JsonObject]:
167
+ key = {
168
+ "template": "templates",
169
+ "modifier": "modifiers",
170
+ "palette": "palettes",
171
+ "composition": "compositions",
172
+ }[kind]
173
+ return registry.setdefault(key, {})
174
+
175
+
176
+ def _load_doc_file(
177
+ root: Path,
178
+ rel_file: str,
179
+ validator: Validator,
180
+ project: LoadedProject,
181
+ diagnostics: list[RegistryDiagnostic],
182
+ ) -> None:
183
+ _ensure_safe_file(Path(rel_file).name, rel_file)
184
+ doc = _read_doc(root / rel_file)
185
+ kind = _classify(doc, rel_file)
186
+ _validate_or_throw(validator, doc, kind, rel_file)
187
+
188
+ if kind == "capability-manifest":
189
+ provider = _provider_id(doc, rel_file)
190
+ if provider in project.manifests:
191
+ diagnostics.append(
192
+ _diagnostic(
193
+ "duplicate_id",
194
+ (
195
+ f'duplicate capability manifest provider "{provider}" '
196
+ f'while loading "{rel_file}"'
197
+ ),
198
+ kind="capability-manifest",
199
+ id=provider,
200
+ path=rel_file,
201
+ )
202
+ )
203
+ project.manifests[provider] = doc
204
+ return
205
+
206
+ collection = _registry_collection(project.registry, kind)
207
+ doc_id = _require_id(doc, kind, rel_file)
208
+ if doc_id in collection:
209
+ diagnostics.append(
210
+ _diagnostic(
211
+ "duplicate_id",
212
+ f'duplicate {kind} id "{doc_id}" while loading "{rel_file}"',
213
+ kind=kind,
214
+ id=doc_id,
215
+ path=rel_file,
216
+ )
217
+ )
218
+ collection[doc_id] = doc
219
+
220
+
221
+ def load_project(directory: str | Path, validator: Validator | None = None) -> LoadedProject:
222
+ active_validator = validator or create_validator()
223
+ root = Path(directory).resolve()
224
+ strategies: MergeStrategies = {}
225
+ strategy_path = root / "merge-strategies.json"
226
+ if strategy_path.exists():
227
+ strategies = cast(MergeStrategies, _read_doc(strategy_path))
228
+
229
+ project = LoadedProject(
230
+ registry={"templates": {}, "modifiers": {}, "palettes": {}, "compositions": {}},
231
+ manifests={},
232
+ strategies=strategies,
233
+ )
234
+ diagnostics: list[RegistryDiagnostic] = []
235
+
236
+ for entry in root.iterdir():
237
+ if (
238
+ entry.is_file()
239
+ and DOC_EXT_RE.search(entry.name)
240
+ and entry.name != "merge-strategies.json"
241
+ ):
242
+ _load_doc_file(root, entry.name, active_validator, project, diagnostics)
243
+
244
+ manifests_dir = root / "manifests"
245
+ if manifests_dir.exists():
246
+ for entry in manifests_dir.iterdir():
247
+ if entry.is_file() and DOC_EXT_RE.search(entry.name):
248
+ _load_doc_file(
249
+ root,
250
+ str(Path("manifests") / entry.name),
251
+ active_validator,
252
+ project,
253
+ diagnostics,
254
+ )
255
+
256
+ if diagnostics:
257
+ raise RegistryProjectError("registry project contains duplicate ids", diagnostics)
258
+ return project
259
+
260
+
261
+ def _stable_stringify(value: object) -> str:
262
+ return json.dumps(value, indent=2, sort_keys=True) + "\n"
263
+
264
+
265
+ def _file_name(kind: RegistryKind, id: str) -> str:
266
+ return f"{kind}.{id}.json"
267
+
268
+
269
+ def _assert_within(root: Path, target: Path) -> None:
270
+ try:
271
+ target.relative_to(root)
272
+ except ValueError as exc:
273
+ raise RegistryProjectError(
274
+ f"path escapes registry root: {target}",
275
+ [
276
+ _diagnostic(
277
+ "unsafe_filename", f"path escapes registry root: {target}", path=str(target)
278
+ )
279
+ ],
280
+ ) from exc
281
+
282
+
283
+ def _atomic_write(path: Path, body: str) -> None:
284
+ temp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
285
+ temp.write_text(body, encoding="utf8")
286
+ os.replace(temp, path)
287
+
288
+
289
+ def save_project_document(
290
+ directory: str | Path,
291
+ kind: RegistryKind,
292
+ document: JsonObject,
293
+ validator: Validator | None = None,
294
+ create_directory: bool = False,
295
+ ) -> None:
296
+ active_validator = validator or create_validator()
297
+ doc_id = _require_id(document, kind, f"{kind}.{document.get('id', '<missing>')}.json")
298
+ root = Path(directory).resolve()
299
+ if create_directory:
300
+ root.mkdir(parents=True, exist_ok=True)
301
+ path = root / _file_name(kind, doc_id)
302
+ _assert_within(root, path)
303
+ _validate_or_throw(active_validator, document, kind, path.name)
304
+ _atomic_write(path, _stable_stringify(document))
305
+
306
+
307
+ def delete_project_document(directory: str | Path, kind: RegistryKind, id: str) -> None:
308
+ _ensure_safe_id(id)
309
+ root = Path(directory).resolve()
310
+ path = root / _file_name(kind, id)
311
+ _assert_within(root, path)
312
+ if path.exists():
313
+ path.unlink()
314
+
315
+
316
+ def write_merge_strategies(
317
+ directory: str | Path,
318
+ strategies: MergeStrategies,
319
+ create_directory: bool = False,
320
+ ) -> None:
321
+ root = Path(directory).resolve()
322
+ if create_directory:
323
+ root.mkdir(parents=True, exist_ok=True)
324
+ path = root / "merge-strategies.json"
325
+ _assert_within(root, path)
326
+ _atomic_write(path, _stable_stringify(strategies))
mosvera/py.typed ADDED
File without changes