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
modelable/llm/chat.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from modelable.compiler.workspace import Workspace
|
|
11
|
+
from modelable.llm.context import (
|
|
12
|
+
build_model_summary,
|
|
13
|
+
build_projection_summary,
|
|
14
|
+
build_workspace_summary,
|
|
15
|
+
parse_model_ref,
|
|
16
|
+
)
|
|
17
|
+
from modelable.llm.engine import recommend_cli, update_definition
|
|
18
|
+
from modelable.llm.providers import LLMProvider, LLMRequest
|
|
19
|
+
from modelable.llm.qa import answer_question
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ChatState:
|
|
24
|
+
ref: str | None = None
|
|
25
|
+
workspace_summary: str | None = None
|
|
26
|
+
history: list[tuple[str, str]] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
CHAT_SYSTEM_PROMPT = """You are Modelable's interactive assistant.
|
|
30
|
+
Answer using the current workspace context only.
|
|
31
|
+
If the user asks for a model edit, explain that edit requests are previewed through the update pipeline and do not claim that files were written.
|
|
32
|
+
If the user asks for a summary, be concise and factual.
|
|
33
|
+
If the user asks a question you cannot answer from the context, say what is missing.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def chat_reply(
|
|
38
|
+
workspace: Workspace,
|
|
39
|
+
message: str,
|
|
40
|
+
*,
|
|
41
|
+
ref: str | None = None,
|
|
42
|
+
workspace_summary: str | None = None,
|
|
43
|
+
provider: LLMProvider | None = None,
|
|
44
|
+
history: Iterable[tuple[str, str]] | None = None,
|
|
45
|
+
) -> str:
|
|
46
|
+
if provider is None:
|
|
47
|
+
return answer_question(workspace, message)
|
|
48
|
+
|
|
49
|
+
user = _build_user_prompt(workspace, message, ref=ref, workspace_summary=workspace_summary, history=history)
|
|
50
|
+
response = provider.complete(LLMRequest(system=CHAT_SYSTEM_PROMPT, user=user, temperature=0.2))
|
|
51
|
+
return response.content.strip() or "No response returned."
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def chat_turn(
|
|
55
|
+
workspace: Workspace,
|
|
56
|
+
message: str,
|
|
57
|
+
*,
|
|
58
|
+
path: Path,
|
|
59
|
+
state: ChatState,
|
|
60
|
+
provider: LLMProvider | None = None,
|
|
61
|
+
) -> str:
|
|
62
|
+
stripped = message.strip()
|
|
63
|
+
if stripped.startswith("/"):
|
|
64
|
+
response = _handle_chat_command(workspace, path, stripped, state=state, provider=provider)
|
|
65
|
+
elif _looks_like_update_request(stripped):
|
|
66
|
+
ref = _resolve_update_ref(stripped, state.ref)
|
|
67
|
+
if ref is None:
|
|
68
|
+
response = "Provide a ref or set one with /ref."
|
|
69
|
+
else:
|
|
70
|
+
instruction = _strip_update_ref_from_message(stripped, ref=ref)
|
|
71
|
+
try:
|
|
72
|
+
result = update_definition(path, ref, instruction, provider=provider, write=False)
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
response = f"ERROR: {exc}"
|
|
75
|
+
else:
|
|
76
|
+
response = _render_update_preview(result)
|
|
77
|
+
else:
|
|
78
|
+
response = chat_reply(
|
|
79
|
+
workspace,
|
|
80
|
+
message,
|
|
81
|
+
ref=state.ref,
|
|
82
|
+
workspace_summary=state.workspace_summary,
|
|
83
|
+
provider=provider,
|
|
84
|
+
history=state.history,
|
|
85
|
+
)
|
|
86
|
+
state.history.append(("user", message))
|
|
87
|
+
state.history.append(("assistant", response))
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_user_prompt(
|
|
92
|
+
workspace: Workspace,
|
|
93
|
+
message: str,
|
|
94
|
+
*,
|
|
95
|
+
ref: str | None = None,
|
|
96
|
+
workspace_summary: str | None = None,
|
|
97
|
+
history: Iterable[tuple[str, str]] | None = None,
|
|
98
|
+
) -> str:
|
|
99
|
+
if ref is None:
|
|
100
|
+
context = workspace_summary or build_workspace_summary(workspace)
|
|
101
|
+
else:
|
|
102
|
+
context = _build_context_summary(workspace, ref=ref)
|
|
103
|
+
lines = [f"Workspace context:\n{context}"]
|
|
104
|
+
if history:
|
|
105
|
+
lines.append("Conversation:")
|
|
106
|
+
for role, text in history:
|
|
107
|
+
lines.append(f"{role}: {text}")
|
|
108
|
+
lines.append(f"user: {message}")
|
|
109
|
+
return "\n\n".join(lines)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _build_context_summary(workspace: Workspace, *, ref: str | None) -> str:
|
|
113
|
+
if ref is None:
|
|
114
|
+
return build_workspace_summary(workspace)
|
|
115
|
+
model_ref = parse_model_ref(ref)
|
|
116
|
+
domain = next((d for d in workspace.mdl.domains if d.name == model_ref.domain), None)
|
|
117
|
+
if domain is None:
|
|
118
|
+
return f"Unknown domain: {model_ref.domain}"
|
|
119
|
+
if model_ref.name in domain.models:
|
|
120
|
+
return build_model_summary(workspace, ref)
|
|
121
|
+
if model_ref.name in domain.projections:
|
|
122
|
+
return build_projection_summary(workspace, ref)
|
|
123
|
+
return f"Unknown model or projection: {ref}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _handle_chat_command(
|
|
127
|
+
workspace: Workspace,
|
|
128
|
+
path: Path,
|
|
129
|
+
command_text: str,
|
|
130
|
+
*,
|
|
131
|
+
state: ChatState,
|
|
132
|
+
provider: LLMProvider | None,
|
|
133
|
+
) -> str:
|
|
134
|
+
parts = shlex.split(command_text)
|
|
135
|
+
if not parts:
|
|
136
|
+
return "Empty command."
|
|
137
|
+
command = parts[0].lstrip("/").lower()
|
|
138
|
+
args = parts[1:]
|
|
139
|
+
|
|
140
|
+
if command in {"help", "?"}:
|
|
141
|
+
return _chat_help()
|
|
142
|
+
if command == "ref":
|
|
143
|
+
if not args:
|
|
144
|
+
return state.ref or "No focus ref is set."
|
|
145
|
+
state.ref = args[0]
|
|
146
|
+
return f"Focused on {state.ref}."
|
|
147
|
+
if command == "context":
|
|
148
|
+
return _build_context_summary(workspace, ref=state.ref)
|
|
149
|
+
if command == "describe":
|
|
150
|
+
ref = args[0] if args else state.ref
|
|
151
|
+
if ref:
|
|
152
|
+
return _build_context_summary(workspace, ref=ref)
|
|
153
|
+
return build_workspace_summary(workspace)
|
|
154
|
+
if command == "recommend":
|
|
155
|
+
ref = args[0] if args else state.ref
|
|
156
|
+
consumer = args[1] if len(args) > 1 else None
|
|
157
|
+
if ref is None:
|
|
158
|
+
return "Provide a ref or set one with /ref."
|
|
159
|
+
return recommend_cli(path, ref=ref, consumer=consumer)
|
|
160
|
+
if command == "ask":
|
|
161
|
+
question = " ".join(args).strip()
|
|
162
|
+
if not question:
|
|
163
|
+
return "Provide a question after /ask."
|
|
164
|
+
if provider is not None:
|
|
165
|
+
return chat_reply(
|
|
166
|
+
workspace,
|
|
167
|
+
question,
|
|
168
|
+
ref=state.ref,
|
|
169
|
+
workspace_summary=state.workspace_summary,
|
|
170
|
+
provider=provider,
|
|
171
|
+
history=state.history,
|
|
172
|
+
)
|
|
173
|
+
return answer_question(workspace, question)
|
|
174
|
+
if command == "update":
|
|
175
|
+
ref = args[0] if args else state.ref
|
|
176
|
+
if ref is None:
|
|
177
|
+
return "Provide a ref or set one with /ref."
|
|
178
|
+
instruction = " ".join(args[1:]).strip() if len(args) > 1 else ""
|
|
179
|
+
if not instruction:
|
|
180
|
+
return "Provide an edit instruction after /update."
|
|
181
|
+
try:
|
|
182
|
+
result = update_definition(path, ref, instruction, provider=provider, write=False)
|
|
183
|
+
except ValueError as exc:
|
|
184
|
+
return f"ERROR: {exc}"
|
|
185
|
+
return _render_update_preview(result)
|
|
186
|
+
if command in {"exit", "quit"}:
|
|
187
|
+
return "/exit"
|
|
188
|
+
return f"Unknown command: {command}. Try /help."
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _chat_help() -> str:
|
|
192
|
+
return (
|
|
193
|
+
"Commands: /help, /ref <ref>, /context, /describe [ref], /recommend <ref> [consumer], "
|
|
194
|
+
"/ask <question>, /update <ref> <instruction> (preview only), /exit"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _render_update_preview(result) -> str:
|
|
199
|
+
return _render_update_result(result, written=False)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _render_update_result(result, *, written: bool = True) -> str:
|
|
203
|
+
diff = difflib.unified_diff(
|
|
204
|
+
result.original_content.splitlines(),
|
|
205
|
+
result.content.splitlines(),
|
|
206
|
+
fromfile=str(result.path),
|
|
207
|
+
tofile=f"{result.path} (preview)",
|
|
208
|
+
lineterm="",
|
|
209
|
+
)
|
|
210
|
+
rendered = "\n".join(diff)
|
|
211
|
+
if not rendered:
|
|
212
|
+
rendered = result.content
|
|
213
|
+
if result.warnings:
|
|
214
|
+
rendered += "\n" + "\n".join(f"WARN: {warning}" for warning in result.warnings)
|
|
215
|
+
if written:
|
|
216
|
+
rendered = f"Wrote changes to {result.path}\n{rendered}"
|
|
217
|
+
return rendered
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _looks_like_update_request(message: str) -> bool:
|
|
221
|
+
lowered = message.lower().strip()
|
|
222
|
+
if lowered.endswith("?") and lowered.startswith(("how do i ", "how to ", "what is ", "why does ", "why is ")):
|
|
223
|
+
return False
|
|
224
|
+
if lowered.startswith(("please ", "please,", "could you ", "can you ", "would you ", "kindly ")):
|
|
225
|
+
return True
|
|
226
|
+
if re.match(r"^(make|rename|add|remove|delete|change|set|update|replace)\b", lowered):
|
|
227
|
+
return True
|
|
228
|
+
return any(
|
|
229
|
+
f" {verb} " in lowered
|
|
230
|
+
for verb in ("make", "rename", "add", "remove", "delete", "change", "set", "update", "replace")
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _resolve_update_ref(message: str, fallback_ref: str | None) -> str | None:
|
|
235
|
+
explicit_ref = _find_model_ref_in_message(message)
|
|
236
|
+
if explicit_ref is not None:
|
|
237
|
+
return explicit_ref
|
|
238
|
+
return fallback_ref
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _find_model_ref_in_message(message: str) -> str | None:
|
|
242
|
+
match = re.search(r"\b([A-Za-z_][A-Za-z0-9_-]*\.[A-Za-z_][A-Za-z0-9_-]*@\d+)\b", message)
|
|
243
|
+
if match is None:
|
|
244
|
+
return None
|
|
245
|
+
candidate = match.group(1)
|
|
246
|
+
try:
|
|
247
|
+
parse_model_ref(candidate)
|
|
248
|
+
except ValueError:
|
|
249
|
+
return None
|
|
250
|
+
return candidate
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _strip_update_ref_from_message(message: str, *, ref: str) -> str:
|
|
254
|
+
cleaned = re.sub(rf"\b{re.escape(ref)}\b", "", message, count=1).strip()
|
|
255
|
+
return cleaned.lstrip(" ,:;-")
|
modelable/llm/config.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from os import environ
|
|
5
|
+
|
|
6
|
+
from modelable.parser.ir import WorkspaceDef
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class LlmConfig:
|
|
11
|
+
provider: str | None
|
|
12
|
+
model: str | None
|
|
13
|
+
base_url: str | None
|
|
14
|
+
repair_attempts: int
|
|
15
|
+
source: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_llm_config(
|
|
19
|
+
*,
|
|
20
|
+
flag_provider: str | None = None,
|
|
21
|
+
flag_model: str | None = None,
|
|
22
|
+
flag_base_url: str | None = None,
|
|
23
|
+
workspace: WorkspaceDef | None = None,
|
|
24
|
+
env: dict[str, str] | None = None,
|
|
25
|
+
default_model: str = "modelable-local",
|
|
26
|
+
) -> LlmConfig:
|
|
27
|
+
values = env or environ
|
|
28
|
+
|
|
29
|
+
provider = (
|
|
30
|
+
flag_provider
|
|
31
|
+
or values.get("MODELABLE_LLM_PROVIDER")
|
|
32
|
+
or (workspace.ai.provider if workspace and workspace.ai and workspace.ai.provider else None)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
model = (
|
|
36
|
+
flag_model
|
|
37
|
+
or values.get("MODELABLE_LLM_MODEL")
|
|
38
|
+
or (workspace.ai.model if workspace and workspace.ai and workspace.ai.model else None)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
base_url = flag_base_url or values.get("MODELABLE_LLM_BASE_URL") or values.get("OLLAMA_HOST")
|
|
42
|
+
|
|
43
|
+
repair_attempts = _resolve_repair_attempts(
|
|
44
|
+
flag_repair_attempts=None,
|
|
45
|
+
env_repair_attempts=values.get("MODELABLE_LLM_REPAIR_ATTEMPTS"),
|
|
46
|
+
workspace_repair_attempts=workspace.ai.repair_attempts if workspace and workspace.ai else None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
source = "default"
|
|
50
|
+
if flag_provider or flag_model or flag_base_url:
|
|
51
|
+
source = "flag"
|
|
52
|
+
elif (
|
|
53
|
+
values.get("MODELABLE_LLM_PROVIDER")
|
|
54
|
+
or values.get("MODELABLE_LLM_MODEL")
|
|
55
|
+
or values.get("MODELABLE_LLM_BASE_URL")
|
|
56
|
+
or values.get("MODELABLE_LLM_REPAIR_ATTEMPTS")
|
|
57
|
+
):
|
|
58
|
+
source = "environment"
|
|
59
|
+
elif workspace and workspace.ai:
|
|
60
|
+
source = "workspace"
|
|
61
|
+
|
|
62
|
+
if provider is None and model is None:
|
|
63
|
+
model = default_model
|
|
64
|
+
|
|
65
|
+
return LlmConfig(provider=provider, model=model, base_url=base_url, repair_attempts=repair_attempts, source=source)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_repair_attempts(
|
|
69
|
+
*,
|
|
70
|
+
flag_repair_attempts: int | None,
|
|
71
|
+
env_repair_attempts: str | None,
|
|
72
|
+
workspace_repair_attempts: int | None,
|
|
73
|
+
) -> int:
|
|
74
|
+
if flag_repair_attempts is not None:
|
|
75
|
+
if flag_repair_attempts < 0:
|
|
76
|
+
raise ValueError("MODELABLE_LLM_REPAIR_ATTEMPTS must be >= 0")
|
|
77
|
+
return flag_repair_attempts
|
|
78
|
+
if env_repair_attempts is not None:
|
|
79
|
+
value = int(env_repair_attempts)
|
|
80
|
+
if value < 0:
|
|
81
|
+
raise ValueError("MODELABLE_LLM_REPAIR_ATTEMPTS must be >= 0")
|
|
82
|
+
return value
|
|
83
|
+
if workspace_repair_attempts is not None:
|
|
84
|
+
if workspace_repair_attempts < 0:
|
|
85
|
+
raise ValueError("workspace ai.repair_attempts must be >= 0")
|
|
86
|
+
return workspace_repair_attempts
|
|
87
|
+
return 1
|
modelable/llm/context.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from modelable.compiler.workspace import Workspace
|
|
7
|
+
from modelable.parser.ir import (
|
|
8
|
+
ComputedMapping,
|
|
9
|
+
DirectMapping,
|
|
10
|
+
ProjectionField,
|
|
11
|
+
VersionMin,
|
|
12
|
+
VersionPinned,
|
|
13
|
+
VersionRange,
|
|
14
|
+
VersionSpec,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ModelRef:
|
|
20
|
+
domain: str
|
|
21
|
+
name: str
|
|
22
|
+
version: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_model_ref(ref: str) -> ModelRef:
|
|
26
|
+
if "@" not in ref or "." not in ref:
|
|
27
|
+
raise ValueError("REF must be in the form domain.Model@version")
|
|
28
|
+
model_ref, version_text = ref.rsplit("@", 1)
|
|
29
|
+
domain, name = model_ref.split(".", 1)
|
|
30
|
+
return ModelRef(domain=domain, name=name, version=int(version_text))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_model_ref_version_spec(ref: str) -> tuple[str, str, VersionSpec | int]:
|
|
34
|
+
if "@" not in ref or "." not in ref:
|
|
35
|
+
raise ValueError("REF must be in the form domain.Model@version")
|
|
36
|
+
model_ref, version_text = ref.rsplit("@", 1)
|
|
37
|
+
domain, name = model_ref.split(".", 1)
|
|
38
|
+
return domain, name, _parse_version_spec(version_text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_workspace_summary(workspace: Workspace) -> str:
|
|
42
|
+
lines: list[str] = []
|
|
43
|
+
for domain in workspace.mdl.domains:
|
|
44
|
+
lines.append(f"domain {domain.name}")
|
|
45
|
+
if domain.owner:
|
|
46
|
+
lines.append(f" owner: {domain.owner}")
|
|
47
|
+
if domain.description:
|
|
48
|
+
lines.append(f" description: {domain.description}")
|
|
49
|
+
for model_name, versions in domain.models.items():
|
|
50
|
+
for version in versions:
|
|
51
|
+
lines.append(
|
|
52
|
+
f" {version.model_kind.value} {model_name} @ {version.version} ({version.change_kind.value})"
|
|
53
|
+
)
|
|
54
|
+
for field in version.fields:
|
|
55
|
+
lines.append(f" - {field.name}: {_field_type_text(field.type)}")
|
|
56
|
+
for projection_name, versions in domain.projections.items():
|
|
57
|
+
for version in versions:
|
|
58
|
+
lines.append(f" projection {projection_name} @ {version.version}")
|
|
59
|
+
lines.append(
|
|
60
|
+
f" from {version.source.model} @ {_version_text(version.source.version)} as {version.source.alias}"
|
|
61
|
+
)
|
|
62
|
+
for field in version.fields:
|
|
63
|
+
lines.append(f" - {field.name}")
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_model_summary(workspace: Workspace, ref: str) -> str:
|
|
68
|
+
model_ref = parse_model_ref(ref)
|
|
69
|
+
domain = next((d for d in workspace.mdl.domains if d.name == model_ref.domain), None)
|
|
70
|
+
if domain is None:
|
|
71
|
+
return f"Unknown domain: {model_ref.domain}"
|
|
72
|
+
versions = domain.models.get(model_ref.name)
|
|
73
|
+
if not versions:
|
|
74
|
+
return f"Unknown model: {model_ref.domain}.{model_ref.name}"
|
|
75
|
+
version = next((item for item in versions if item.version == model_ref.version), None)
|
|
76
|
+
if version is None:
|
|
77
|
+
return f"Unknown model version: {ref}"
|
|
78
|
+
|
|
79
|
+
lines = [f"{model_ref.domain}.{model_ref.name}@{version.version}"]
|
|
80
|
+
lines.append(f"kind: {version.model_kind.value}")
|
|
81
|
+
lines.append(f"change: {version.change_kind.value}")
|
|
82
|
+
if domain.owner:
|
|
83
|
+
lines.append(f"owner: {domain.owner}")
|
|
84
|
+
if domain.description:
|
|
85
|
+
lines.append(f"description: {domain.description}")
|
|
86
|
+
for field in version.fields:
|
|
87
|
+
flags = []
|
|
88
|
+
if field.is_key:
|
|
89
|
+
flags.append("key")
|
|
90
|
+
if field.is_pii:
|
|
91
|
+
flags.append("pii")
|
|
92
|
+
if field.classification:
|
|
93
|
+
flags.append(f"classification={field.classification.value}")
|
|
94
|
+
suffix = f" [{', '.join(flags)}]" if flags else ""
|
|
95
|
+
lines.append(f"- {field.name}: {_field_type_text(field.type)}{suffix}")
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_projection_summary(workspace: Workspace, ref: str) -> str:
|
|
100
|
+
model_ref = parse_model_ref(ref)
|
|
101
|
+
domain = next((d for d in workspace.mdl.domains if d.name == model_ref.domain), None)
|
|
102
|
+
if domain is None:
|
|
103
|
+
return f"Unknown domain: {model_ref.domain}"
|
|
104
|
+
versions = domain.projections.get(model_ref.name)
|
|
105
|
+
if not versions:
|
|
106
|
+
return f"Unknown projection: {model_ref.domain}.{model_ref.name}"
|
|
107
|
+
version = next((item for item in versions if item.version == model_ref.version), None)
|
|
108
|
+
if version is None:
|
|
109
|
+
return f"Unknown projection version: {ref}"
|
|
110
|
+
|
|
111
|
+
lines = [f"{model_ref.domain}.{model_ref.name}@{version.version}"]
|
|
112
|
+
lines.append(f"source: {version.source.model} @ {_version_text(version.source.version)} as {version.source.alias}")
|
|
113
|
+
if version.joins:
|
|
114
|
+
for join in version.joins:
|
|
115
|
+
lines.append(f"join: {join.model} @ {_version_text(join.version)} as {join.alias} on {join.on}")
|
|
116
|
+
if version.group_by:
|
|
117
|
+
lines.append(f"group by: {', '.join(version.group_by)}")
|
|
118
|
+
for field in version.fields:
|
|
119
|
+
lines.append(f"- {field.name}: {_projection_mapping_text(field)}")
|
|
120
|
+
return "\n".join(lines)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _field_type_text(field_type) -> str:
|
|
124
|
+
kind = getattr(field_type, "kind", None)
|
|
125
|
+
if kind is None:
|
|
126
|
+
return "unknown"
|
|
127
|
+
if kind == "decimal":
|
|
128
|
+
return f"decimal({field_type.precision}, {field_type.scale})"
|
|
129
|
+
if kind == "array":
|
|
130
|
+
return f"array<{_field_type_text(field_type.item)}>"
|
|
131
|
+
if kind == "map":
|
|
132
|
+
return f"map<{_field_type_text(field_type.key)}, {_field_type_text(field_type.value)}>"
|
|
133
|
+
if kind == "ref":
|
|
134
|
+
return f"ref<{field_type.target}>"
|
|
135
|
+
if kind == "enum":
|
|
136
|
+
return f"enum({', '.join(field_type.values)})"
|
|
137
|
+
if kind == "object":
|
|
138
|
+
return "object"
|
|
139
|
+
if kind == "named":
|
|
140
|
+
return field_type.name
|
|
141
|
+
return kind
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _version_text(version_spec) -> str:
|
|
145
|
+
kind = getattr(version_spec, "kind", None)
|
|
146
|
+
if kind == "exact":
|
|
147
|
+
return str(version_spec.version)
|
|
148
|
+
if kind == "range":
|
|
149
|
+
return f">={version_spec.min_inclusive}<{version_spec.max_exclusive}"
|
|
150
|
+
if kind == "min":
|
|
151
|
+
return f">={version_spec.min_inclusive}"
|
|
152
|
+
if isinstance(version_spec, VersionPinned):
|
|
153
|
+
return f"{version_spec.version}#{version_spec.content_hash}"
|
|
154
|
+
return "?"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _projection_mapping_text(field: ProjectionField) -> str:
|
|
158
|
+
mapping = field.mapping
|
|
159
|
+
if isinstance(mapping, DirectMapping):
|
|
160
|
+
return f"direct {mapping.source_alias}.{mapping.source_field}"
|
|
161
|
+
if isinstance(mapping, ComputedMapping):
|
|
162
|
+
return f"computed {mapping.expression}"
|
|
163
|
+
return "unknown"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
_VERSION_RANGE_RE = re.compile(r"^>=\s*(\d+)\s*<\s*(\d+)$")
|
|
167
|
+
_VERSION_MIN_RE = re.compile(r"^>=\s*(\d+)$")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_version_spec(version_text: str) -> VersionSpec | int:
|
|
171
|
+
try:
|
|
172
|
+
return int(version_text)
|
|
173
|
+
except ValueError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
pinned_match = re.match(r"^(\d+)\#([0-9a-fA-F]+)$", version_text)
|
|
177
|
+
if pinned_match:
|
|
178
|
+
return VersionPinned(
|
|
179
|
+
version=int(pinned_match.group(1)),
|
|
180
|
+
content_hash=pinned_match.group(2),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
range_match = _VERSION_RANGE_RE.match(version_text)
|
|
184
|
+
if range_match:
|
|
185
|
+
return VersionRange(
|
|
186
|
+
min_inclusive=int(range_match.group(1)),
|
|
187
|
+
max_exclusive=int(range_match.group(2)),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
min_match = _VERSION_MIN_RE.match(version_text)
|
|
191
|
+
if min_match:
|
|
192
|
+
return VersionMin(min_inclusive=int(min_match.group(1)))
|
|
193
|
+
|
|
194
|
+
raise ValueError("version must be an integer or version range like >=1<3")
|