modelable 1.0.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.
Potentially problematic release.
This version of modelable might be problematic. Click here for more details.
- modelable/__init__.py +1 -0
- modelable/__main__.py +3 -0
- modelable/_pydantic_py314_compat.py +31 -0
- modelable/cli.py +41 -0
- modelable/commands/__init__.py +1 -0
- modelable/commands/apicurio.py +84 -0
- modelable/commands/codegen.py +241 -0
- modelable/commands/common.py +43 -0
- modelable/commands/compile.py +237 -0
- modelable/commands/create.py +164 -0
- modelable/commands/diff.py +82 -0
- modelable/commands/graph.py +53 -0
- modelable/commands/llm.py +564 -0
- modelable/commands/lsp.py +15 -0
- modelable/commands/runtime.py +37 -0
- modelable/commands/scenario.py +104 -0
- modelable/commands/spec.py +197 -0
- modelable/commands/workspace.py +240 -0
- modelable/compat/__init__.py +11 -0
- modelable/compat/checker.py +179 -0
- modelable/compat/diff.py +169 -0
- modelable/compiler/__init__.py +3 -0
- modelable/compiler/compiler.py +19 -0
- modelable/compiler/workspace.py +346 -0
- modelable/diagnostics/__init__.py +3 -0
- modelable/diagnostics/model.py +27 -0
- modelable/emitters/__init__.py +0 -0
- modelable/emitters/base.py +22 -0
- modelable/emitters/csharp.py +245 -0
- modelable/emitters/dbt_yaml.py +290 -0
- modelable/emitters/diagnostics.py +25 -0
- modelable/emitters/fhir.py +694 -0
- modelable/emitters/fhir_validator.py +36 -0
- modelable/emitters/go.py +334 -0
- modelable/emitters/java.py +264 -0
- modelable/emitters/json_schema.py +458 -0
- modelable/emitters/markdown.py +252 -0
- modelable/emitters/odcs.py +355 -0
- modelable/emitters/openlineage.py +315 -0
- modelable/emitters/openmetadata.py +258 -0
- modelable/emitters/python.py +282 -0
- modelable/emitters/rust.py +643 -0
- modelable/emitters/shapes.py +261 -0
- modelable/emitters/sql.py +266 -0
- modelable/emitters/targets.py +141 -0
- modelable/emitters/typescript.py +352 -0
- modelable/expressions/__init__.py +0 -0
- modelable/expressions/cel.py +547 -0
- modelable/governance/__init__.py +3 -0
- modelable/governance/checker.py +271 -0
- modelable/governance/por.py +46 -0
- modelable/grammar/__init__.py +1 -0
- modelable/grammar/modelable.lark +257 -0
- modelable/graph/__init__.py +5 -0
- modelable/graph/export.py +442 -0
- modelable/llm/__init__.py +43 -0
- modelable/llm/chat.py +255 -0
- modelable/llm/config.py +87 -0
- modelable/llm/context.py +194 -0
- modelable/llm/engine.py +976 -0
- modelable/llm/importers.py +1077 -0
- modelable/llm/provenance.py +84 -0
- modelable/llm/providers.py +182 -0
- modelable/llm/qa.py +126 -0
- modelable/llm/recommendations.py +33 -0
- modelable/llm/redaction.py +19 -0
- modelable/llm/render.py +279 -0
- modelable/llm/update_plan.py +101 -0
- modelable/llm/validation_help.py +10 -0
- modelable/lsp/__init__.py +3 -0
- modelable/lsp/__main__.py +4 -0
- modelable/lsp/code_actions.py +210 -0
- modelable/lsp/completion.py +480 -0
- modelable/lsp/definition.py +343 -0
- modelable/lsp/diagnostics.py +31 -0
- modelable/lsp/document_symbols.py +197 -0
- modelable/lsp/federation.py +261 -0
- modelable/lsp/folding.py +33 -0
- modelable/lsp/formatting.py +64 -0
- modelable/lsp/highlight.py +30 -0
- modelable/lsp/hover.py +370 -0
- modelable/lsp/inlay_hints.py +158 -0
- modelable/lsp/references.py +511 -0
- modelable/lsp/rename.py +564 -0
- modelable/lsp/semantic_tokens.py +412 -0
- modelable/lsp/server.py +370 -0
- modelable/lsp/workspace.py +83 -0
- modelable/lsp/workspace_symbols.py +104 -0
- modelable/parser/__init__.py +94 -0
- modelable/parser/ir.py +451 -0
- modelable/parser/parse.py +47 -0
- modelable/parser/transformer.py +798 -0
- modelable/parser/wire.py +68 -0
- modelable/planner/__init__.py +0 -0
- modelable/planner/lineage.py +91 -0
- modelable/planner/planner.py +134 -0
- modelable/planner/plans.py +122 -0
- modelable/py.typed +0 -0
- modelable/registry/__init__.py +9 -0
- modelable/registry/apicurio.py +166 -0
- modelable/registry/base.py +18 -0
- modelable/registry/factory.py +18 -0
- modelable/registry/index.py +419 -0
- modelable/registry/local.py +26 -0
- modelable/registry/oci.py +22 -0
- modelable/registry/resolver.py +213 -0
- modelable/registry/schema.sql +119 -0
- modelable/registry/signature.py +26 -0
- modelable/release.py +125 -0
- modelable/runtime/__init__.py +5 -0
- modelable/runtime/adapter/__init__.py +17 -0
- modelable/runtime/adapter/base.py +18 -0
- modelable/runtime/adapter/postgres.py +82 -0
- modelable/specs/__init__.py +23 -0
- modelable/specs/tracking.py +220 -0
- modelable/validation/__init__.py +3 -0
- modelable/validation/semantic.py +659 -0
- modelable-1.0.0.dist-info/METADATA +61 -0
- modelable-1.0.0.dist-info/RECORD +122 -0
- modelable-1.0.0.dist-info/WHEEL +4 -0
- modelable-1.0.0.dist-info/entry_points.txt +2 -0
- modelable-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from modelable.compat.checker import check_model_version_compatibility
|
|
10
|
+
from modelable.compiler.workspace import Workspace
|
|
11
|
+
from modelable.diagnostics.model import render_diagnostic
|
|
12
|
+
from modelable.governance.por import build_por_record
|
|
13
|
+
from modelable.parser.ir import AccessBlock, AccessGrant, ComputedMapping, DirectMapping
|
|
14
|
+
from modelable.planner.lineage import build_projection_lineage
|
|
15
|
+
from modelable.registry.resolver import resolved_version_spec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_registry(workspace: Workspace, output_dir: str | Path = ".modelable") -> Path:
|
|
19
|
+
"""Write a rebuildable SQLite registry index for a validated workspace."""
|
|
20
|
+
if workspace.errors:
|
|
21
|
+
joined = "\n".join(render_diagnostic(diagnostic) for diagnostic in workspace.errors)
|
|
22
|
+
raise ValueError(f"Cannot build registry with validation errors:\n{joined}")
|
|
23
|
+
|
|
24
|
+
output_path = Path(output_dir)
|
|
25
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
registry_path = output_path / "registry.db"
|
|
27
|
+
if registry_path.exists():
|
|
28
|
+
registry_path.unlink()
|
|
29
|
+
|
|
30
|
+
schema = files("modelable.registry").joinpath("schema.sql").read_text(encoding="utf-8")
|
|
31
|
+
with sqlite3.connect(registry_path) as conn:
|
|
32
|
+
conn.executescript(schema)
|
|
33
|
+
_insert_workspace(conn, workspace)
|
|
34
|
+
conn.commit()
|
|
35
|
+
|
|
36
|
+
return registry_path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _insert_workspace(conn: sqlite3.Connection, workspace: Workspace) -> None:
|
|
40
|
+
source_paths = {id(domain): source.path for source in workspace.sources for domain in source.mdl.domains}
|
|
41
|
+
|
|
42
|
+
for domain in workspace.mdl.domains:
|
|
43
|
+
conn.execute(
|
|
44
|
+
"insert into domains (name, owner, description) values (?, ?, ?)",
|
|
45
|
+
(domain.name, domain.owner, domain.description),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
for model_name, versions in domain.models.items():
|
|
49
|
+
kind = versions[0].model_kind.value
|
|
50
|
+
conn.execute(
|
|
51
|
+
"insert into models (domain_name, name, kind) values (?, ?, ?)",
|
|
52
|
+
(domain.name, model_name, kind),
|
|
53
|
+
)
|
|
54
|
+
for version in versions:
|
|
55
|
+
conn.execute(
|
|
56
|
+
"""
|
|
57
|
+
insert into model_versions
|
|
58
|
+
(domain_name, model_name, version, change_kind, source_path)
|
|
59
|
+
values (?, ?, ?, ?, ?)
|
|
60
|
+
""",
|
|
61
|
+
(
|
|
62
|
+
domain.name,
|
|
63
|
+
model_name,
|
|
64
|
+
version.version,
|
|
65
|
+
version.change_kind.value,
|
|
66
|
+
str(source_paths[id(domain)]),
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
for position, field in enumerate(version.fields):
|
|
70
|
+
classification = field.classification
|
|
71
|
+
conn.execute(
|
|
72
|
+
"""
|
|
73
|
+
insert into fields
|
|
74
|
+
(
|
|
75
|
+
domain_name, model_name, model_version, field_name,
|
|
76
|
+
position, type_json, optional, is_key, is_pii, classification
|
|
77
|
+
)
|
|
78
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
79
|
+
""",
|
|
80
|
+
(
|
|
81
|
+
domain.name,
|
|
82
|
+
model_name,
|
|
83
|
+
version.version,
|
|
84
|
+
field.name,
|
|
85
|
+
position,
|
|
86
|
+
_to_json(field.type),
|
|
87
|
+
int(field.optional),
|
|
88
|
+
int(field.is_key),
|
|
89
|
+
int(field.is_pii),
|
|
90
|
+
classification.value if classification else None,
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
_insert_access_policies(
|
|
94
|
+
conn,
|
|
95
|
+
subject_ref=f"{domain.name}.{model_name}@{version.version}",
|
|
96
|
+
access=version.access,
|
|
97
|
+
same_domain=domain.name,
|
|
98
|
+
owner=domain.owner,
|
|
99
|
+
)
|
|
100
|
+
_insert_por_log(
|
|
101
|
+
conn,
|
|
102
|
+
build_por_record(f"{domain.name}.{model_name}.v{version.version}").as_dict(),
|
|
103
|
+
)
|
|
104
|
+
_insert_compatibility_reports(conn, workspace, domain.name, model_name, versions)
|
|
105
|
+
|
|
106
|
+
for projection_name, versions in domain.projections.items():
|
|
107
|
+
conn.execute(
|
|
108
|
+
"insert into projections (domain_name, name) values (?, ?)",
|
|
109
|
+
(domain.name, projection_name),
|
|
110
|
+
)
|
|
111
|
+
for version in versions:
|
|
112
|
+
conn.execute(
|
|
113
|
+
"""
|
|
114
|
+
insert into projection_versions
|
|
115
|
+
(
|
|
116
|
+
domain_name, projection_name, version, source_model,
|
|
117
|
+
source_version_json, source_alias
|
|
118
|
+
)
|
|
119
|
+
values (?, ?, ?, ?, ?, ?)
|
|
120
|
+
""",
|
|
121
|
+
(
|
|
122
|
+
domain.name,
|
|
123
|
+
projection_name,
|
|
124
|
+
version.version,
|
|
125
|
+
version.source.model,
|
|
126
|
+
_to_json(
|
|
127
|
+
resolved_version_spec(
|
|
128
|
+
workspace.mdl,
|
|
129
|
+
version.source.model,
|
|
130
|
+
version.source.version,
|
|
131
|
+
)
|
|
132
|
+
),
|
|
133
|
+
version.source.alias,
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
conn.execute(
|
|
137
|
+
"""
|
|
138
|
+
insert into projection_sources
|
|
139
|
+
(
|
|
140
|
+
domain_name, projection_name, projection_version,
|
|
141
|
+
source_kind, source_model, source_version_json, source_alias
|
|
142
|
+
)
|
|
143
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
144
|
+
""",
|
|
145
|
+
(
|
|
146
|
+
domain.name,
|
|
147
|
+
projection_name,
|
|
148
|
+
version.version,
|
|
149
|
+
"primary",
|
|
150
|
+
version.source.model,
|
|
151
|
+
_to_json(
|
|
152
|
+
resolved_version_spec(
|
|
153
|
+
workspace.mdl,
|
|
154
|
+
version.source.model,
|
|
155
|
+
version.source.version,
|
|
156
|
+
)
|
|
157
|
+
),
|
|
158
|
+
version.source.alias,
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
for join in version.joins:
|
|
162
|
+
conn.execute(
|
|
163
|
+
"""
|
|
164
|
+
insert into projection_sources
|
|
165
|
+
(
|
|
166
|
+
domain_name, projection_name, projection_version,
|
|
167
|
+
source_kind, source_model, source_version_json,
|
|
168
|
+
source_alias, join_on
|
|
169
|
+
)
|
|
170
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
171
|
+
""",
|
|
172
|
+
(
|
|
173
|
+
domain.name,
|
|
174
|
+
projection_name,
|
|
175
|
+
version.version,
|
|
176
|
+
"join",
|
|
177
|
+
join.model,
|
|
178
|
+
_to_json(resolved_version_spec(workspace.mdl, join.model, join.version)),
|
|
179
|
+
join.alias,
|
|
180
|
+
join.on,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
for position, field in enumerate(version.fields):
|
|
184
|
+
classification = field.classification
|
|
185
|
+
conn.execute(
|
|
186
|
+
"""
|
|
187
|
+
insert into projection_fields
|
|
188
|
+
(
|
|
189
|
+
domain_name, projection_name, projection_version,
|
|
190
|
+
field_name, position, mapping_json, is_pii, classification
|
|
191
|
+
)
|
|
192
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
193
|
+
""",
|
|
194
|
+
(
|
|
195
|
+
domain.name,
|
|
196
|
+
projection_name,
|
|
197
|
+
version.version,
|
|
198
|
+
field.name,
|
|
199
|
+
position,
|
|
200
|
+
_to_json(field.mapping),
|
|
201
|
+
int(field.is_pii),
|
|
202
|
+
classification.value if classification else None,
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
_insert_field_mapping(
|
|
206
|
+
conn,
|
|
207
|
+
domain.name,
|
|
208
|
+
projection_name,
|
|
209
|
+
version.version,
|
|
210
|
+
field.name,
|
|
211
|
+
field.mapping,
|
|
212
|
+
)
|
|
213
|
+
_insert_lineage_edges(conn, workspace, domain.name, projection_name, version)
|
|
214
|
+
_insert_access_policies(
|
|
215
|
+
conn,
|
|
216
|
+
subject_ref=f"{domain.name}.{projection_name}@{version.version}",
|
|
217
|
+
access=version.access,
|
|
218
|
+
same_domain=domain.name,
|
|
219
|
+
owner=domain.owner,
|
|
220
|
+
)
|
|
221
|
+
_insert_por_log(
|
|
222
|
+
conn,
|
|
223
|
+
build_por_record(f"{domain.name}.{projection_name}.v{version.version}").as_dict(),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
for binding in workspace.mdl.bindings:
|
|
227
|
+
conn.execute(
|
|
228
|
+
"""
|
|
229
|
+
insert into adapter_bindings (name, model_ref, adapter, table_name)
|
|
230
|
+
values (?, ?, ?, ?)
|
|
231
|
+
""",
|
|
232
|
+
(
|
|
233
|
+
binding.name,
|
|
234
|
+
f"{binding.model}@{binding.model_version}",
|
|
235
|
+
binding.adapter,
|
|
236
|
+
binding.table,
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _insert_field_mapping(
|
|
242
|
+
conn: sqlite3.Connection,
|
|
243
|
+
domain_name: str,
|
|
244
|
+
projection_name: str,
|
|
245
|
+
projection_version: int,
|
|
246
|
+
target_field: str,
|
|
247
|
+
mapping: DirectMapping | ComputedMapping,
|
|
248
|
+
) -> None:
|
|
249
|
+
if isinstance(mapping, DirectMapping):
|
|
250
|
+
conn.execute(
|
|
251
|
+
"""
|
|
252
|
+
insert into field_mappings
|
|
253
|
+
(
|
|
254
|
+
domain_name, projection_name, projection_version, target_field,
|
|
255
|
+
mapping_kind, source_alias, source_field
|
|
256
|
+
)
|
|
257
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
258
|
+
""",
|
|
259
|
+
(
|
|
260
|
+
domain_name,
|
|
261
|
+
projection_name,
|
|
262
|
+
projection_version,
|
|
263
|
+
target_field,
|
|
264
|
+
mapping.kind,
|
|
265
|
+
mapping.source_alias,
|
|
266
|
+
mapping.source_field,
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
conn.execute(
|
|
272
|
+
"""
|
|
273
|
+
insert into field_mappings
|
|
274
|
+
(
|
|
275
|
+
domain_name, projection_name, projection_version, target_field,
|
|
276
|
+
mapping_kind, expression
|
|
277
|
+
)
|
|
278
|
+
values (?, ?, ?, ?, ?, ?)
|
|
279
|
+
""",
|
|
280
|
+
(
|
|
281
|
+
domain_name,
|
|
282
|
+
projection_name,
|
|
283
|
+
projection_version,
|
|
284
|
+
target_field,
|
|
285
|
+
mapping.kind,
|
|
286
|
+
mapping.expression,
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _insert_access_policies(
|
|
292
|
+
conn: sqlite3.Connection,
|
|
293
|
+
*,
|
|
294
|
+
subject_ref: str,
|
|
295
|
+
access: AccessBlock | None,
|
|
296
|
+
same_domain: str,
|
|
297
|
+
owner: str | None,
|
|
298
|
+
) -> None:
|
|
299
|
+
if access is not None:
|
|
300
|
+
for grant in access.entity:
|
|
301
|
+
_insert_access_grant(
|
|
302
|
+
conn,
|
|
303
|
+
subject_ref=subject_ref,
|
|
304
|
+
grant=grant,
|
|
305
|
+
)
|
|
306
|
+
for field_name, grants in access.properties.items():
|
|
307
|
+
for grant in grants:
|
|
308
|
+
_insert_access_grant(
|
|
309
|
+
conn,
|
|
310
|
+
subject_ref=f"{subject_ref}.{field_name}",
|
|
311
|
+
grant=grant,
|
|
312
|
+
)
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
for action in ("read", "project", "subscribe"):
|
|
316
|
+
conn.execute(
|
|
317
|
+
"""
|
|
318
|
+
insert into access_policies (subject_ref, action, grantee)
|
|
319
|
+
values (?, ?, ?)
|
|
320
|
+
""",
|
|
321
|
+
(subject_ref, action, same_domain),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if owner:
|
|
325
|
+
for action in ("write", "transfer", "manage_access"):
|
|
326
|
+
conn.execute(
|
|
327
|
+
"""
|
|
328
|
+
insert into access_policies (subject_ref, action, grantee)
|
|
329
|
+
values (?, ?, ?)
|
|
330
|
+
""",
|
|
331
|
+
(subject_ref, action, owner),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _insert_access_grant(
|
|
336
|
+
conn: sqlite3.Connection,
|
|
337
|
+
*,
|
|
338
|
+
subject_ref: str,
|
|
339
|
+
grant: AccessGrant,
|
|
340
|
+
) -> None:
|
|
341
|
+
for action in grant.permissions:
|
|
342
|
+
conn.execute(
|
|
343
|
+
"""
|
|
344
|
+
insert into access_policies (subject_ref, action, grantee)
|
|
345
|
+
values (?, ?, ?)
|
|
346
|
+
""",
|
|
347
|
+
(subject_ref, action, grant.principal),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _insert_compatibility_reports(
|
|
352
|
+
conn: sqlite3.Connection,
|
|
353
|
+
workspace: Workspace,
|
|
354
|
+
domain_name: str,
|
|
355
|
+
model_name: str,
|
|
356
|
+
versions,
|
|
357
|
+
) -> None:
|
|
358
|
+
for previous, current in itertools.pairwise(versions):
|
|
359
|
+
report = check_model_version_compatibility(
|
|
360
|
+
workspace.mdl,
|
|
361
|
+
domain_name,
|
|
362
|
+
model_name,
|
|
363
|
+
previous.version,
|
|
364
|
+
current.version,
|
|
365
|
+
)
|
|
366
|
+
conn.execute(
|
|
367
|
+
"""
|
|
368
|
+
insert into compatibility_reports
|
|
369
|
+
(domain_name, model_name, from_version, to_version, status)
|
|
370
|
+
values (?, ?, ?, ?, ?)
|
|
371
|
+
""",
|
|
372
|
+
(
|
|
373
|
+
domain_name,
|
|
374
|
+
model_name,
|
|
375
|
+
previous.version,
|
|
376
|
+
current.version,
|
|
377
|
+
report.status,
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _insert_lineage_edges(
|
|
383
|
+
conn: sqlite3.Connection,
|
|
384
|
+
workspace: Workspace,
|
|
385
|
+
domain_name: str,
|
|
386
|
+
projection_name: str,
|
|
387
|
+
version,
|
|
388
|
+
) -> None:
|
|
389
|
+
lineage = build_projection_lineage(domain_name, projection_name, version, workspace.mdl)
|
|
390
|
+
target_prefix = f"{domain_name}.{projection_name}@{version.version}"
|
|
391
|
+
for field_lineage in lineage.fields:
|
|
392
|
+
target_ref = f"{target_prefix}.{field_lineage.field_name}"
|
|
393
|
+
for source_ref in field_lineage.lineage:
|
|
394
|
+
conn.execute(
|
|
395
|
+
"""
|
|
396
|
+
insert into lineage_edges (source_ref, target_ref, edge_kind)
|
|
397
|
+
values (?, ?, ?)
|
|
398
|
+
""",
|
|
399
|
+
(source_ref, target_ref, field_lineage.kind),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _insert_por_log(conn: sqlite3.Connection, por_record: dict[str, str | None]) -> None:
|
|
404
|
+
conn.execute(
|
|
405
|
+
"""
|
|
406
|
+
insert into por_log (model_ref, issuer, issued_at, signature)
|
|
407
|
+
values (?, ?, ?, ?)
|
|
408
|
+
""",
|
|
409
|
+
(
|
|
410
|
+
por_record["model"],
|
|
411
|
+
por_record["issuer"],
|
|
412
|
+
por_record["issuedAt"],
|
|
413
|
+
por_record.get("signature"),
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _to_json(value) -> str:
|
|
419
|
+
return json.dumps(value.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from shutil import copy2
|
|
5
|
+
|
|
6
|
+
from modelable.registry.base import Registry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LocalRegistry(Registry):
|
|
10
|
+
"""Local file-based registry."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, path: Path) -> None:
|
|
13
|
+
self.path = path
|
|
14
|
+
|
|
15
|
+
def push(self, registry_path: Path) -> None:
|
|
16
|
+
"""Push a registry index to the registry."""
|
|
17
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
if registry_path.resolve() == self.path.resolve():
|
|
19
|
+
return
|
|
20
|
+
copy2(registry_path, self.path)
|
|
21
|
+
|
|
22
|
+
def pull(self, dest_path: Path) -> None:
|
|
23
|
+
"""Pull a registry index from the registry."""
|
|
24
|
+
if self.path.resolve() == dest_path.resolve():
|
|
25
|
+
return
|
|
26
|
+
copy2(self.path, dest_path)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from modelable.registry.base import Registry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OCIRegistry(Registry):
|
|
9
|
+
"""OCI-compliant registry."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, url: str) -> None:
|
|
12
|
+
self.url = url
|
|
13
|
+
|
|
14
|
+
def push(self, registry_path: Path) -> None:
|
|
15
|
+
"""Push a registry index to the OCI registry."""
|
|
16
|
+
# TODO: Implement OCI push (e.g., skopeo copy)
|
|
17
|
+
print(f"Pushing to {self.url} (not implemented)")
|
|
18
|
+
|
|
19
|
+
def pull(self, dest_path: Path) -> None:
|
|
20
|
+
"""Pull a registry index from the OCI registry."""
|
|
21
|
+
# TODO: Implement OCI pull (e.g., skopeo copy)
|
|
22
|
+
print(f"Pulling from {self.url} (not implemented)")
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from modelable.parser.ir import (
|
|
6
|
+
MdlFile,
|
|
7
|
+
ModelVersion,
|
|
8
|
+
ProjectionVersion,
|
|
9
|
+
VersionExact,
|
|
10
|
+
VersionMin,
|
|
11
|
+
VersionPinned,
|
|
12
|
+
VersionRange,
|
|
13
|
+
VersionSpec,
|
|
14
|
+
)
|
|
15
|
+
from modelable.registry.signature import compute_version_signature
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ResolvedModelRef:
|
|
20
|
+
domain_name: str
|
|
21
|
+
model_name: str
|
|
22
|
+
version: ModelVersion | ProjectionVersion
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_model_ref(
|
|
26
|
+
mdl: MdlFile,
|
|
27
|
+
model_ref: str,
|
|
28
|
+
version_spec: VersionSpec | int,
|
|
29
|
+
) -> ResolvedModelRef:
|
|
30
|
+
"""Resolve a model reference to a concrete published model version."""
|
|
31
|
+
domain_name, model_name = _split_model_ref(model_ref)
|
|
32
|
+
versions = _find_model_versions(mdl, domain_name, model_name)
|
|
33
|
+
if not versions:
|
|
34
|
+
raise LookupError(f"unresolved model reference {model_ref}@{_format_version_spec(version_spec)}")
|
|
35
|
+
|
|
36
|
+
matching = [version for version in versions if _matches(version, version_spec, domain_name, model_name)]
|
|
37
|
+
if not matching:
|
|
38
|
+
raise LookupError(f"unresolved model reference {model_ref}@{_format_version_spec(version_spec)}")
|
|
39
|
+
|
|
40
|
+
selected = max(matching, key=lambda version: version.version)
|
|
41
|
+
|
|
42
|
+
# If using a range or min spec, ensure no breaking change exists between
|
|
43
|
+
# the requested start and the selected version.
|
|
44
|
+
if isinstance(version_spec, (VersionRange, VersionMin)):
|
|
45
|
+
min_v = version_spec.min_inclusive
|
|
46
|
+
# Check all versions from min_v + 1 up to selected.version
|
|
47
|
+
for v in versions:
|
|
48
|
+
if min_v < v.version <= selected.version:
|
|
49
|
+
from modelable.parser.ir import ChangeKind
|
|
50
|
+
|
|
51
|
+
if v.change_kind == ChangeKind.breaking:
|
|
52
|
+
raise LookupError(
|
|
53
|
+
f"unresolved model reference {model_ref}@{_format_version_spec(version_spec)}: "
|
|
54
|
+
f"breaking change at version {v.version} blocks automatic resolution"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return ResolvedModelRef(
|
|
58
|
+
domain_name=domain_name,
|
|
59
|
+
model_name=model_name,
|
|
60
|
+
version=selected,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolved_version_spec(
|
|
65
|
+
mdl: MdlFile,
|
|
66
|
+
model_ref: str,
|
|
67
|
+
version_spec: VersionSpec | int,
|
|
68
|
+
) -> VersionExact:
|
|
69
|
+
"""Return the concrete version selected by a model reference."""
|
|
70
|
+
resolved = resolve_model_ref(mdl, model_ref, version_spec)
|
|
71
|
+
return VersionExact(version=resolved.version.version)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_dependents(
|
|
75
|
+
mdl: MdlFile,
|
|
76
|
+
domain_name: str,
|
|
77
|
+
model_name: str,
|
|
78
|
+
version: int,
|
|
79
|
+
) -> list[tuple[str, str, int]]:
|
|
80
|
+
"""Return list of (domain, projection, version) depending on the source model version."""
|
|
81
|
+
dependents: list[tuple[str, str, int]] = []
|
|
82
|
+
source_ref = f"{domain_name}.{model_name}"
|
|
83
|
+
|
|
84
|
+
for domain in mdl.domains:
|
|
85
|
+
for proj_name, proj_versions in domain.projections.items():
|
|
86
|
+
for pv in proj_versions:
|
|
87
|
+
# Check primary source
|
|
88
|
+
is_dependent = False
|
|
89
|
+
if pv.source.model == source_ref:
|
|
90
|
+
try:
|
|
91
|
+
resolved = resolve_model_ref(mdl, pv.source.model, pv.source.version)
|
|
92
|
+
if resolved.version.version == version:
|
|
93
|
+
is_dependent = True
|
|
94
|
+
except LookupError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
# Check joins if not already found
|
|
98
|
+
if not is_dependent:
|
|
99
|
+
for join in pv.joins:
|
|
100
|
+
if join.model == source_ref:
|
|
101
|
+
try:
|
|
102
|
+
resolved = resolve_model_ref(mdl, join.model, join.version)
|
|
103
|
+
if resolved.version.version == version:
|
|
104
|
+
is_dependent = True
|
|
105
|
+
break
|
|
106
|
+
except LookupError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
if is_dependent:
|
|
110
|
+
dependents.append((domain.name, proj_name, pv.version))
|
|
111
|
+
|
|
112
|
+
return dependents
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def validate_references(mdl: MdlFile) -> list[str]:
|
|
116
|
+
"""Return unresolved reference errors for projections, joins, and bindings."""
|
|
117
|
+
errors: list[str] = []
|
|
118
|
+
|
|
119
|
+
for domain in mdl.domains:
|
|
120
|
+
for projection_name, projection_versions in domain.projections.items():
|
|
121
|
+
for projection_version in projection_versions:
|
|
122
|
+
context = f"{domain.name}.{projection_name}@{projection_version.version}"
|
|
123
|
+
_append_lookup_error(
|
|
124
|
+
errors,
|
|
125
|
+
context,
|
|
126
|
+
projection_version.source.model,
|
|
127
|
+
projection_version.source.version,
|
|
128
|
+
mdl,
|
|
129
|
+
)
|
|
130
|
+
for join in projection_version.joins:
|
|
131
|
+
_append_lookup_error(errors, context, join.model, join.version, mdl)
|
|
132
|
+
|
|
133
|
+
for binding in mdl.bindings:
|
|
134
|
+
if not binding.model:
|
|
135
|
+
continue
|
|
136
|
+
_append_lookup_error(
|
|
137
|
+
errors,
|
|
138
|
+
f"binding {binding.name}",
|
|
139
|
+
binding.model,
|
|
140
|
+
binding.model_version,
|
|
141
|
+
mdl,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return errors
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _append_lookup_error(
|
|
148
|
+
errors: list[str],
|
|
149
|
+
context: str,
|
|
150
|
+
model_ref: str,
|
|
151
|
+
version_spec: VersionSpec | int,
|
|
152
|
+
mdl: MdlFile,
|
|
153
|
+
) -> None:
|
|
154
|
+
try:
|
|
155
|
+
resolve_model_ref(mdl, model_ref, version_spec)
|
|
156
|
+
except LookupError as exc:
|
|
157
|
+
errors.append(f"{context}: {exc}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _find_model_versions(
|
|
161
|
+
mdl: MdlFile,
|
|
162
|
+
domain_name: str,
|
|
163
|
+
model_name: str,
|
|
164
|
+
) -> list[ModelVersion]:
|
|
165
|
+
for domain in mdl.domains:
|
|
166
|
+
if domain.name == domain_name:
|
|
167
|
+
versions: list[ModelVersion] = []
|
|
168
|
+
versions.extend(domain.models.get(model_name, []))
|
|
169
|
+
versions.extend(domain.projections.get(model_name, []))
|
|
170
|
+
return versions
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _split_model_ref(model_ref: str) -> tuple[str, str]:
|
|
175
|
+
parts = model_ref.split(".", 1)
|
|
176
|
+
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
177
|
+
raise LookupError(f"invalid model reference {model_ref}")
|
|
178
|
+
return parts[0], parts[1]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _matches(
|
|
182
|
+
version: ModelVersion | ProjectionVersion,
|
|
183
|
+
version_spec: VersionSpec | int,
|
|
184
|
+
domain_name: str,
|
|
185
|
+
model_name: str,
|
|
186
|
+
) -> bool:
|
|
187
|
+
if isinstance(version_spec, int):
|
|
188
|
+
return version.version == version_spec
|
|
189
|
+
if isinstance(version_spec, VersionExact):
|
|
190
|
+
return version.version == version_spec.version
|
|
191
|
+
if isinstance(version_spec, VersionRange):
|
|
192
|
+
return version_spec.min_inclusive <= version.version < version_spec.max_exclusive
|
|
193
|
+
if isinstance(version_spec, VersionMin):
|
|
194
|
+
return version.version >= version_spec.min_inclusive
|
|
195
|
+
if isinstance(version_spec, VersionPinned):
|
|
196
|
+
if version.version != version_spec.version:
|
|
197
|
+
return False
|
|
198
|
+
return compute_version_signature(domain_name, model_name, version).lower() == version_spec.content_hash.lower()
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _format_version_spec(version_spec: VersionSpec | int) -> str:
|
|
203
|
+
if isinstance(version_spec, int):
|
|
204
|
+
return str(version_spec)
|
|
205
|
+
if isinstance(version_spec, VersionExact):
|
|
206
|
+
return str(version_spec.version)
|
|
207
|
+
if isinstance(version_spec, VersionRange):
|
|
208
|
+
return f">={version_spec.min_inclusive}<{version_spec.max_exclusive}"
|
|
209
|
+
if isinstance(version_spec, VersionMin):
|
|
210
|
+
return f">={version_spec.min_inclusive}"
|
|
211
|
+
if isinstance(version_spec, VersionPinned):
|
|
212
|
+
return f"{version_spec.version}#{version_spec.content_hash}"
|
|
213
|
+
return str(version_spec)
|