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,315 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from modelable.compiler.workspace import Workspace
7
+ from modelable.emitters.base import EmittedArtifact, compute_content_hash
8
+ from modelable.parser.ir import (
9
+ AnnOwner,
10
+ ArrayType,
11
+ ComputedMapping,
12
+ DecimalType,
13
+ DirectMapping,
14
+ DomainDef,
15
+ EnumType,
16
+ FieldDef,
17
+ FieldType,
18
+ MapType,
19
+ MdlFile,
20
+ ModelVersion,
21
+ NamedType,
22
+ ObjectType,
23
+ PrimitiveType,
24
+ ProjectionField,
25
+ ProjectionVersion,
26
+ RefType,
27
+ )
28
+ from modelable.registry.resolver import ResolvedModelRef, resolve_model_ref
29
+
30
+ PRODUCER = "https://github.com/ktjn/modelable"
31
+ SCHEMA_FACET_URL = "https://openlineage.io/spec/facets/1-1-1/SchemaDatasetFacet.json"
32
+ COLUMN_LINEAGE_FACET_URL = "https://openlineage.io/spec/facets/1-2-0/ColumnLineageDatasetFacet.json"
33
+ EVENT_TIME = "1970-01-01T00:00:00.000Z"
34
+
35
+
36
+ def emit_openlineage(workspace: Workspace, out_dir: Path) -> list[EmittedArtifact]:
37
+ """Emit design-time OpenLineage run events for Modelable models and projections."""
38
+ artifacts: list[EmittedArtifact] = []
39
+
40
+ for domain in workspace.mdl.domains:
41
+ for model_name, versions in domain.models.items():
42
+ for version in versions:
43
+ artifacts.append(_emit_model(domain, model_name, version, out_dir))
44
+
45
+ for projection_name, versions in domain.projections.items():
46
+ for version in versions:
47
+ artifacts.append(_emit_projection(domain, projection_name, version, workspace.mdl, out_dir))
48
+
49
+ return artifacts
50
+
51
+
52
+ def _emit_model(domain: DomainDef, model_name: str, version: ModelVersion, out_dir: Path) -> EmittedArtifact:
53
+ artifact_id = _artifact_id(domain.name, model_name, version.version)
54
+ event = _event(
55
+ domain=domain.name,
56
+ artifact_id=artifact_id,
57
+ outputs=[
58
+ _dataset(
59
+ domain.name,
60
+ artifact_id,
61
+ fields=[_model_schema_field(field) for field in version.fields],
62
+ )
63
+ ],
64
+ )
65
+ return _artifact(f"{domain.name}.{model_name}@{version.version}", artifact_id, out_dir, event)
66
+
67
+
68
+ def _emit_projection(
69
+ domain: DomainDef,
70
+ projection_name: str,
71
+ version: ProjectionVersion,
72
+ mdl: MdlFile,
73
+ out_dir: Path,
74
+ ) -> EmittedArtifact:
75
+ artifact_id = _artifact_id(domain.name, projection_name, version.version)
76
+ source = _resolve_source(mdl, version)
77
+ source_dataset = _source_dataset(version, source)
78
+ output_dataset = _dataset(
79
+ domain.name,
80
+ artifact_id,
81
+ fields=[_projection_schema_field(field, version, source) for field in version.fields],
82
+ )
83
+ output_dataset["facets"]["columnLineage"] = _column_lineage(version, source_dataset, source)
84
+
85
+ event = _event(
86
+ domain=domain.name,
87
+ artifact_id=artifact_id,
88
+ inputs=[source_dataset] if source_dataset is not None else [],
89
+ outputs=[output_dataset],
90
+ )
91
+ return _artifact(f"{domain.name}.{projection_name}@{version.version}", artifact_id, out_dir, event)
92
+
93
+
94
+ def _event(
95
+ *,
96
+ domain: str,
97
+ artifact_id: str,
98
+ outputs: list[dict[str, object]],
99
+ inputs: list[dict[str, object]] | None = None,
100
+ ) -> dict[str, object]:
101
+ return {
102
+ "eventType": "COMPLETE",
103
+ "eventTime": EVENT_TIME,
104
+ "run": {
105
+ "runId": f"modelable-{artifact_id.replace('.', '-')}",
106
+ "facets": {},
107
+ },
108
+ "job": {
109
+ "namespace": f"modelable://{domain}",
110
+ "name": f"compile/{artifact_id}",
111
+ },
112
+ "inputs": inputs or [],
113
+ "outputs": outputs,
114
+ "producer": PRODUCER,
115
+ }
116
+
117
+
118
+ def _dataset(domain: str, name: str, *, fields: list[dict[str, str]]) -> dict[str, object]:
119
+ return {
120
+ "namespace": f"modelable://{domain}",
121
+ "name": name,
122
+ "facets": {
123
+ "schema": {
124
+ "_producer": PRODUCER,
125
+ "_schemaURL": SCHEMA_FACET_URL,
126
+ "fields": fields,
127
+ }
128
+ },
129
+ }
130
+
131
+
132
+ def _source_dataset(projection: ProjectionVersion, source: ResolvedModelRef | None) -> dict[str, object] | None:
133
+ if source is None:
134
+ return None
135
+ return _dataset(
136
+ source.domain_name,
137
+ _artifact_id(source.domain_name, source.model_name, source.version.version),
138
+ fields=[_model_schema_field(field) for field in source.version.fields],
139
+ )
140
+
141
+
142
+ def _model_schema_field(field: FieldDef) -> dict[str, str]:
143
+ return _schema_field(
144
+ name=field.name,
145
+ field_type=field.type,
146
+ pii=field.is_pii,
147
+ classification=field.classification.value if field.classification else None,
148
+ owner=_owner(field),
149
+ )
150
+
151
+
152
+ def _projection_schema_field(
153
+ field: ProjectionField,
154
+ projection: ProjectionVersion,
155
+ source: ResolvedModelRef | None,
156
+ ) -> dict[str, str]:
157
+ source_field = _source_field(field, source)
158
+ field_type = source_field.type if source_field is not None else PrimitiveType(kind="string")
159
+ pii = field.is_pii or (source_field.is_pii if source_field is not None else False)
160
+ classification = field.classification or (source_field.classification if source_field is not None else None)
161
+ owner = _owner(source_field) if source_field is not None else None
162
+ return _schema_field(
163
+ name=field.name,
164
+ field_type=field_type,
165
+ pii=pii,
166
+ classification=classification.value if classification is not None else None,
167
+ owner=owner,
168
+ )
169
+
170
+
171
+ def _schema_field(
172
+ *,
173
+ name: str,
174
+ field_type: FieldType,
175
+ pii: bool,
176
+ classification: str | None,
177
+ owner: str | None,
178
+ ) -> dict[str, str]:
179
+ data = {"name": name, "type": _type_name(field_type)}
180
+ description_parts = []
181
+ if classification is not None:
182
+ description_parts.append(f"classification={classification}")
183
+ if pii:
184
+ description_parts.append("pii=true")
185
+ if owner is not None:
186
+ description_parts.append(f"owner={owner}")
187
+ if description_parts:
188
+ data["description"] = "; ".join(description_parts)
189
+ return data
190
+
191
+
192
+ def _column_lineage(
193
+ projection: ProjectionVersion,
194
+ source_dataset: dict[str, object] | None,
195
+ source: ResolvedModelRef | None,
196
+ ) -> dict[str, object]:
197
+ fields: dict[str, object] = {}
198
+ if source_dataset is None:
199
+ return _lineage_facet(fields)
200
+
201
+ for field in projection.fields:
202
+ lineage = _field_lineage(field, projection, source_dataset, source)
203
+ if lineage is not None:
204
+ fields[field.name] = lineage
205
+
206
+ return _lineage_facet(fields)
207
+
208
+
209
+ def _lineage_facet(fields: dict[str, object]) -> dict[str, object]:
210
+ return {
211
+ "_producer": PRODUCER,
212
+ "_schemaURL": COLUMN_LINEAGE_FACET_URL,
213
+ "fields": fields,
214
+ }
215
+
216
+
217
+ def _field_lineage(
218
+ field: ProjectionField,
219
+ projection: ProjectionVersion,
220
+ source_dataset: dict[str, object],
221
+ source: ResolvedModelRef | None,
222
+ ) -> dict[str, object] | None:
223
+ if isinstance(field.mapping, DirectMapping):
224
+ return {
225
+ "inputFields": [
226
+ {
227
+ "namespace": source_dataset["namespace"],
228
+ "name": source_dataset["name"],
229
+ "field": field.mapping.source_field,
230
+ }
231
+ ]
232
+ }
233
+ if isinstance(field.mapping, ComputedMapping):
234
+ input_fields = [
235
+ {"namespace": source_dataset["namespace"], "name": source_dataset["name"], "field": source_field}
236
+ for source_field in _expression_source_fields(field.mapping.expression, projection, source)
237
+ ]
238
+ return {
239
+ "inputFields": input_fields,
240
+ "transformationDescription": field.mapping.expression,
241
+ "transformationType": "TRANSFORMATION",
242
+ }
243
+ return None
244
+
245
+
246
+ def _expression_source_fields(
247
+ expression: str,
248
+ projection: ProjectionVersion,
249
+ source: ResolvedModelRef | None,
250
+ ) -> list[str]:
251
+ if source is None:
252
+ return []
253
+ candidates = set(re.findall(rf"\b{re.escape(projection.source.alias)}\.([A-Za-z_][A-Za-z0-9_]*)\b", expression))
254
+ known_fields = {field.name for field in source.version.fields}
255
+ return sorted(candidates & known_fields)
256
+
257
+
258
+ def _resolve_source(mdl: MdlFile, projection: ProjectionVersion) -> ResolvedModelRef | None:
259
+ try:
260
+ return resolve_model_ref(mdl, projection.source.model, projection.source.version)
261
+ except LookupError:
262
+ return None
263
+
264
+
265
+ def _source_field(field: ProjectionField, source: ResolvedModelRef | None) -> FieldDef | None:
266
+ if source is None or not isinstance(field.mapping, DirectMapping):
267
+ return None
268
+ return next(
269
+ (source_field for source_field in source.version.fields if source_field.name == field.mapping.source_field),
270
+ None,
271
+ )
272
+
273
+
274
+ def _owner(field: FieldDef | None) -> str | None:
275
+ if field is None:
276
+ return None
277
+ for annotation in field.annotations:
278
+ if isinstance(annotation, AnnOwner):
279
+ return annotation.team
280
+ return None
281
+
282
+
283
+ def _artifact(ref: str, artifact_id: str, out_dir: Path, event: dict[str, object]) -> EmittedArtifact:
284
+ return EmittedArtifact(
285
+ target="openlineage",
286
+ ref=ref,
287
+ artifact_id=artifact_id,
288
+ path=out_dir / f"{artifact_id}.openlineage.json",
289
+ content=event,
290
+ content_hash=compute_content_hash(event),
291
+ )
292
+
293
+
294
+ def _artifact_id(domain: str, name: str, version: int) -> str:
295
+ return f"{domain}.{name}.v{version}"
296
+
297
+
298
+ def _type_name(field_type: FieldType) -> str:
299
+ if isinstance(field_type, PrimitiveType):
300
+ return field_type.kind
301
+ if isinstance(field_type, DecimalType):
302
+ return f"decimal({field_type.precision},{field_type.scale})"
303
+ if isinstance(field_type, ArrayType):
304
+ return f"array<{_type_name(field_type.item)}>"
305
+ if isinstance(field_type, MapType):
306
+ return f"map<{_type_name(field_type.key)},{_type_name(field_type.value)}>"
307
+ if isinstance(field_type, RefType):
308
+ return f"ref<{field_type.target}>"
309
+ if isinstance(field_type, EnumType):
310
+ return "enum(" + ",".join(field_type.values) + ")"
311
+ if isinstance(field_type, NamedType):
312
+ return field_type.name
313
+ if isinstance(field_type, ObjectType):
314
+ return "object"
315
+ return "unknown"
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from modelable.compiler.workspace import Workspace
6
+ from modelable.emitters.base import EmittedArtifact, compute_content_hash
7
+ from modelable.parser.ir import (
8
+ AnnOwner,
9
+ ArrayType,
10
+ ComputedMapping,
11
+ DecimalType,
12
+ DirectMapping,
13
+ DomainDef,
14
+ EnumType,
15
+ FieldDef,
16
+ FieldType,
17
+ MapType,
18
+ ModelVersion,
19
+ NamedType,
20
+ ObjectType,
21
+ PrimitiveType,
22
+ ProjectionField,
23
+ ProjectionVersion,
24
+ RefType,
25
+ VersionExact,
26
+ VersionMin,
27
+ VersionPinned,
28
+ VersionRange,
29
+ )
30
+ from modelable.registry.resolver import ResolvedModelRef, resolve_model_ref
31
+
32
+
33
+ def emit_openmetadata(workspace: Workspace, out_dir: Path) -> list[EmittedArtifact]:
34
+ """Emit OpenMetadata-oriented catalog assets with ownership, governance, and lineage."""
35
+ artifacts: list[EmittedArtifact] = []
36
+
37
+ for domain in workspace.mdl.domains:
38
+ artifact_id = f"{domain.name}.openmetadata"
39
+ om_data = {
40
+ "name": domain.name,
41
+ "description": domain.description,
42
+ "owner": domain.owner,
43
+ "assets": [],
44
+ "lineage": [],
45
+ }
46
+
47
+ for model_name, versions in domain.models.items():
48
+ for version in versions:
49
+ om_data["assets"].append(_model_asset(domain, model_name, version))
50
+
51
+ for projection_name, versions in domain.projections.items():
52
+ for version in versions:
53
+ source = _resolve_source(workspace, version)
54
+ om_data["assets"].append(_projection_asset(domain, projection_name, version, source))
55
+ om_data["lineage"].extend(_projection_lineage(domain, projection_name, version, source))
56
+
57
+ path = out_dir / f"{artifact_id}.json"
58
+ content_hash = compute_content_hash(om_data)
59
+
60
+ artifacts.append(
61
+ EmittedArtifact(
62
+ target="openmetadata",
63
+ ref=f"{domain.name}",
64
+ artifact_id=artifact_id,
65
+ path=path,
66
+ content=om_data,
67
+ content_hash=content_hash,
68
+ warnings=[],
69
+ )
70
+ )
71
+
72
+ return artifacts
73
+
74
+
75
+ def _model_asset(domain: DomainDef, model_name: str, version: ModelVersion) -> dict[str, object]:
76
+ return {
77
+ "name": model_name,
78
+ "kind": version.model_kind.value,
79
+ "version": version.version,
80
+ "changeKind": version.change_kind.value,
81
+ "fullyQualifiedName": _asset_fqn(domain.name, model_name, version.version),
82
+ "fields": [_model_field(field) for field in version.fields],
83
+ }
84
+
85
+
86
+ def _projection_asset(
87
+ domain: DomainDef,
88
+ projection_name: str,
89
+ version: ProjectionVersion,
90
+ source: ResolvedModelRef | None,
91
+ ) -> dict[str, object]:
92
+ asset: dict[str, object] = {
93
+ "name": projection_name,
94
+ "kind": "projection",
95
+ "version": version.version,
96
+ "fullyQualifiedName": _asset_fqn(domain.name, projection_name, version.version),
97
+ "source": {
98
+ "model": version.source.model,
99
+ "version": _version_spec(version.source.version),
100
+ "alias": version.source.alias,
101
+ },
102
+ "fields": [_projection_field(field, version, source) for field in version.fields],
103
+ }
104
+ if version.joins:
105
+ asset["joins"] = [
106
+ {
107
+ "model": join.model,
108
+ "version": _version_spec(join.version),
109
+ "alias": join.alias,
110
+ "on": join.on,
111
+ "kind": join.join_kind,
112
+ "cardinality": join.cardinality,
113
+ }
114
+ for join in version.joins
115
+ ]
116
+ if version.where is not None:
117
+ asset["where"] = version.where
118
+ if version.group_by:
119
+ asset["groupBy"] = version.group_by
120
+ return asset
121
+
122
+
123
+ def _model_field(field: FieldDef) -> dict[str, object]:
124
+ return {
125
+ "name": field.name,
126
+ "type": _type_name(field.type),
127
+ "required": not field.optional,
128
+ "key": field.is_key,
129
+ "pii": field.is_pii,
130
+ "classification": field.classification.value if field.classification is not None else None,
131
+ "owner": _owner(field),
132
+ }
133
+
134
+
135
+ def _projection_field(
136
+ field: ProjectionField,
137
+ projection: ProjectionVersion,
138
+ source: ResolvedModelRef | None,
139
+ ) -> dict[str, object]:
140
+ source_field = _source_field(field, source)
141
+ pii = field.is_pii or (source_field.is_pii if source_field is not None else False)
142
+ classification = field.classification or (source_field.classification if source_field is not None else None)
143
+
144
+ if isinstance(field.mapping, DirectMapping):
145
+ data: dict[str, object] = {
146
+ "name": field.name,
147
+ "mapping": "direct",
148
+ "source": _source_field_ref(projection, field.mapping),
149
+ "pii": pii,
150
+ "classification": classification.value if classification is not None else None,
151
+ }
152
+ elif isinstance(field.mapping, ComputedMapping):
153
+ data = {
154
+ "name": field.name,
155
+ "mapping": "computed",
156
+ "expression": field.mapping.expression,
157
+ "pii": pii,
158
+ "classification": classification.value if classification is not None else None,
159
+ }
160
+ else:
161
+ data = {"name": field.name, "mapping": "unknown", "pii": pii, "classification": None}
162
+ return data
163
+
164
+
165
+ def _projection_lineage(
166
+ domain: DomainDef,
167
+ projection_name: str,
168
+ version: ProjectionVersion,
169
+ source: ResolvedModelRef | None,
170
+ ) -> list[dict[str, str]]:
171
+ if source is None:
172
+ return []
173
+ lineage: list[dict[str, str]] = []
174
+ for field in version.fields:
175
+ if not isinstance(field.mapping, DirectMapping):
176
+ continue
177
+ source_model = source.model_name
178
+ source_domain = source.domain_name
179
+ lineage.append(
180
+ {
181
+ "from": f"{_asset_fqn(source_domain, source_model, source.version.version)}.{field.mapping.source_field}",
182
+ "to": f"{_asset_fqn(domain.name, projection_name, version.version)}.{field.name}",
183
+ "kind": "direct",
184
+ }
185
+ )
186
+ return lineage
187
+
188
+
189
+ def _resolve_source(workspace: Workspace, projection: ProjectionVersion) -> ResolvedModelRef | None:
190
+ try:
191
+ return resolve_model_ref(workspace.mdl, projection.source.model, projection.source.version)
192
+ except LookupError:
193
+ return None
194
+
195
+
196
+ def _source_field(field: ProjectionField, source: ResolvedModelRef | None) -> FieldDef | None:
197
+ if source is None or not isinstance(field.mapping, DirectMapping):
198
+ return None
199
+ for source_field in source.version.fields:
200
+ if source_field.name == field.mapping.source_field:
201
+ return source_field
202
+ return None
203
+
204
+
205
+ def _source_field_ref(projection: ProjectionVersion, mapping: DirectMapping) -> str:
206
+ version = projection.source.version
207
+ if isinstance(version, VersionExact):
208
+ return f"{projection.source.model}@{version.version}.{mapping.source_field}"
209
+ if isinstance(version, VersionPinned):
210
+ return f"{projection.source.model}@{version.version}#{version.content_hash}.{mapping.source_field}"
211
+ return f"{projection.source.model}@{_version_spec(version)}.{mapping.source_field}"
212
+
213
+
214
+ def _asset_fqn(domain: str, name: str, version: int) -> str:
215
+ return f"modelable.{domain}.{name}.v{version}"
216
+
217
+
218
+ def _version_spec(version) -> dict[str, object]:
219
+ if isinstance(version, VersionExact):
220
+ return {"kind": "exact", "version": version.version}
221
+ if isinstance(version, VersionRange):
222
+ return {
223
+ "kind": "range",
224
+ "minInclusive": version.min_inclusive,
225
+ "maxExclusive": version.max_exclusive,
226
+ }
227
+ if isinstance(version, VersionMin):
228
+ return {"kind": "min", "minInclusive": version.min_inclusive}
229
+ if isinstance(version, VersionPinned):
230
+ return {"kind": "pinned", "version": version.version, "contentHash": version.content_hash}
231
+ return {"kind": "unknown"}
232
+
233
+
234
+ def _owner(field: FieldDef) -> str | None:
235
+ for annotation in field.annotations:
236
+ if isinstance(annotation, AnnOwner):
237
+ return annotation.team
238
+ return None
239
+
240
+
241
+ def _type_name(field_type: FieldType) -> str:
242
+ if isinstance(field_type, PrimitiveType):
243
+ return field_type.kind
244
+ if isinstance(field_type, DecimalType):
245
+ return f"decimal({field_type.precision},{field_type.scale})"
246
+ if isinstance(field_type, ArrayType):
247
+ return f"array<{_type_name(field_type.item)}>"
248
+ if isinstance(field_type, MapType):
249
+ return f"map<{_type_name(field_type.key)},{_type_name(field_type.value)}>"
250
+ if isinstance(field_type, RefType):
251
+ return f"ref<{field_type.target}>"
252
+ if isinstance(field_type, EnumType):
253
+ return "enum(" + ",".join(field_type.values) + ")"
254
+ if isinstance(field_type, NamedType):
255
+ return field_type.name
256
+ if isinstance(field_type, ObjectType):
257
+ return "object"
258
+ return "unknown"