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,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"
|