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