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,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from modelable.compat.diff import FieldChange, compare_model_versions
|
|
6
|
+
from modelable.parser.ir import ChangeKind, DirectMapping, MdlFile, ModelVersion
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class CompatibilityReport:
|
|
11
|
+
domain_name: str
|
|
12
|
+
model_name: str
|
|
13
|
+
from_version: int
|
|
14
|
+
to_version: int
|
|
15
|
+
status: str
|
|
16
|
+
findings: list[str] = field(default_factory=list)
|
|
17
|
+
changes: list[FieldChange] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ProjectionImpact:
|
|
22
|
+
domain_name: str
|
|
23
|
+
projection_name: str
|
|
24
|
+
version: int
|
|
25
|
+
status: str # "broken", "affected", "compatible"
|
|
26
|
+
reason: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_model_version_compatibility(
|
|
30
|
+
mdl: MdlFile,
|
|
31
|
+
domain_name: str,
|
|
32
|
+
model_name: str,
|
|
33
|
+
from_version: int,
|
|
34
|
+
to_version: int,
|
|
35
|
+
) -> CompatibilityReport:
|
|
36
|
+
"""Compare two published versions and classify the change set."""
|
|
37
|
+
old_version = _find_version(mdl, domain_name, model_name, from_version)
|
|
38
|
+
new_version = _find_version(mdl, domain_name, model_name, to_version)
|
|
39
|
+
changes = compare_model_versions(old_version, new_version)
|
|
40
|
+
findings = [_format_finding(change) for change in changes]
|
|
41
|
+
status = "breaking" if _has_breaking_change(changes, new_version) else "compatible"
|
|
42
|
+
return CompatibilityReport(
|
|
43
|
+
domain_name=domain_name,
|
|
44
|
+
model_name=model_name,
|
|
45
|
+
from_version=from_version,
|
|
46
|
+
to_version=to_version,
|
|
47
|
+
status=status,
|
|
48
|
+
findings=findings,
|
|
49
|
+
changes=changes,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def analyze_impact(
|
|
54
|
+
mdl: MdlFile,
|
|
55
|
+
report: CompatibilityReport,
|
|
56
|
+
dependent: tuple[str, str, int],
|
|
57
|
+
) -> ProjectionImpact:
|
|
58
|
+
"""Analyze the impact of a model change on a specific downstream projection."""
|
|
59
|
+
dep_domain_name, dep_proj_name, dep_version = dependent
|
|
60
|
+
|
|
61
|
+
# Find the projection version
|
|
62
|
+
pv = None
|
|
63
|
+
for domain in mdl.domains:
|
|
64
|
+
if domain.name == dep_domain_name:
|
|
65
|
+
pv = next(
|
|
66
|
+
(v for v in domain.projections.get(dep_proj_name, []) if v.version == dep_version),
|
|
67
|
+
None,
|
|
68
|
+
)
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
if pv is None:
|
|
72
|
+
return ProjectionImpact(dep_domain_name, dep_proj_name, dep_version, "affected", "unresolved projection")
|
|
73
|
+
|
|
74
|
+
# Determine the alias(es) used for this source model
|
|
75
|
+
source_ref = f"{report.domain_name}.{report.model_name}"
|
|
76
|
+
aliases = []
|
|
77
|
+
if pv.source.model == source_ref:
|
|
78
|
+
aliases.append(pv.source.alias)
|
|
79
|
+
for join in pv.joins:
|
|
80
|
+
if join.model == source_ref:
|
|
81
|
+
aliases.append(join.alias)
|
|
82
|
+
|
|
83
|
+
# Check if any breaking field change affects this projection's direct mappings
|
|
84
|
+
impacted_fields = []
|
|
85
|
+
for change in report.changes:
|
|
86
|
+
is_breaking_field = change.kind in {
|
|
87
|
+
"removed_field",
|
|
88
|
+
"renamed_field",
|
|
89
|
+
"type_changed",
|
|
90
|
+
"enum_changed",
|
|
91
|
+
"identity_changed",
|
|
92
|
+
}
|
|
93
|
+
if change.kind == "added_field" and change.to_optional is False:
|
|
94
|
+
is_breaking_field = True
|
|
95
|
+
|
|
96
|
+
if not is_breaking_field:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
for proj_field in pv.fields:
|
|
100
|
+
if (
|
|
101
|
+
isinstance(proj_field.mapping, DirectMapping)
|
|
102
|
+
and proj_field.mapping.source_alias in aliases
|
|
103
|
+
and proj_field.mapping.source_field == change.field_name
|
|
104
|
+
):
|
|
105
|
+
impacted_fields.append(f"field '{change.field_name}' ({change.kind})")
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
if impacted_fields:
|
|
109
|
+
return ProjectionImpact(
|
|
110
|
+
dep_domain_name,
|
|
111
|
+
dep_proj_name,
|
|
112
|
+
dep_version,
|
|
113
|
+
"broken",
|
|
114
|
+
f"uses {', '.join(impacted_fields)}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if report.status == "breaking":
|
|
118
|
+
return ProjectionImpact(
|
|
119
|
+
dep_domain_name,
|
|
120
|
+
dep_proj_name,
|
|
121
|
+
dep_version,
|
|
122
|
+
"affected",
|
|
123
|
+
f"source {source_ref} is marked breaking",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return ProjectionImpact(dep_domain_name, dep_proj_name, dep_version, "compatible")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _find_version(
|
|
130
|
+
mdl: MdlFile,
|
|
131
|
+
domain_name: str,
|
|
132
|
+
model_name: str,
|
|
133
|
+
version: int,
|
|
134
|
+
) -> ModelVersion:
|
|
135
|
+
for domain in mdl.domains:
|
|
136
|
+
if domain.name != domain_name:
|
|
137
|
+
continue
|
|
138
|
+
versions = domain.models.get(model_name, [])
|
|
139
|
+
for candidate in versions:
|
|
140
|
+
if candidate.version == version:
|
|
141
|
+
return candidate
|
|
142
|
+
break
|
|
143
|
+
raise LookupError(f"unknown model version {domain_name}.{model_name}@{version}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _format_finding(change: FieldChange) -> str:
|
|
147
|
+
if change.kind == "renamed_field":
|
|
148
|
+
return f"renamed_field {change.field_name} -> {change.replacement}"
|
|
149
|
+
if change.kind == "nullability_changed":
|
|
150
|
+
return (
|
|
151
|
+
f"nullability_changed {change.field_name}: "
|
|
152
|
+
f"{_bool_word(change.from_optional)} -> {_bool_word(change.to_optional)}"
|
|
153
|
+
)
|
|
154
|
+
if change.kind == "identity_changed":
|
|
155
|
+
return f"identity_changed {change.field_name}"
|
|
156
|
+
if change.kind == "enum_changed":
|
|
157
|
+
return f"enum_changed {change.field_name}"
|
|
158
|
+
if change.kind == "type_changed":
|
|
159
|
+
return f"type_changed {change.field_name}"
|
|
160
|
+
if change.kind == "removed_field":
|
|
161
|
+
return f"removed_field {change.field_name}"
|
|
162
|
+
if change.kind == "added_field":
|
|
163
|
+
return f"added_field {change.field_name}"
|
|
164
|
+
return f"{change.kind} {change.field_name}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _bool_word(value: bool | None) -> str:
|
|
168
|
+
if value is None:
|
|
169
|
+
return "unknown"
|
|
170
|
+
return "optional" if value else "required"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _has_breaking_change(changes: list[FieldChange], new_version: ModelVersion) -> bool:
|
|
174
|
+
for change in changes:
|
|
175
|
+
if change.kind in {"removed_field", "renamed_field", "type_changed", "enum_changed", "identity_changed"}:
|
|
176
|
+
return True
|
|
177
|
+
if change.kind == "added_field" and change.to_optional is False:
|
|
178
|
+
return True
|
|
179
|
+
return new_version.change_kind == ChangeKind.breaking
|
modelable/compat/diff.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from modelable.parser.ir import AnnDeprecated, EnumType, FieldDef, ModelVersion
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class FieldChange:
|
|
11
|
+
kind: str
|
|
12
|
+
field_name: str
|
|
13
|
+
previous_name: str | None = None
|
|
14
|
+
replacement: str | None = None
|
|
15
|
+
from_optional: bool | None = None
|
|
16
|
+
to_optional: bool | None = None
|
|
17
|
+
from_type: str | None = None
|
|
18
|
+
to_type: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compare_model_versions(old_version: ModelVersion, new_version: ModelVersion) -> list[FieldChange]:
|
|
22
|
+
"""Compare two published model versions field by field."""
|
|
23
|
+
changes: list[FieldChange] = []
|
|
24
|
+
old_fields = {field.name: field for field in old_version.fields}
|
|
25
|
+
new_fields = {field.name: field for field in new_version.fields}
|
|
26
|
+
matched_old: set[str] = set()
|
|
27
|
+
matched_new: set[str] = set()
|
|
28
|
+
|
|
29
|
+
for old_field in old_version.fields:
|
|
30
|
+
replacement = _deprecated_replacement(old_field)
|
|
31
|
+
if replacement is None:
|
|
32
|
+
continue
|
|
33
|
+
new_field = new_fields.get(replacement)
|
|
34
|
+
if new_field is None or replacement in matched_new:
|
|
35
|
+
continue
|
|
36
|
+
changes.append(
|
|
37
|
+
FieldChange(
|
|
38
|
+
kind="renamed_field",
|
|
39
|
+
field_name=old_field.name,
|
|
40
|
+
previous_name=old_field.name,
|
|
41
|
+
replacement=replacement,
|
|
42
|
+
from_optional=old_field.optional,
|
|
43
|
+
to_optional=new_field.optional,
|
|
44
|
+
from_type=_type_signature(old_field),
|
|
45
|
+
to_type=_type_signature(new_field),
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
if old_field.optional != new_field.optional:
|
|
49
|
+
changes.append(
|
|
50
|
+
FieldChange(
|
|
51
|
+
kind="nullability_changed",
|
|
52
|
+
field_name=replacement,
|
|
53
|
+
from_optional=old_field.optional,
|
|
54
|
+
to_optional=new_field.optional,
|
|
55
|
+
from_type=_type_signature(old_field),
|
|
56
|
+
to_type=_type_signature(new_field),
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
if _type_signature(old_field) != _type_signature(new_field):
|
|
60
|
+
if isinstance(old_field.type, EnumType) and isinstance(new_field.type, EnumType):
|
|
61
|
+
kind = "enum_changed"
|
|
62
|
+
else:
|
|
63
|
+
kind = "type_changed"
|
|
64
|
+
changes.append(
|
|
65
|
+
FieldChange(
|
|
66
|
+
kind=kind,
|
|
67
|
+
field_name=replacement,
|
|
68
|
+
from_type=_type_signature(old_field),
|
|
69
|
+
to_type=_type_signature(new_field),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
matched_old.add(old_field.name)
|
|
73
|
+
matched_new.add(replacement)
|
|
74
|
+
|
|
75
|
+
for old_field in old_version.fields:
|
|
76
|
+
if old_field.name in matched_old:
|
|
77
|
+
continue
|
|
78
|
+
if old_field.name not in new_fields:
|
|
79
|
+
changes.append(
|
|
80
|
+
FieldChange(
|
|
81
|
+
kind="removed_field",
|
|
82
|
+
field_name=old_field.name,
|
|
83
|
+
from_optional=old_field.optional,
|
|
84
|
+
from_type=_type_signature(old_field),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
for name in _sorted_common_field_names(old_fields, new_fields):
|
|
89
|
+
old_field = old_fields[name]
|
|
90
|
+
new_field = new_fields[name]
|
|
91
|
+
|
|
92
|
+
if old_field.optional != new_field.optional:
|
|
93
|
+
changes.append(
|
|
94
|
+
FieldChange(
|
|
95
|
+
kind="nullability_changed",
|
|
96
|
+
field_name=name,
|
|
97
|
+
from_optional=old_field.optional,
|
|
98
|
+
to_optional=new_field.optional,
|
|
99
|
+
from_type=_type_signature(old_field),
|
|
100
|
+
to_type=_type_signature(new_field),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if old_field.is_key != new_field.is_key:
|
|
105
|
+
changes.append(
|
|
106
|
+
FieldChange(
|
|
107
|
+
kind="identity_changed",
|
|
108
|
+
field_name=name,
|
|
109
|
+
from_type=_type_signature(old_field),
|
|
110
|
+
to_type=_type_signature(new_field),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
old_sig = _type_signature(old_field)
|
|
115
|
+
new_sig = _type_signature(new_field)
|
|
116
|
+
if old_sig == new_sig:
|
|
117
|
+
continue
|
|
118
|
+
if isinstance(old_field.type, EnumType) and isinstance(new_field.type, EnumType):
|
|
119
|
+
changes.append(
|
|
120
|
+
FieldChange(
|
|
121
|
+
kind="enum_changed",
|
|
122
|
+
field_name=name,
|
|
123
|
+
from_type=old_sig,
|
|
124
|
+
to_type=new_sig,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
changes.append(
|
|
129
|
+
FieldChange(
|
|
130
|
+
kind="type_changed",
|
|
131
|
+
field_name=name,
|
|
132
|
+
from_type=old_sig,
|
|
133
|
+
to_type=new_sig,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
for new_field in new_version.fields:
|
|
138
|
+
if new_field.name in matched_new:
|
|
139
|
+
continue
|
|
140
|
+
if new_field.name not in old_fields:
|
|
141
|
+
changes.append(
|
|
142
|
+
FieldChange(
|
|
143
|
+
kind="added_field",
|
|
144
|
+
field_name=new_field.name,
|
|
145
|
+
to_optional=new_field.optional,
|
|
146
|
+
to_type=_type_signature(new_field),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return changes
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _sorted_common_field_names(
|
|
154
|
+
old_fields: dict[str, FieldDef],
|
|
155
|
+
new_fields: dict[str, FieldDef],
|
|
156
|
+
) -> list[str]:
|
|
157
|
+
names = [field.name for field in old_fields.values() if field.name in new_fields]
|
|
158
|
+
return names
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _deprecated_replacement(field: FieldDef) -> str | None:
|
|
162
|
+
for annotation in field.annotations:
|
|
163
|
+
if isinstance(annotation, AnnDeprecated):
|
|
164
|
+
return annotation.replaced_by
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _type_signature(field: FieldDef) -> str:
|
|
169
|
+
return json.dumps(field.type.model_dump(mode="json"), sort_keys=True)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from modelable.parser.ir import MdlFile
|
|
6
|
+
from modelable.parser.parse import parse_file_to_ir, parse_text_to_ir
|
|
7
|
+
from modelable.validation.semantic import validate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compile_text(text: str) -> tuple[MdlFile, list[str]]:
|
|
11
|
+
"""Parse and validate .mdl text."""
|
|
12
|
+
mdl = parse_text_to_ir(text)
|
|
13
|
+
return mdl, validate(mdl)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compile_file(path: str | Path) -> tuple[MdlFile, list[str]]:
|
|
17
|
+
"""Parse and validate a .mdl file."""
|
|
18
|
+
mdl = parse_file_to_ir(path)
|
|
19
|
+
return mdl, validate(mdl)
|