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
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(" ,:;-")
@@ -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
@@ -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")