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.

Files changed (122) hide show
  1. modelable/__init__.py +1 -0
  2. modelable/__main__.py +3 -0
  3. modelable/_pydantic_py314_compat.py +31 -0
  4. modelable/cli.py +41 -0
  5. modelable/commands/__init__.py +1 -0
  6. modelable/commands/apicurio.py +84 -0
  7. modelable/commands/codegen.py +241 -0
  8. modelable/commands/common.py +43 -0
  9. modelable/commands/compile.py +237 -0
  10. modelable/commands/create.py +164 -0
  11. modelable/commands/diff.py +82 -0
  12. modelable/commands/graph.py +53 -0
  13. modelable/commands/llm.py +564 -0
  14. modelable/commands/lsp.py +15 -0
  15. modelable/commands/runtime.py +37 -0
  16. modelable/commands/scenario.py +104 -0
  17. modelable/commands/spec.py +197 -0
  18. modelable/commands/workspace.py +240 -0
  19. modelable/compat/__init__.py +11 -0
  20. modelable/compat/checker.py +179 -0
  21. modelable/compat/diff.py +169 -0
  22. modelable/compiler/__init__.py +3 -0
  23. modelable/compiler/compiler.py +19 -0
  24. modelable/compiler/workspace.py +346 -0
  25. modelable/diagnostics/__init__.py +3 -0
  26. modelable/diagnostics/model.py +27 -0
  27. modelable/emitters/__init__.py +0 -0
  28. modelable/emitters/base.py +22 -0
  29. modelable/emitters/csharp.py +245 -0
  30. modelable/emitters/dbt_yaml.py +290 -0
  31. modelable/emitters/diagnostics.py +25 -0
  32. modelable/emitters/fhir.py +694 -0
  33. modelable/emitters/fhir_validator.py +36 -0
  34. modelable/emitters/go.py +334 -0
  35. modelable/emitters/java.py +264 -0
  36. modelable/emitters/json_schema.py +458 -0
  37. modelable/emitters/markdown.py +252 -0
  38. modelable/emitters/odcs.py +355 -0
  39. modelable/emitters/openlineage.py +315 -0
  40. modelable/emitters/openmetadata.py +258 -0
  41. modelable/emitters/python.py +282 -0
  42. modelable/emitters/rust.py +643 -0
  43. modelable/emitters/shapes.py +261 -0
  44. modelable/emitters/sql.py +266 -0
  45. modelable/emitters/targets.py +141 -0
  46. modelable/emitters/typescript.py +352 -0
  47. modelable/expressions/__init__.py +0 -0
  48. modelable/expressions/cel.py +547 -0
  49. modelable/governance/__init__.py +3 -0
  50. modelable/governance/checker.py +271 -0
  51. modelable/governance/por.py +46 -0
  52. modelable/grammar/__init__.py +1 -0
  53. modelable/grammar/modelable.lark +257 -0
  54. modelable/graph/__init__.py +5 -0
  55. modelable/graph/export.py +442 -0
  56. modelable/llm/__init__.py +43 -0
  57. modelable/llm/chat.py +255 -0
  58. modelable/llm/config.py +87 -0
  59. modelable/llm/context.py +194 -0
  60. modelable/llm/engine.py +976 -0
  61. modelable/llm/importers.py +1077 -0
  62. modelable/llm/provenance.py +84 -0
  63. modelable/llm/providers.py +182 -0
  64. modelable/llm/qa.py +126 -0
  65. modelable/llm/recommendations.py +33 -0
  66. modelable/llm/redaction.py +19 -0
  67. modelable/llm/render.py +279 -0
  68. modelable/llm/update_plan.py +101 -0
  69. modelable/llm/validation_help.py +10 -0
  70. modelable/lsp/__init__.py +3 -0
  71. modelable/lsp/__main__.py +4 -0
  72. modelable/lsp/code_actions.py +210 -0
  73. modelable/lsp/completion.py +480 -0
  74. modelable/lsp/definition.py +343 -0
  75. modelable/lsp/diagnostics.py +31 -0
  76. modelable/lsp/document_symbols.py +197 -0
  77. modelable/lsp/federation.py +261 -0
  78. modelable/lsp/folding.py +33 -0
  79. modelable/lsp/formatting.py +64 -0
  80. modelable/lsp/highlight.py +30 -0
  81. modelable/lsp/hover.py +370 -0
  82. modelable/lsp/inlay_hints.py +158 -0
  83. modelable/lsp/references.py +511 -0
  84. modelable/lsp/rename.py +564 -0
  85. modelable/lsp/semantic_tokens.py +412 -0
  86. modelable/lsp/server.py +370 -0
  87. modelable/lsp/workspace.py +83 -0
  88. modelable/lsp/workspace_symbols.py +104 -0
  89. modelable/parser/__init__.py +94 -0
  90. modelable/parser/ir.py +451 -0
  91. modelable/parser/parse.py +47 -0
  92. modelable/parser/transformer.py +798 -0
  93. modelable/parser/wire.py +68 -0
  94. modelable/planner/__init__.py +0 -0
  95. modelable/planner/lineage.py +91 -0
  96. modelable/planner/planner.py +134 -0
  97. modelable/planner/plans.py +122 -0
  98. modelable/py.typed +0 -0
  99. modelable/registry/__init__.py +9 -0
  100. modelable/registry/apicurio.py +166 -0
  101. modelable/registry/base.py +18 -0
  102. modelable/registry/factory.py +18 -0
  103. modelable/registry/index.py +419 -0
  104. modelable/registry/local.py +26 -0
  105. modelable/registry/oci.py +22 -0
  106. modelable/registry/resolver.py +213 -0
  107. modelable/registry/schema.sql +119 -0
  108. modelable/registry/signature.py +26 -0
  109. modelable/release.py +125 -0
  110. modelable/runtime/__init__.py +5 -0
  111. modelable/runtime/adapter/__init__.py +17 -0
  112. modelable/runtime/adapter/base.py +18 -0
  113. modelable/runtime/adapter/postgres.py +82 -0
  114. modelable/specs/__init__.py +23 -0
  115. modelable/specs/tracking.py +220 -0
  116. modelable/validation/__init__.py +3 -0
  117. modelable/validation/semantic.py +659 -0
  118. modelable-1.0.0.dist-info/METADATA +61 -0
  119. modelable-1.0.0.dist-info/RECORD +122 -0
  120. modelable-1.0.0.dist-info/WHEEL +4 -0
  121. modelable-1.0.0.dist-info/entry_points.txt +2 -0
  122. 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)