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,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from modelable.commands.common import console, load_workspace_or_exit
|
|
10
|
+
from modelable.emitters.csharp import emit_csharp
|
|
11
|
+
from modelable.emitters.dbt_yaml import emit_dbt_yaml
|
|
12
|
+
from modelable.emitters.diagnostics import deferred_target
|
|
13
|
+
from modelable.emitters.fhir import emit_fhir_profile
|
|
14
|
+
from modelable.emitters.go import emit_go
|
|
15
|
+
from modelable.emitters.java import emit_java
|
|
16
|
+
from modelable.emitters.json_schema import emit_json_schema
|
|
17
|
+
from modelable.emitters.markdown import emit_markdown
|
|
18
|
+
from modelable.emitters.odcs import emit_odcs
|
|
19
|
+
from modelable.emitters.openlineage import emit_openlineage
|
|
20
|
+
from modelable.emitters.openmetadata import emit_openmetadata
|
|
21
|
+
from modelable.emitters.python import emit_python
|
|
22
|
+
from modelable.emitters.rust import emit_rust
|
|
23
|
+
from modelable.emitters.sql import emit_sql
|
|
24
|
+
from modelable.emitters.targets import list_implemented_codegen_targets
|
|
25
|
+
from modelable.emitters.typescript import emit_typescript
|
|
26
|
+
from modelable.planner.plans import write_plans
|
|
27
|
+
from modelable.registry.factory import get_registry
|
|
28
|
+
from modelable.registry.index import build_registry
|
|
29
|
+
|
|
30
|
+
_DEFAULT_OUT_DIRS: dict[str, Path] = {
|
|
31
|
+
target.name: target.default_out_dir
|
|
32
|
+
for target in list_implemented_codegen_targets()
|
|
33
|
+
if target.default_out_dir is not None
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_compile_commands(cli_group: click.Group) -> None:
|
|
38
|
+
cli_group.add_command(compile)
|
|
39
|
+
cli_group.add_command(docs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.command()
|
|
43
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
44
|
+
@click.option(
|
|
45
|
+
"--target",
|
|
46
|
+
required=True,
|
|
47
|
+
type=click.Choice([target.name for target in list_implemented_codegen_targets()]),
|
|
48
|
+
help="Artifact target to compile after registry indexing.",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--out",
|
|
52
|
+
"out_dir",
|
|
53
|
+
type=click.Path(path_type=Path),
|
|
54
|
+
default=None,
|
|
55
|
+
help="Directory for target artifacts.",
|
|
56
|
+
)
|
|
57
|
+
@click.option(
|
|
58
|
+
"--registry",
|
|
59
|
+
"registry_path",
|
|
60
|
+
type=click.Path(path_type=Path),
|
|
61
|
+
default=Path(".modelable/registry.db"),
|
|
62
|
+
help="Path to the registry index file.",
|
|
63
|
+
)
|
|
64
|
+
def compile(source: Path, target: str, out_dir: Path | None, registry_path: Path) -> None:
|
|
65
|
+
"""Compile Modelable definitions and write the local registry index."""
|
|
66
|
+
workspace = load_workspace_or_exit(source)
|
|
67
|
+
|
|
68
|
+
build_registry(workspace, registry_path.parent)
|
|
69
|
+
registry = get_registry(registry_path)
|
|
70
|
+
registry.push(registry_path)
|
|
71
|
+
console.print(f"[green]OK[/green] wrote {registry_path}")
|
|
72
|
+
|
|
73
|
+
plans_dir = Path(".modelable/plans")
|
|
74
|
+
plan_paths = write_plans(workspace, plans_dir)
|
|
75
|
+
for plan_path in plan_paths:
|
|
76
|
+
console.print(f"[green]OK[/green] wrote {plan_path}")
|
|
77
|
+
|
|
78
|
+
output = out_dir or _DEFAULT_OUT_DIRS[target]
|
|
79
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
if target == "json-schema":
|
|
82
|
+
artifacts = emit_json_schema(workspace, output)
|
|
83
|
+
for art in artifacts:
|
|
84
|
+
_write_artifact_text(art.path, json.dumps(art.content, indent=2, ensure_ascii=False) + "\n")
|
|
85
|
+
_print_artifact_result(art)
|
|
86
|
+
if not artifacts:
|
|
87
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
88
|
+
elif target == "markdown":
|
|
89
|
+
artifacts = emit_markdown(workspace, output)
|
|
90
|
+
for art in artifacts:
|
|
91
|
+
assert isinstance(art.content, str)
|
|
92
|
+
_write_artifact_text(art.path, art.content)
|
|
93
|
+
_print_artifact_result(art)
|
|
94
|
+
if not artifacts:
|
|
95
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
96
|
+
elif target == "typescript":
|
|
97
|
+
artifacts = emit_typescript(workspace, output)
|
|
98
|
+
for art in artifacts:
|
|
99
|
+
assert isinstance(art.content, str)
|
|
100
|
+
_write_artifact_text(art.path, art.content)
|
|
101
|
+
_print_artifact_result(art)
|
|
102
|
+
if not artifacts:
|
|
103
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
104
|
+
elif target == "csharp":
|
|
105
|
+
artifacts = emit_csharp(workspace, output)
|
|
106
|
+
for art in artifacts:
|
|
107
|
+
assert isinstance(art.content, str)
|
|
108
|
+
_write_artifact_text(art.path, art.content)
|
|
109
|
+
_print_artifact_result(art)
|
|
110
|
+
if not artifacts:
|
|
111
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
112
|
+
elif target == "java":
|
|
113
|
+
artifacts = emit_java(workspace, output)
|
|
114
|
+
for art in artifacts:
|
|
115
|
+
assert isinstance(art.content, str)
|
|
116
|
+
_write_artifact_text(art.path, art.content)
|
|
117
|
+
_print_artifact_result(art)
|
|
118
|
+
if not artifacts:
|
|
119
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
120
|
+
elif target == "python":
|
|
121
|
+
artifacts = emit_python(workspace, output)
|
|
122
|
+
for art in artifacts:
|
|
123
|
+
assert isinstance(art.content, str)
|
|
124
|
+
_write_artifact_text(art.path, art.content)
|
|
125
|
+
_print_artifact_result(art)
|
|
126
|
+
if not artifacts:
|
|
127
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
128
|
+
elif target == "rust":
|
|
129
|
+
artifacts = emit_rust(workspace, output)
|
|
130
|
+
for art in artifacts:
|
|
131
|
+
assert isinstance(art.content, str)
|
|
132
|
+
_write_artifact_text(art.path, art.content)
|
|
133
|
+
_print_artifact_result(art)
|
|
134
|
+
if not artifacts:
|
|
135
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
136
|
+
elif target == "go":
|
|
137
|
+
artifacts = emit_go(workspace, output)
|
|
138
|
+
for art in artifacts:
|
|
139
|
+
assert isinstance(art.content, str)
|
|
140
|
+
_write_artifact_text(art.path, art.content)
|
|
141
|
+
_print_artifact_result(art)
|
|
142
|
+
if not artifacts:
|
|
143
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
144
|
+
elif target == "dbt-yaml":
|
|
145
|
+
artifacts = emit_dbt_yaml(workspace, output)
|
|
146
|
+
for art in artifacts:
|
|
147
|
+
assert isinstance(art.content, str)
|
|
148
|
+
_write_artifact_text(art.path, art.content)
|
|
149
|
+
_print_artifact_result(art)
|
|
150
|
+
if not artifacts:
|
|
151
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
152
|
+
elif target == "fhir-profile":
|
|
153
|
+
artifacts = emit_fhir_profile(workspace, output)
|
|
154
|
+
for art in artifacts:
|
|
155
|
+
assert isinstance(art.content, str)
|
|
156
|
+
_write_artifact_text(art.path, art.content)
|
|
157
|
+
_print_artifact_result(art)
|
|
158
|
+
if not artifacts:
|
|
159
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
160
|
+
elif target == "openmetadata":
|
|
161
|
+
artifacts = emit_openmetadata(workspace, output)
|
|
162
|
+
for art in artifacts:
|
|
163
|
+
content = (
|
|
164
|
+
json.dumps(art.content, indent=2, ensure_ascii=False) + "\n"
|
|
165
|
+
if isinstance(art.content, dict)
|
|
166
|
+
else art.content
|
|
167
|
+
)
|
|
168
|
+
assert isinstance(content, str)
|
|
169
|
+
_write_artifact_text(art.path, content)
|
|
170
|
+
_print_artifact_result(art)
|
|
171
|
+
if not artifacts:
|
|
172
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
173
|
+
elif target == "openlineage":
|
|
174
|
+
artifacts = emit_openlineage(workspace, output)
|
|
175
|
+
for art in artifacts:
|
|
176
|
+
assert isinstance(art.content, dict)
|
|
177
|
+
_write_artifact_text(art.path, json.dumps(art.content, indent=2, ensure_ascii=False) + "\n")
|
|
178
|
+
_print_artifact_result(art)
|
|
179
|
+
if not artifacts:
|
|
180
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
181
|
+
elif target == "odcs":
|
|
182
|
+
artifacts = emit_odcs(workspace, output)
|
|
183
|
+
for art in artifacts:
|
|
184
|
+
assert isinstance(art.content, str)
|
|
185
|
+
_write_artifact_text(art.path, art.content)
|
|
186
|
+
_print_artifact_result(art)
|
|
187
|
+
if not artifacts:
|
|
188
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
189
|
+
elif target in ("sql-postgres", "sql-clickhouse"):
|
|
190
|
+
dialect = target.removeprefix("sql-")
|
|
191
|
+
artifacts = emit_sql(workspace, output, dialect)
|
|
192
|
+
for art in artifacts:
|
|
193
|
+
assert isinstance(art.content, str)
|
|
194
|
+
_write_artifact_text(art.path, art.content)
|
|
195
|
+
_print_artifact_result(art)
|
|
196
|
+
if not artifacts:
|
|
197
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
198
|
+
else:
|
|
199
|
+
console.print(f"[yellow]{deferred_target(target)}[/yellow]")
|
|
200
|
+
|
|
201
|
+
sys.exit(0)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@click.command()
|
|
205
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
206
|
+
@click.option(
|
|
207
|
+
"--out",
|
|
208
|
+
"out_dir",
|
|
209
|
+
type=click.Path(path_type=Path),
|
|
210
|
+
default=None,
|
|
211
|
+
help="Directory for generated documentation.",
|
|
212
|
+
)
|
|
213
|
+
def docs(source: Path, out_dir: Path | None) -> None:
|
|
214
|
+
"""Generate Markdown documentation from Modelable definitions at SOURCE."""
|
|
215
|
+
workspace = load_workspace_or_exit(source)
|
|
216
|
+
|
|
217
|
+
output = out_dir or Path("./dist/docs")
|
|
218
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
artifacts = emit_markdown(workspace, output)
|
|
220
|
+
for art in artifacts:
|
|
221
|
+
assert isinstance(art.content, str)
|
|
222
|
+
art.path.write_text(art.content, encoding="utf-8")
|
|
223
|
+
_print_artifact_result(art)
|
|
224
|
+
if not artifacts:
|
|
225
|
+
console.print("[yellow]No artifacts generated.[/yellow]")
|
|
226
|
+
sys.exit(0)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _print_artifact_result(art) -> None:
|
|
230
|
+
for warning in art.warnings:
|
|
231
|
+
console.print(f"[yellow]WARN[/yellow] {warning}")
|
|
232
|
+
console.print(f"[green]OK[/green] {art.path} [dim]{art.content_hash}[/dim]")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _write_artifact_text(path: Path, content: str) -> None:
|
|
236
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
path.write_text(content, encoding="utf-8")
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from modelable.commands.common import console
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_create_commands(cli_group: click.Group) -> None:
|
|
12
|
+
cli_group.add_command(create)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def create() -> None:
|
|
17
|
+
"""Create Modelable definition files interactively."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@create.command(name="domain")
|
|
21
|
+
@click.option("--output-dir", "-d", default=".", type=click.Path(path_type=Path), show_default=True)
|
|
22
|
+
def create_domain(output_dir: Path) -> None:
|
|
23
|
+
"""Create a domain definition file."""
|
|
24
|
+
name = click.prompt("Domain name")
|
|
25
|
+
out_file = output_dir / f"{name}.mdl"
|
|
26
|
+
if out_file.exists():
|
|
27
|
+
raise click.ClickException(f"{out_file} already exists")
|
|
28
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
out_file.write_text(_domain_text(name), encoding="utf-8")
|
|
30
|
+
console.print(f"[green]Created[/green] {out_file}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _domain_text(name: str) -> str:
|
|
34
|
+
return f'domain {name} {{\n owner: "required-team"\n}}\n'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_FIELD_TYPES = [
|
|
38
|
+
"string",
|
|
39
|
+
"int",
|
|
40
|
+
"float",
|
|
41
|
+
"bool",
|
|
42
|
+
"date",
|
|
43
|
+
"time",
|
|
44
|
+
"timestamp",
|
|
45
|
+
"uuid",
|
|
46
|
+
"duration",
|
|
47
|
+
"binary",
|
|
48
|
+
"decimal",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@create.command(name="model")
|
|
53
|
+
@click.option("--output-dir", "-d", default=".", type=click.Path(path_type=Path), show_default=True)
|
|
54
|
+
def create_model(output_dir: Path) -> None:
|
|
55
|
+
"""Create a model (entity/aggregate/event/value) definition file."""
|
|
56
|
+
domain = click.prompt("Domain name")
|
|
57
|
+
kind = click.prompt("Model kind", type=click.Choice(["entity", "aggregate", "event", "value"]))
|
|
58
|
+
name = click.prompt("Model name")
|
|
59
|
+
version = click.prompt("Version", default=1, type=int)
|
|
60
|
+
change_kind = click.prompt("Change kind", type=click.Choice(["additive", "breaking"]), default="additive")
|
|
61
|
+
|
|
62
|
+
fields: list[dict] = []
|
|
63
|
+
while True:
|
|
64
|
+
field_name = click.prompt("Field name (leave blank to finish)", default="", show_default=False)
|
|
65
|
+
if not field_name:
|
|
66
|
+
break
|
|
67
|
+
field_type = click.prompt("Field type", type=click.Choice(_FIELD_TYPES))
|
|
68
|
+
if field_type == "decimal":
|
|
69
|
+
precision = click.prompt("Precision", default=18, type=int)
|
|
70
|
+
scale = click.prompt("Scale", default=2, type=int)
|
|
71
|
+
field_type = f"decimal({precision}, {scale})"
|
|
72
|
+
optional = click.confirm("Optional field?", default=False)
|
|
73
|
+
is_key = click.confirm("Add @key annotation?", default=False)
|
|
74
|
+
is_pii = click.confirm("Add @pii annotation?", default=False)
|
|
75
|
+
fields.append(
|
|
76
|
+
{"name": field_name, "type": field_type, "optional": optional, "is_key": is_key, "is_pii": is_pii}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
out_file = output_dir / f"{domain}.mdl"
|
|
80
|
+
if out_file.exists():
|
|
81
|
+
raise click.ClickException(f"{out_file} already exists")
|
|
82
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
out_file.write_text(_model_text(domain, kind, name, version, change_kind, fields), encoding="utf-8")
|
|
84
|
+
console.print(f"[green]Created[/green] {out_file}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _model_text(
|
|
88
|
+
domain: str,
|
|
89
|
+
kind: str,
|
|
90
|
+
name: str,
|
|
91
|
+
version: int,
|
|
92
|
+
change_kind: str,
|
|
93
|
+
fields: list[dict],
|
|
94
|
+
) -> str:
|
|
95
|
+
lines = [f"domain {domain} {{", ' owner: "required-team"', f" {kind} {name} @ {version} ({change_kind}) {{"]
|
|
96
|
+
for field in fields:
|
|
97
|
+
annotations = ""
|
|
98
|
+
if field.get("is_key"):
|
|
99
|
+
annotations += "@key "
|
|
100
|
+
if field.get("is_pii"):
|
|
101
|
+
annotations += "@pii "
|
|
102
|
+
optional_marker = "?" if field.get("optional") else ""
|
|
103
|
+
lines.append(f" {annotations}{field['name']}{optional_marker}: {field['type']}")
|
|
104
|
+
lines += [" }", "}"]
|
|
105
|
+
return "\n".join(lines) + "\n"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@create.command(name="projection")
|
|
109
|
+
@click.option("--output-dir", "-d", default=".", type=click.Path(path_type=Path), show_default=True)
|
|
110
|
+
def create_projection(output_dir: Path) -> None:
|
|
111
|
+
"""Create a projection definition file."""
|
|
112
|
+
domain = click.prompt("Domain name")
|
|
113
|
+
name = click.prompt("Projection name")
|
|
114
|
+
version = click.prompt("Version", default=1, type=int)
|
|
115
|
+
source_model = click.prompt("Source model ref (e.g. customer.Customer)")
|
|
116
|
+
source_version = click.prompt("Source version", default=1, type=int)
|
|
117
|
+
alias = click.prompt("Source alias")
|
|
118
|
+
|
|
119
|
+
fields: list[dict] = []
|
|
120
|
+
while True:
|
|
121
|
+
field_name = click.prompt("Field name (leave blank to finish)", default="", show_default=False)
|
|
122
|
+
if not field_name:
|
|
123
|
+
break
|
|
124
|
+
mapping = click.prompt("Mapping (alias.field for direct, or CEL expression)")
|
|
125
|
+
fields.append({"name": field_name, "mapping": mapping})
|
|
126
|
+
|
|
127
|
+
out_file = output_dir / f"{domain}.mdl"
|
|
128
|
+
if out_file.exists():
|
|
129
|
+
raise click.ClickException(f"{out_file} already exists")
|
|
130
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
out_file.write_text(
|
|
132
|
+
_projection_text(domain, name, version, source_model, source_version, alias, fields),
|
|
133
|
+
encoding="utf-8",
|
|
134
|
+
)
|
|
135
|
+
console.print(f"[green]Created[/green] {out_file}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_DIRECT_MAPPING_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _projection_text(
|
|
142
|
+
domain: str,
|
|
143
|
+
name: str,
|
|
144
|
+
version: int,
|
|
145
|
+
source_model: str,
|
|
146
|
+
source_version: int,
|
|
147
|
+
alias: str,
|
|
148
|
+
fields: list[dict],
|
|
149
|
+
) -> str:
|
|
150
|
+
lines = [
|
|
151
|
+
f"domain {domain} {{",
|
|
152
|
+
' owner: "required-team"',
|
|
153
|
+
f" projection {name} @ {version}",
|
|
154
|
+
f" from {source_model} @ {source_version} as {alias}",
|
|
155
|
+
" {",
|
|
156
|
+
]
|
|
157
|
+
for field in fields:
|
|
158
|
+
mapping = field["mapping"]
|
|
159
|
+
if _DIRECT_MAPPING_RE.match(mapping):
|
|
160
|
+
lines.append(f" {field['name']} <- {mapping}")
|
|
161
|
+
else:
|
|
162
|
+
lines.append(f" {field['name']} = {mapping}")
|
|
163
|
+
lines += [" }", "}"]
|
|
164
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from modelable.commands.common import console
|
|
8
|
+
from modelable.compat.checker import analyze_impact, check_model_version_compatibility
|
|
9
|
+
from modelable.compiler.workspace import load_workspace
|
|
10
|
+
from modelable.llm.context import parse_model_ref_version_spec
|
|
11
|
+
from modelable.registry.resolver import find_dependents, resolve_model_ref
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register_diff_commands(cli_group: click.Group) -> None:
|
|
15
|
+
cli_group.add_command(diff)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_diff(from_ref: str, to_ref: str, path: Path) -> None:
|
|
19
|
+
"""Compare two published model versions and print the compatibility report."""
|
|
20
|
+
workspace = load_workspace(path)
|
|
21
|
+
try:
|
|
22
|
+
from_domain, from_name, from_version_spec = parse_model_ref_version_spec(from_ref)
|
|
23
|
+
to_domain, to_name, to_version_spec = parse_model_ref_version_spec(to_ref)
|
|
24
|
+
except ValueError as exc:
|
|
25
|
+
raise click.ClickException(str(exc)) from exc
|
|
26
|
+
if from_domain != to_domain or from_name != to_name:
|
|
27
|
+
raise click.ClickException("diff requires refs from the same domain and model")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from_model = resolve_model_ref(workspace.mdl, f"{from_domain}.{from_name}", from_version_spec)
|
|
31
|
+
to_model = resolve_model_ref(workspace.mdl, f"{to_domain}.{to_name}", to_version_spec)
|
|
32
|
+
report = check_model_version_compatibility(
|
|
33
|
+
workspace.mdl,
|
|
34
|
+
from_model.domain_name,
|
|
35
|
+
from_model.model_name,
|
|
36
|
+
from_model.version.version,
|
|
37
|
+
to_model.version.version,
|
|
38
|
+
)
|
|
39
|
+
except LookupError as exc:
|
|
40
|
+
raise click.ClickException(str(exc)) from exc
|
|
41
|
+
|
|
42
|
+
console.print(f"{from_ref} -> {to_ref}")
|
|
43
|
+
console.print(f"status: {report.status}")
|
|
44
|
+
if report.findings:
|
|
45
|
+
for finding in report.findings:
|
|
46
|
+
console.print(f"- {finding}")
|
|
47
|
+
else:
|
|
48
|
+
console.print("- no changes")
|
|
49
|
+
|
|
50
|
+
dependents = find_dependents(
|
|
51
|
+
workspace.mdl, from_model.domain_name, from_model.model_name, from_model.version.version
|
|
52
|
+
)
|
|
53
|
+
if dependents:
|
|
54
|
+
impacts = []
|
|
55
|
+
for dep in dependents:
|
|
56
|
+
impact = analyze_impact(workspace.mdl, report, dep)
|
|
57
|
+
if impact.status != "compatible":
|
|
58
|
+
impacts.append(impact)
|
|
59
|
+
|
|
60
|
+
if impacts:
|
|
61
|
+
console.print("\nImpacted Projections:")
|
|
62
|
+
for impact in impacts:
|
|
63
|
+
status_tag = f"[{impact.status.upper()}]"
|
|
64
|
+
color = "red" if impact.status == "broken" else "yellow"
|
|
65
|
+
line = (
|
|
66
|
+
f"- [{color}]{status_tag}[/{color}] {impact.domain_name}.{impact.projection_name}@{impact.version}"
|
|
67
|
+
)
|
|
68
|
+
if impact.reason:
|
|
69
|
+
line += f" ({impact.reason})"
|
|
70
|
+
console.print(line)
|
|
71
|
+
|
|
72
|
+
if report.status == "breaking":
|
|
73
|
+
raise click.exceptions.Exit(1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@click.command()
|
|
77
|
+
@click.argument("from_ref")
|
|
78
|
+
@click.argument("to_ref")
|
|
79
|
+
@click.option("--path", "path", type=click.Path(exists=True, path_type=Path), required=True)
|
|
80
|
+
def diff(from_ref: str, to_ref: str, path: Path) -> None:
|
|
81
|
+
"""Compare two published model versions."""
|
|
82
|
+
run_diff(from_ref, to_ref, path)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from modelable.commands.common import console, load_workspace_or_exit
|
|
9
|
+
from modelable.graph.export import build_graph_export, write_graph_export
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_graph_commands(cli_group: click.Group) -> None:
|
|
13
|
+
cli_group.add_command(graph)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
def graph() -> None:
|
|
18
|
+
"""Graph export commands for the normalized workspace graph."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@graph.command(name="export")
|
|
22
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
23
|
+
@click.option(
|
|
24
|
+
"--path",
|
|
25
|
+
"path",
|
|
26
|
+
type=click.Path(exists=True, path_type=Path),
|
|
27
|
+
default=None,
|
|
28
|
+
help="Workspace path to load instead of SOURCE.",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--focus",
|
|
32
|
+
"focus",
|
|
33
|
+
default=None,
|
|
34
|
+
help="Optional model or projection ref to center the graph.",
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--out",
|
|
38
|
+
"out_path",
|
|
39
|
+
type=click.Path(path_type=Path),
|
|
40
|
+
required=True,
|
|
41
|
+
help="Output file for the canonical graph JSON.",
|
|
42
|
+
)
|
|
43
|
+
def export_graph(source: Path, path: Path | None, focus: str | None, out_path: Path) -> None:
|
|
44
|
+
"""Export the normalized workspace graph as deterministic JSON."""
|
|
45
|
+
workspace = load_workspace_or_exit(path or source)
|
|
46
|
+
try:
|
|
47
|
+
graph_export = build_graph_export(workspace, focus=focus)
|
|
48
|
+
except (LookupError, ValueError) as exc:
|
|
49
|
+
console.print(f"[red]ERROR[/red] {exc}")
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
write_graph_export(graph_export, out_path)
|
|
53
|
+
console.print(f"[green]OK[/green] wrote {out_path}")
|