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,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
14
|
+
_SCENARIOS_DIR = _REPO_ROOT / "samples" / "scenarios"
|
|
15
|
+
_SAMPLES_README = _REPO_ROOT / "samples" / "README.md"
|
|
16
|
+
_ROW_PATTERN = re.compile(
|
|
17
|
+
r"^\|\s*\d+\s*\|\s*`(?P<id>[^`]+)`\s*\|\s*(?P<title>[^|]+?)\s*\|",
|
|
18
|
+
re.MULTILINE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ScenarioInfo:
|
|
24
|
+
scenario_id: str
|
|
25
|
+
title: str
|
|
26
|
+
path: Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register_scenario_commands(cli_group: click.Group) -> None:
|
|
30
|
+
cli_group.add_command(scenario)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.group()
|
|
34
|
+
def scenario() -> None:
|
|
35
|
+
"""Browse and load bundled sample scenarios."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@scenario.command(name="list")
|
|
39
|
+
def list_scenarios() -> None:
|
|
40
|
+
"""List bundled scenario IDs and titles."""
|
|
41
|
+
for index, info in enumerate(_load_scenarios(), start=1):
|
|
42
|
+
console.print(f"{index}. {info.scenario_id} - {info.title}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@scenario.command(name="show")
|
|
46
|
+
@click.argument("scenario_id")
|
|
47
|
+
def show_scenario(scenario_id: str) -> None:
|
|
48
|
+
"""Show the contents of a bundled sample scenario."""
|
|
49
|
+
info = _find_scenario(scenario_id)
|
|
50
|
+
if info is None:
|
|
51
|
+
raise click.ClickException(f"unknown scenario: {scenario_id}")
|
|
52
|
+
|
|
53
|
+
console.print(f"{info.scenario_id} - {info.title}")
|
|
54
|
+
for path in _scenario_files(info.path):
|
|
55
|
+
console.print(f"[bold]{path.name}[/bold]")
|
|
56
|
+
console.print(path.read_text(encoding="utf-8").rstrip())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@scenario.command(name="load")
|
|
60
|
+
@click.argument("scenario_id")
|
|
61
|
+
@click.option(
|
|
62
|
+
"--output-dir",
|
|
63
|
+
"output_dir",
|
|
64
|
+
type=click.Path(path_type=Path),
|
|
65
|
+
default=Path("."),
|
|
66
|
+
show_default=True,
|
|
67
|
+
help="Destination directory for the copied scenario.",
|
|
68
|
+
)
|
|
69
|
+
def load_scenario(scenario_id: str, output_dir: Path) -> None:
|
|
70
|
+
"""Copy a bundled sample scenario into a working directory."""
|
|
71
|
+
info = _find_scenario(scenario_id)
|
|
72
|
+
if info is None:
|
|
73
|
+
raise click.ClickException(f"unknown scenario: {scenario_id}")
|
|
74
|
+
|
|
75
|
+
target = output_dir / scenario_id
|
|
76
|
+
shutil.copytree(info.path, target)
|
|
77
|
+
console.print(f"[green]OK[/green] wrote {target}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_scenarios() -> list[ScenarioInfo]:
|
|
81
|
+
titles = _parse_readme_titles()
|
|
82
|
+
infos: list[ScenarioInfo] = []
|
|
83
|
+
for scenario_dir in sorted(
|
|
84
|
+
[path for path in _SCENARIOS_DIR.iterdir() if path.is_dir()],
|
|
85
|
+
key=lambda path: path.name,
|
|
86
|
+
):
|
|
87
|
+
title = titles.get(scenario_dir.name, scenario_dir.name)
|
|
88
|
+
infos.append(ScenarioInfo(scenario_dir.name, title, scenario_dir))
|
|
89
|
+
return infos
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_readme_titles() -> dict[str, str]:
|
|
93
|
+
if not _SAMPLES_README.exists():
|
|
94
|
+
return {}
|
|
95
|
+
text = _SAMPLES_README.read_text(encoding="utf-8")
|
|
96
|
+
return {match.group("id"): match.group("title").strip() for match in _ROW_PATTERN.finditer(text)}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _find_scenario(scenario_id: str) -> ScenarioInfo | None:
|
|
100
|
+
return next((info for info in _load_scenarios() if info.scenario_id == scenario_id), None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _scenario_files(path: Path) -> list[Path]:
|
|
104
|
+
return sorted(path.glob("*.mdl"), key=lambda item: item.name)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from modelable.specs.tracking import (
|
|
12
|
+
SpecEntry,
|
|
13
|
+
add_spec,
|
|
14
|
+
change_dicts,
|
|
15
|
+
evaluate_spec,
|
|
16
|
+
select_specs,
|
|
17
|
+
spec_config_path,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register_spec_commands(cli_group: click.Group) -> None:
|
|
24
|
+
cli_group.add_command(spec)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group()
|
|
28
|
+
def spec() -> None:
|
|
29
|
+
"""Track external specifications and detect drift."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@spec.command(name="add")
|
|
33
|
+
@click.argument("spec_id")
|
|
34
|
+
@click.option("--kind", required=True, help="Tracked spec kind, for example dbt, fhir, or odcs.")
|
|
35
|
+
@click.option("--source", required=True, help="Local external specification path.")
|
|
36
|
+
@click.option("--ref", "model_ref", required=True, help="Modelable model reference, for example domain.Model@1.")
|
|
37
|
+
@click.option("--source-name", default=None, help="Named source object inside multi-object specs.")
|
|
38
|
+
@click.option("--path", "workspace_path", type=click.Path(exists=True, path_type=Path), default=Path("."))
|
|
39
|
+
@click.option(
|
|
40
|
+
"--update-policy", default="preview", help="Default update policy. Currently informational; defaults to preview."
|
|
41
|
+
)
|
|
42
|
+
def add(
|
|
43
|
+
spec_id: str,
|
|
44
|
+
kind: str,
|
|
45
|
+
source: str,
|
|
46
|
+
model_ref: str,
|
|
47
|
+
source_name: str | None,
|
|
48
|
+
workspace_path: Path,
|
|
49
|
+
update_policy: str,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Add a tracked external specification."""
|
|
52
|
+
try:
|
|
53
|
+
path = add_spec(
|
|
54
|
+
workspace_path,
|
|
55
|
+
SpecEntry(
|
|
56
|
+
id=spec_id,
|
|
57
|
+
kind=kind.lower(),
|
|
58
|
+
source=source,
|
|
59
|
+
ref=model_ref,
|
|
60
|
+
source_name=source_name,
|
|
61
|
+
update_policy=update_policy,
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
raise click.ClickException(str(exc)) from exc
|
|
66
|
+
console.print(f"[green]OK[/green] added tracked spec {spec_id} to {path}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@spec.command(name="status")
|
|
70
|
+
@click.option("--path", "workspace_path", type=click.Path(exists=True, path_type=Path), default=Path("."))
|
|
71
|
+
@click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
|
|
72
|
+
@click.option("--fail-on", default="", help="Comma-separated statuses that should return a non-zero exit code.")
|
|
73
|
+
def status(workspace_path: Path, json_output: bool, fail_on: str) -> None:
|
|
74
|
+
"""Report drift status for tracked external specifications."""
|
|
75
|
+
token = os.getenv("MODELABLE_SPEC_TOKEN")
|
|
76
|
+
entries = select_specs(workspace_path, None)
|
|
77
|
+
evaluations = [evaluate_spec(workspace_path, entry, write=False, token=token) for entry in entries]
|
|
78
|
+
payload = {"specs": [evaluation.as_status_dict() for evaluation in evaluations]}
|
|
79
|
+
if json_output:
|
|
80
|
+
click.echo(json.dumps(payload, indent=2, sort_keys=True))
|
|
81
|
+
else:
|
|
82
|
+
if not evaluations:
|
|
83
|
+
console.print(f"[yellow]No tracked specs in {spec_config_path(workspace_path)}[/yellow]")
|
|
84
|
+
for evaluation in evaluations:
|
|
85
|
+
suffix = ""
|
|
86
|
+
if evaluation.change_kind:
|
|
87
|
+
suffix = f" ({evaluation.change_kind}, {evaluation.change_count} change(s))"
|
|
88
|
+
if evaluation.error:
|
|
89
|
+
suffix = f" ({evaluation.error})"
|
|
90
|
+
console.print(f"{evaluation.entry.id}: {evaluation.status}{suffix}")
|
|
91
|
+
fail_statuses = {item.strip() for item in fail_on.split(",") if item.strip()}
|
|
92
|
+
if fail_statuses and any(evaluation.status in fail_statuses for evaluation in evaluations):
|
|
93
|
+
raise click.ClickException("tracked spec status matched --fail-on")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@spec.command(name="diff")
|
|
97
|
+
@click.argument("spec_id")
|
|
98
|
+
@click.option("--path", "workspace_path", type=click.Path(exists=True, path_type=Path), default=Path("."))
|
|
99
|
+
@click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
|
|
100
|
+
def diff(spec_id: str, workspace_path: Path, json_output: bool) -> None:
|
|
101
|
+
"""Compare one tracked specification against its bound Modelable model."""
|
|
102
|
+
token = os.getenv("MODELABLE_SPEC_TOKEN")
|
|
103
|
+
try:
|
|
104
|
+
entry = select_specs(workspace_path, spec_id)[0]
|
|
105
|
+
except ValueError as exc:
|
|
106
|
+
raise click.ClickException(str(exc)) from exc
|
|
107
|
+
evaluation = evaluate_spec(workspace_path, entry, write=False, token=token)
|
|
108
|
+
payload = evaluation.as_status_dict()
|
|
109
|
+
if evaluation.result is not None:
|
|
110
|
+
payload["changes"] = change_dicts(evaluation.result)
|
|
111
|
+
if json_output:
|
|
112
|
+
click.echo(json.dumps(payload, indent=2, sort_keys=True))
|
|
113
|
+
return
|
|
114
|
+
if evaluation.error:
|
|
115
|
+
raise click.ClickException(evaluation.error)
|
|
116
|
+
if evaluation.status == "clean":
|
|
117
|
+
console.print(f"[green]OK[/green] {spec_id} is clean")
|
|
118
|
+
return
|
|
119
|
+
console.print(f"{spec_id}: {evaluation.change_kind} drift")
|
|
120
|
+
for change in payload.get("changes", []):
|
|
121
|
+
console.print(f"- {change['kind']}: {change['field_name']}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@spec.command(name="sync")
|
|
125
|
+
@click.argument("spec_id", required=False)
|
|
126
|
+
@click.option("--path", "workspace_path", type=click.Path(exists=True, path_type=Path), default=Path("."))
|
|
127
|
+
@click.option("--preview", is_flag=True, help="Show proposed changes without writing.")
|
|
128
|
+
@click.option("--write", "write_changes", is_flag=True, help="Write proposed .mdl changes.")
|
|
129
|
+
def sync(spec_id: str | None, workspace_path: Path, preview: bool, write_changes: bool) -> None:
|
|
130
|
+
"""Synchronize tracked specs into reviewed Modelable version updates."""
|
|
131
|
+
if preview and write_changes:
|
|
132
|
+
raise click.UsageError("choose either --preview or --write")
|
|
133
|
+
write = write_changes
|
|
134
|
+
try:
|
|
135
|
+
entries = select_specs(workspace_path, spec_id)
|
|
136
|
+
except ValueError as exc:
|
|
137
|
+
raise click.ClickException(str(exc)) from exc
|
|
138
|
+
if not preview and not write:
|
|
139
|
+
preview = True
|
|
140
|
+
|
|
141
|
+
token = os.getenv("MODELABLE_SPEC_TOKEN")
|
|
142
|
+
for entry in entries:
|
|
143
|
+
evaluation = evaluate_spec(workspace_path, entry, write=write, token=token)
|
|
144
|
+
if evaluation.error:
|
|
145
|
+
raise click.ClickException(f"{entry.id}: {evaluation.error}")
|
|
146
|
+
result = evaluation.result
|
|
147
|
+
if result is None:
|
|
148
|
+
continue
|
|
149
|
+
if not result.attached:
|
|
150
|
+
console.print(f"[green]OK[/green] {entry.id} already matches {entry.ref}; no new version created")
|
|
151
|
+
continue
|
|
152
|
+
if preview:
|
|
153
|
+
console.print(_render_preview(result.original_content, result.content))
|
|
154
|
+
continue
|
|
155
|
+
_write_spec_attachment_record(entry.id, result.path, evaluation)
|
|
156
|
+
console.print(result.content.rstrip())
|
|
157
|
+
console.print(f"[green]OK[/green] synced {entry.id}; new version {result.to_version} ({result.change_kind})")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _render_preview(original: str, updated: str) -> str:
|
|
161
|
+
import difflib
|
|
162
|
+
|
|
163
|
+
return "\n".join(
|
|
164
|
+
difflib.unified_diff(
|
|
165
|
+
original.splitlines(),
|
|
166
|
+
updated.splitlines(),
|
|
167
|
+
fromfile="current",
|
|
168
|
+
tofile="tracked-spec",
|
|
169
|
+
lineterm="",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _write_spec_attachment_record(spec_id: str, artifact_path: Path, evaluation) -> Path:
|
|
175
|
+
result = evaluation.result
|
|
176
|
+
if result is None:
|
|
177
|
+
raise ValueError("cannot write attachment record without an attach result")
|
|
178
|
+
sidecar_path = artifact_path.with_name(f"{artifact_path.name}.attachments.json")
|
|
179
|
+
records: list[dict[str, object]] = []
|
|
180
|
+
if sidecar_path.exists():
|
|
181
|
+
records = json.loads(sidecar_path.read_text(encoding="utf-8"))
|
|
182
|
+
records.append(
|
|
183
|
+
{
|
|
184
|
+
"spec_id": spec_id,
|
|
185
|
+
"ref": result.ref,
|
|
186
|
+
"source_format": result.source_format,
|
|
187
|
+
"source_name": result.source_name,
|
|
188
|
+
"source_path": result.source_descriptor,
|
|
189
|
+
"source_hash": result.source_hash,
|
|
190
|
+
"from_version": result.from_version,
|
|
191
|
+
"to_version": result.to_version,
|
|
192
|
+
"change_kind": result.change_kind,
|
|
193
|
+
"changes": [asdict(change) for change in result.changes],
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
sidecar_path.write_text(json.dumps(records, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
197
|
+
return sidecar_path
|
|
@@ -0,0 +1,240 @@
|
|
|
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, render_version_spec
|
|
9
|
+
from modelable.llm.context import parse_model_ref_version_spec
|
|
10
|
+
from modelable.llm.render import render_model_version, render_projection_version
|
|
11
|
+
from modelable.planner.lineage import build_projection_lineage
|
|
12
|
+
from modelable.registry.resolver import resolve_model_ref
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_workspace_commands(cli_group: click.Group) -> None:
|
|
16
|
+
cli_group.add_command(validate)
|
|
17
|
+
cli_group.add_command(resolve)
|
|
18
|
+
cli_group.add_command(lineage)
|
|
19
|
+
cli_group.add_command(inspect)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.command()
|
|
23
|
+
@click.argument("path", default=".", type=click.Path(exists=True, path_type=Path))
|
|
24
|
+
@click.option("--strict", is_flag=True, help="Exit non-zero on any validation error.")
|
|
25
|
+
def validate(path: Path, strict: bool) -> None:
|
|
26
|
+
"""Validate Modelable definition files at PATH."""
|
|
27
|
+
workspace = load_workspace_or_exit(path)
|
|
28
|
+
|
|
29
|
+
if len(workspace.sources) == 1:
|
|
30
|
+
console.print(f"[green]OK[/green] {workspace.sources[0].path} is valid.")
|
|
31
|
+
else:
|
|
32
|
+
console.print(f"[green]OK[/green] {len(workspace.sources)} files valid.")
|
|
33
|
+
|
|
34
|
+
sys.exit(0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.command()
|
|
38
|
+
@click.argument("ref")
|
|
39
|
+
@click.option("--path", "path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
40
|
+
def resolve(ref: str, path: Path) -> None:
|
|
41
|
+
"""Resolve and print a normalized model or projection definition."""
|
|
42
|
+
workspace = load_workspace_or_exit(path)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
domain, name, version_spec = parse_model_ref_version_spec(ref)
|
|
46
|
+
resolved = resolve_model_ref(workspace.mdl, domain + "." + name, version_spec)
|
|
47
|
+
except (ValueError, LookupError) as exc:
|
|
48
|
+
console.print(f"[red]ERROR[/red] {exc}")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
domain = next((d for d in workspace.mdl.domains if d.name == resolved.domain_name), None)
|
|
52
|
+
if domain is None:
|
|
53
|
+
console.print(f"[red]ERROR[/red] domain '{resolved.domain_name}' not found.")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
model_versions = domain.models.get(resolved.model_name, [])
|
|
57
|
+
model_version = next((version for version in model_versions if version.version == resolved.version.version), None)
|
|
58
|
+
if model_version is not None:
|
|
59
|
+
console.print(
|
|
60
|
+
render_model_version(domain.name, resolved.model_name, model_version, domain.owner, domain.description),
|
|
61
|
+
end="",
|
|
62
|
+
)
|
|
63
|
+
sys.exit(0)
|
|
64
|
+
|
|
65
|
+
projection_versions = domain.projections.get(resolved.model_name, [])
|
|
66
|
+
projection_version = next(
|
|
67
|
+
(version for version in projection_versions if version.version == resolved.version.version),
|
|
68
|
+
None,
|
|
69
|
+
)
|
|
70
|
+
if projection_version is not None:
|
|
71
|
+
console.print(
|
|
72
|
+
render_projection_version(
|
|
73
|
+
domain.name,
|
|
74
|
+
resolved.model_name,
|
|
75
|
+
projection_version,
|
|
76
|
+
domain.owner,
|
|
77
|
+
domain.description,
|
|
78
|
+
),
|
|
79
|
+
end="",
|
|
80
|
+
)
|
|
81
|
+
sys.exit(0)
|
|
82
|
+
|
|
83
|
+
console.print(f"[red]ERROR[/red] unresolved reference {ref}")
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@click.command()
|
|
88
|
+
@click.argument("ref")
|
|
89
|
+
@click.option("--path", "path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
90
|
+
def lineage(ref: str, path: Path) -> None:
|
|
91
|
+
"""Show field-level lineage for a model or projection."""
|
|
92
|
+
workspace = load_workspace_or_exit(path)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
domain, name, version_spec = parse_model_ref_version_spec(ref)
|
|
96
|
+
resolved = resolve_model_ref(workspace.mdl, domain + "." + name, version_spec)
|
|
97
|
+
except (ValueError, LookupError) as exc:
|
|
98
|
+
console.print(f"[red]ERROR[/red] {exc}")
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
domain = next((d for d in workspace.mdl.domains if d.name == resolved.domain_name), None)
|
|
102
|
+
if domain is None:
|
|
103
|
+
console.print(f"[red]ERROR[/red] domain '{resolved.domain_name}' not found.")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
model_versions = domain.models.get(resolved.model_name, [])
|
|
107
|
+
model_version = next(
|
|
108
|
+
(version for version in model_versions if version.version == resolved.version.version),
|
|
109
|
+
None,
|
|
110
|
+
)
|
|
111
|
+
if model_version is not None:
|
|
112
|
+
console.print(f"{domain.name}.{resolved.model_name}@{model_version.version}")
|
|
113
|
+
console.print(f"kind: {model_version.model_kind.value}")
|
|
114
|
+
for field in model_version.fields:
|
|
115
|
+
flags = []
|
|
116
|
+
if field.is_key:
|
|
117
|
+
flags.append("key")
|
|
118
|
+
if field.is_pii:
|
|
119
|
+
flags.append("pii")
|
|
120
|
+
if field.classification:
|
|
121
|
+
flags.append(f"classification={field.classification.value}")
|
|
122
|
+
suffix = f" [{', '.join(flags)}]" if flags else ""
|
|
123
|
+
console.print(f"- {field.name}: {field.type.kind}{suffix}", markup=False)
|
|
124
|
+
sys.exit(0)
|
|
125
|
+
|
|
126
|
+
projection_versions = domain.projections.get(resolved.model_name, [])
|
|
127
|
+
projection_version = next(
|
|
128
|
+
(version for version in projection_versions if version.version == resolved.version.version),
|
|
129
|
+
None,
|
|
130
|
+
)
|
|
131
|
+
if projection_version is not None:
|
|
132
|
+
lineage = build_projection_lineage(domain.name, resolved.model_name, projection_version, workspace.mdl)
|
|
133
|
+
console.print(f"{domain.name}.{resolved.model_name}@{projection_version.version}")
|
|
134
|
+
console.print(
|
|
135
|
+
f"source: {projection_version.source.model} @ {render_version_spec(projection_version.source.version)} as {projection_version.source.alias}"
|
|
136
|
+
)
|
|
137
|
+
if projection_version.joins:
|
|
138
|
+
for join in projection_version.joins:
|
|
139
|
+
console.print(f"join: {join.model} @ {render_version_spec(join.version)} as {join.alias} on {join.on}")
|
|
140
|
+
if projection_version.group_by:
|
|
141
|
+
console.print(f"group by: {', '.join(projection_version.group_by)}")
|
|
142
|
+
by_name = {item.field_name: item for item in lineage.fields}
|
|
143
|
+
for field in projection_version.fields:
|
|
144
|
+
field_lineage = by_name.get(field.name)
|
|
145
|
+
if field_lineage is None:
|
|
146
|
+
console.print(f"- {field.name}: unknown")
|
|
147
|
+
continue
|
|
148
|
+
console.print(f"- {field.name}: {field_lineage.kind}")
|
|
149
|
+
for source in field_lineage.lineage:
|
|
150
|
+
console.print(f" <- {source}")
|
|
151
|
+
if field_lineage.expression:
|
|
152
|
+
console.print(f" expr: {field_lineage.expression}")
|
|
153
|
+
sys.exit(0)
|
|
154
|
+
|
|
155
|
+
console.print(f"[red]ERROR[/red] unresolved reference {ref}")
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@click.command()
|
|
160
|
+
@click.argument("ref")
|
|
161
|
+
@click.option("--auto", is_flag=True, help="Display generated auto projections.")
|
|
162
|
+
@click.option("--path", "path", type=click.Path(exists=True, path_type=Path), default=".")
|
|
163
|
+
def inspect(ref: str, auto: bool, path: Path) -> None:
|
|
164
|
+
"""Inspect a model or projection at REF (domain.Model@version)."""
|
|
165
|
+
workspace = load_workspace_or_exit(path)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
domain_name, model_name, version_spec = _parse_entity_ref_version_spec(ref)
|
|
169
|
+
except click.BadParameter as exc:
|
|
170
|
+
console.print(f"[red]ERROR[/red] {exc}")
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
|
|
173
|
+
domain = next((d for d in workspace.mdl.domains if d.name == domain_name), None)
|
|
174
|
+
if domain is None:
|
|
175
|
+
console.print(f"[red]ERROR[/red] domain '{domain_name}' not found.")
|
|
176
|
+
sys.exit(1)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
resolved = resolve_model_ref(workspace.mdl, domain_name + "." + model_name, version_spec)
|
|
180
|
+
except LookupError as exc:
|
|
181
|
+
console.print(f"[red]ERROR[/red] {exc}")
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
|
|
184
|
+
version = resolved.version.version
|
|
185
|
+
|
|
186
|
+
if auto:
|
|
187
|
+
model_versions = domain.models.get(model_name)
|
|
188
|
+
if not model_versions:
|
|
189
|
+
console.print(f"[red]ERROR[/red] model '{model_name}' not found in domain '{domain_name}'.")
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
|
|
192
|
+
targets = ["db", "request", "reply", "event"]
|
|
193
|
+
for kind in targets:
|
|
194
|
+
projection_name = f"{model_name}{kind.capitalize()}"
|
|
195
|
+
if kind == "db":
|
|
196
|
+
projection_name = f"{model_name}Db"
|
|
197
|
+
versions = domain.projections.get(projection_name)
|
|
198
|
+
if not versions:
|
|
199
|
+
continue
|
|
200
|
+
pv = next((v for v in versions if v.version == version), None)
|
|
201
|
+
if pv is None:
|
|
202
|
+
continue
|
|
203
|
+
console.print(f"[bold]{domain_name}.{projection_name}@{pv.version}[/bold] (auto {kind})")
|
|
204
|
+
for field in pv.fields:
|
|
205
|
+
console.print(f" {field.name}")
|
|
206
|
+
sys.exit(0)
|
|
207
|
+
|
|
208
|
+
model_versions = domain.models.get(model_name, [])
|
|
209
|
+
model_version = next((mv for mv in model_versions if mv.version == version), None)
|
|
210
|
+
if model_version is not None:
|
|
211
|
+
console.print(
|
|
212
|
+
render_model_version(domain.name, model_name, model_version, domain.owner, domain.description), end=""
|
|
213
|
+
)
|
|
214
|
+
sys.exit(0)
|
|
215
|
+
|
|
216
|
+
projection_versions = domain.projections.get(model_name, [])
|
|
217
|
+
projection_version = next((pv for pv in projection_versions if pv.version == version), None)
|
|
218
|
+
if projection_version is not None:
|
|
219
|
+
console.print(
|
|
220
|
+
render_projection_version(domain.name, model_name, projection_version, domain.owner, domain.description),
|
|
221
|
+
end="",
|
|
222
|
+
)
|
|
223
|
+
sys.exit(0)
|
|
224
|
+
|
|
225
|
+
console.print(f"[red]ERROR[/red] '{model_name}@{version}' not found in domain '{domain.name}'.")
|
|
226
|
+
sys.exit(1)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _parse_entity_ref_version_spec(ref: str) -> tuple[str, str, int | object]:
|
|
230
|
+
if "@" not in ref:
|
|
231
|
+
raise click.BadParameter("REF must be in the form domain.Model@version")
|
|
232
|
+
model_ref, _version_str = ref.rsplit("@", 1)
|
|
233
|
+
parts = model_ref.split(".")
|
|
234
|
+
if len(parts) != 2:
|
|
235
|
+
raise click.BadParameter("REF must be in the form domain.Model@version")
|
|
236
|
+
try:
|
|
237
|
+
_, _, version_spec = parse_model_ref_version_spec(ref)
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
raise click.BadParameter(str(exc)) from exc
|
|
240
|
+
return parts[0], parts[1], version_spec
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .checker import CompatibilityReport, check_model_version_compatibility
|
|
4
|
+
from .diff import FieldChange, compare_model_versions
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CompatibilityReport",
|
|
8
|
+
"FieldChange",
|
|
9
|
+
"check_model_version_compatibility",
|
|
10
|
+
"compare_model_versions",
|
|
11
|
+
]
|