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,976 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ from dataclasses import dataclass
6
+ from os import environ
7
+ from pathlib import Path
8
+
9
+ from modelable.compat.diff import FieldChange, compare_model_versions
10
+ from modelable.compiler.workspace import load_workspace
11
+ from modelable.diagnostics.model import render_diagnostic
12
+ from modelable.emitters.csharp import emit_csharp
13
+ from modelable.emitters.dbt_yaml import emit_dbt_yaml
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.python import emit_python
19
+ from modelable.emitters.rust import emit_rust
20
+ from modelable.emitters.typescript import emit_typescript
21
+ from modelable.llm.config import LlmConfig, resolve_llm_config
22
+ from modelable.llm.context import (
23
+ build_model_summary,
24
+ build_projection_summary,
25
+ build_workspace_summary,
26
+ parse_model_ref,
27
+ )
28
+ from modelable.llm.importers import import_from_path, import_from_text
29
+ from modelable.llm.providers import LLMProvider, build_provider
30
+ from modelable.llm.qa import answer_question
31
+ from modelable.llm.recommendations import recommend_for_model
32
+ from modelable.llm.render import render_mdl, render_model_version, render_projection_version
33
+ from modelable.llm.update_plan import (
34
+ UpdateChange,
35
+ UpdatePlan,
36
+ build_update_repair_request,
37
+ build_update_request,
38
+ parse_update_plan,
39
+ )
40
+ from modelable.llm.validation_help import explain_validation_errors
41
+ from modelable.parser.ir import (
42
+ AnnKey,
43
+ ChangeKind,
44
+ DirectMapping,
45
+ FieldDef,
46
+ MdlFile,
47
+ ModelKind,
48
+ ModelVersion,
49
+ ParseError,
50
+ PrimitiveType,
51
+ ProjectionField,
52
+ ProjectionVersion,
53
+ SourceRef,
54
+ VersionExact,
55
+ )
56
+ from modelable.parser.parse import parse_text_to_ir
57
+ from modelable.planner.planner import expand_auto_projections
58
+ from modelable.validation.semantic import validate
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class AssistantResult:
63
+ content: str
64
+ warnings: list[str]
65
+ explanation: str | None = None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class UpdateResult:
70
+ path: Path
71
+ source_path: Path
72
+ ref: str
73
+ original_content: str
74
+ content: str
75
+ warnings: list[str]
76
+ provider: str
77
+ model: str
78
+ diagnostics_repaired: int
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class UpdatePlanResult:
83
+ plan: UpdatePlan
84
+ provider: str
85
+ model: str
86
+ diagnostics_repaired: int
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class AttachResult:
91
+ path: Path
92
+ source_path: Path
93
+ ref: str
94
+ original_content: str
95
+ content: str
96
+ warnings: list[str]
97
+ attached: bool
98
+ from_version: int
99
+ to_version: int | None
100
+ change_kind: str | None
101
+ changes: list[FieldChange]
102
+ source_format: str
103
+ source_name: str
104
+ source_descriptor: str
105
+ source_hash: str
106
+
107
+
108
+ def describe_path_or_ref(path: Path | None = None, ref: str | None = None) -> str:
109
+ if ref and path is not None:
110
+ workspace = load_workspace(path)
111
+ if ref.count(".") == 1 and "@" in ref:
112
+ model_ref = parse_model_ref(ref)
113
+ domain = next((d for d in workspace.mdl.domains if d.name == model_ref.domain), None)
114
+ if domain and model_ref.name in domain.projections:
115
+ return build_projection_summary(workspace, ref)
116
+ return build_model_summary(workspace, ref)
117
+ if path is not None:
118
+ workspace = load_workspace(path)
119
+ return build_workspace_summary(workspace)
120
+ return "No path or reference provided."
121
+
122
+
123
+ def generate_entity_from_prompt(
124
+ prompt: str, *, domain_name: str | None = None, model_name: str | None = None, owner: str | None = None
125
+ ) -> str:
126
+ domain = domain_name or "generated"
127
+ name = model_name or _derive_name_from_prompt(prompt)
128
+ fields = [
129
+ FieldDef(name=_key_field_name(name), type=_uuid_field(), annotations=[AnnKey()]),
130
+ FieldDef(name="name", type=_string_field()),
131
+ ]
132
+ version = ModelVersion(model_kind=ModelKind.entity, version=1, change_kind="additive", fields=fields)
133
+ return render_model_version(domain, name, version, owner=owner or "generated")
134
+
135
+
136
+ def transform_ref_to_target(path: Path, ref: str, target: str) -> AssistantResult:
137
+ workspace = load_workspace(path)
138
+ domain_name, model_name, version = _split_ref(ref)
139
+ domain = next((d for d in workspace.mdl.domains if d.name == domain_name), None)
140
+ if domain is None:
141
+ raise ValueError(f"Unknown domain: {domain_name}")
142
+
143
+ emitters = {
144
+ "typescript": (emit_typescript, Path(".modelable/types"), False),
145
+ "json-schema": (emit_json_schema, Path(".modelable/jsonschema"), True),
146
+ "markdown": (emit_markdown, Path(".modelable/docs"), False),
147
+ "csharp": (emit_csharp, Path(".modelable/csharp"), False),
148
+ "java": (emit_java, Path(".modelable/java"), False),
149
+ "python": (emit_python, Path(".modelable/python"), False),
150
+ "rust": (emit_rust, Path(".modelable/rust"), False),
151
+ "go": (emit_go, Path(".modelable/go"), False),
152
+ "dbt-yaml": (emit_dbt_yaml, Path(".modelable/dbt"), False),
153
+ }
154
+
155
+ if model_name in domain.models:
156
+ mv = next((item for item in domain.models[model_name] if item.version == version), None)
157
+ if mv is None:
158
+ raise ValueError(f"Unknown model version: {ref}")
159
+ elif model_name in domain.projections:
160
+ pv = next((item for item in domain.projections[model_name] if item.version == version), None)
161
+ if pv is None:
162
+ raise ValueError(f"Unknown projection version: {ref}")
163
+ else:
164
+ raise ValueError(f"Unknown model or projection: {ref}")
165
+
166
+ if target in emitters:
167
+ emitter_fn, out_path, is_json = emitters[target]
168
+ artifacts = emitter_fn(workspace, out_path)
169
+ art = next(a for a in artifacts if a.ref == ref)
170
+ content = _json_dump(art.content) if is_json else str(art.content)
171
+ return AssistantResult(
172
+ content=content,
173
+ warnings=art.warnings,
174
+ explanation=_build_transform_explanation(
175
+ ref=ref, target=target, is_projection=model_name in domain.projections
176
+ ),
177
+ )
178
+ raise ValueError(f"Unsupported target: {target}")
179
+
180
+
181
+ def import_definition(
182
+ source: Path | str, source_format: str, *, domain_name: str | None = None, source_name: str | None = None
183
+ ) -> str:
184
+ if isinstance(source, Path):
185
+ imported = import_from_path(source, source_format, domain_name=domain_name, source_name=source_name)
186
+ else:
187
+ imported = import_from_text(source, source_format, domain_name=domain_name, source_name=source_name)
188
+ return imported.to_mdl()
189
+
190
+
191
+ def suggest_projection(path: Path, source_ref: str, consumer_domain: str) -> str:
192
+ workspace = load_workspace(path)
193
+ model_ref = parse_model_ref(source_ref)
194
+ domain = next((d for d in workspace.mdl.domains if d.name == model_ref.domain), None)
195
+ if domain is None:
196
+ raise ValueError(f"Unknown domain: {model_ref.domain}")
197
+ versions = domain.models.get(model_ref.name)
198
+ if not versions:
199
+ raise ValueError(f"Unknown model: {source_ref}")
200
+ version = next((item for item in versions if item.version == model_ref.version), None)
201
+ if version is None:
202
+ raise ValueError(f"Unknown model version: {source_ref}")
203
+
204
+ target_fields: list[ProjectionField] = []
205
+ alias = model_ref.name[0].lower() + model_ref.name[1:]
206
+ for field in version.fields:
207
+ if field.is_pii or any(ann.kind == "server" for ann in field.annotations):
208
+ continue
209
+ target_fields.append(
210
+ ProjectionField(
211
+ name=field.name,
212
+ mapping=DirectMapping(source_alias=alias, source_field=field.name),
213
+ annotations=list(field.annotations),
214
+ )
215
+ )
216
+ projection = ProjectionVersion(
217
+ version=version.version,
218
+ source=SourceRef(
219
+ model=f"{model_ref.domain}.{model_ref.name}",
220
+ version=VersionExact(version=version.version),
221
+ alias=alias,
222
+ ),
223
+ fields=target_fields,
224
+ )
225
+ return render_projection_version(consumer_domain, f"{model_ref.name}View", projection, owner="suggested")
226
+
227
+
228
+ def answer_model_question_cli(path: Path, question: str) -> str:
229
+ workspace = load_workspace(path)
230
+ return answer_question(workspace, question)
231
+
232
+
233
+ def recommend_cli(path: Path, ref: str | None = None, consumer: str | None = None) -> str:
234
+ workspace = load_workspace(path)
235
+ return recommend_for_model(workspace, ref=ref, consumer=consumer)
236
+
237
+
238
+ def explain_validation(path: Path) -> str:
239
+ workspace = load_workspace(path)
240
+ return explain_validation_errors([render_diagnostic(error) for error in workspace.errors])
241
+
242
+
243
+ def _build_update_plan(
244
+ provider: LLMProvider,
245
+ workspace,
246
+ current_text: str,
247
+ ref: str,
248
+ instruction: str,
249
+ *,
250
+ repair_attempts: int = 1,
251
+ ) -> UpdatePlanResult:
252
+ current_summary = _summarize_update_target(workspace, ref)
253
+ request = build_update_request(
254
+ ref=ref,
255
+ current_summary=current_summary,
256
+ current_text=current_text,
257
+ instruction=instruction,
258
+ )
259
+ response = provider.complete(request)
260
+ try:
261
+ plan = _parse_update_plan_response(response.content, ref=ref)
262
+ return UpdatePlanResult(plan=plan, provider=response.provider, model=response.model, diagnostics_repaired=0)
263
+ except Exception as exc:
264
+ if repair_attempts <= 0:
265
+ raise ValueError(f"LLM returned an invalid update plan: {exc}") from exc
266
+ repair_request = build_update_repair_request(
267
+ ref=ref,
268
+ current_summary=current_summary,
269
+ current_text=current_text,
270
+ instruction=instruction,
271
+ validation_error=str(exc),
272
+ )
273
+ last_error = exc
274
+ for diagnostics_repaired in range(1, repair_attempts + 1):
275
+ repair_response = provider.complete(repair_request)
276
+ try:
277
+ plan = _parse_update_plan_response(repair_response.content, ref=ref)
278
+ return UpdatePlanResult(
279
+ plan=plan,
280
+ provider=repair_response.provider,
281
+ model=repair_response.model,
282
+ diagnostics_repaired=diagnostics_repaired,
283
+ )
284
+ except Exception as repair_exc: # pragma: no cover - provider integration guard
285
+ last_error = repair_exc
286
+ raise ValueError(f"LLM returned an invalid update plan after repair: {last_error}") from last_error
287
+
288
+
289
+ def _parse_update_plan_response(content: str, *, ref: str) -> UpdatePlan:
290
+ try:
291
+ plan = parse_update_plan(content)
292
+ except Exception as exc:
293
+ raise ValueError(f"LLM returned an invalid update plan: {exc}") from exc
294
+ if plan.target != ref:
295
+ raise ValueError(f"LLM proposed an update for '{plan.target}' instead of '{ref}'")
296
+ return plan
297
+
298
+
299
+ def update_definition(
300
+ path: Path,
301
+ ref: str,
302
+ instruction: str,
303
+ *,
304
+ output: Path | None = None,
305
+ write: bool = True,
306
+ provider: LLMProvider | None = None,
307
+ llm_config: LlmConfig | None = None,
308
+ ) -> UpdateResult:
309
+ workspace = load_workspace(path)
310
+ model_ref = parse_model_ref(ref)
311
+ source_path = _find_source_path_for_ref(workspace, model_ref.domain, model_ref.name)
312
+ if source_path is None:
313
+ raise ValueError(f"Could not find source file for {ref}")
314
+
315
+ source_text = source_path.read_text(encoding="utf-8")
316
+ mdl = parse_text_to_ir(source_text)
317
+
318
+ domain = next((item for item in mdl.domains if item.name == model_ref.domain), None)
319
+ if domain is None:
320
+ raise ValueError(f"Unknown domain: {model_ref.domain}")
321
+
322
+ warnings: list[str] = []
323
+ updated = False
324
+ provider_name = "local"
325
+ model_name = llm_config.model if llm_config is not None else "modelable-local"
326
+ diagnostics_repaired = 0
327
+ repair_attempts = llm_config.repair_attempts if llm_config is not None else 1
328
+
329
+ if provider is None and llm_config is None:
330
+ llm_config = resolve_llm_config(workspace=workspace.mdl.workspace, env=environ)
331
+ provider = build_provider(llm_config.provider, model=llm_config.model, base_url=llm_config.base_url)
332
+ provider_name = llm_config.provider or "local"
333
+ model_name = llm_config.model or model_name
334
+ elif llm_config is not None:
335
+ provider_name = llm_config.provider or "local"
336
+ model_name = llm_config.model or model_name
337
+
338
+ if model_ref.name in domain.models:
339
+ version = next((item for item in domain.models[model_ref.name] if item.version == model_ref.version), None)
340
+ if version is None:
341
+ raise ValueError(f"Unknown model version: {ref}")
342
+ if provider is not None:
343
+ plan_result = _build_update_plan(
344
+ provider, workspace, source_text, ref, instruction, repair_attempts=repair_attempts
345
+ )
346
+ updated, warnings = _apply_update_plan_to_model(version, plan_result.plan)
347
+ provider_name = plan_result.provider
348
+ model_name = plan_result.model
349
+ diagnostics_repaired = plan_result.diagnostics_repaired
350
+ else:
351
+ updated, warnings = _apply_model_update(version, instruction)
352
+ elif model_ref.name in domain.projections:
353
+ version = next((item for item in domain.projections[model_ref.name] if item.version == model_ref.version), None)
354
+ if version is None:
355
+ raise ValueError(f"Unknown projection version: {ref}")
356
+ if provider is not None:
357
+ plan_result = _build_update_plan(
358
+ provider, workspace, source_text, ref, instruction, repair_attempts=repair_attempts
359
+ )
360
+ updated, warnings = _apply_update_plan_to_projection(version, plan_result.plan)
361
+ provider_name = plan_result.provider
362
+ model_name = plan_result.model
363
+ diagnostics_repaired = plan_result.diagnostics_repaired
364
+ else:
365
+ updated, warnings = _apply_projection_update(version, instruction)
366
+ else:
367
+ raise ValueError(f"Unknown model or projection: {ref}")
368
+
369
+ if not updated:
370
+ raise ValueError("No supported update instructions were recognized")
371
+
372
+ original_text = source_text
373
+ new_text = render_mdl(mdl)
374
+ _, errors = validate_generated_text(new_text)
375
+ if errors:
376
+ raise ValueError("Updated definition failed validation: " + "; ".join(errors))
377
+
378
+ out_path = output or source_path
379
+ if write:
380
+ out_path.write_text(new_text, encoding="utf-8")
381
+ return UpdateResult(
382
+ path=out_path,
383
+ source_path=source_path,
384
+ ref=ref,
385
+ original_content=original_text,
386
+ content=new_text,
387
+ warnings=warnings,
388
+ provider=provider_name,
389
+ model=model_name,
390
+ diagnostics_repaired=diagnostics_repaired,
391
+ )
392
+
393
+
394
+ _BREAKING_ATTACH_CHANGE_KINDS = {"removed_field", "type_changed", "enum_changed", "identity_changed"}
395
+
396
+
397
+ def attach_external_version(
398
+ path: Path,
399
+ ref: str,
400
+ source: Path | str,
401
+ source_format: str,
402
+ *,
403
+ source_name: str | None = None,
404
+ output: Path | None = None,
405
+ write: bool = True,
406
+ ) -> AttachResult:
407
+ """Attach a model version to an external dbt or FHIR source.
408
+
409
+ If the external source's fields differ from the referenced model version, append a
410
+ new `.mdl` version block with a computed `additive`/`breaking` change kind.
411
+ """
412
+ workspace = load_workspace(path)
413
+ model_ref = parse_model_ref(ref)
414
+ source_path = _find_source_path_for_ref(workspace, model_ref.domain, model_ref.name)
415
+ if source_path is None:
416
+ raise ValueError(f"Could not find source file for {ref}")
417
+
418
+ mdl_text = source_path.read_text(encoding="utf-8")
419
+ mdl = parse_text_to_ir(mdl_text)
420
+ domain = next((item for item in mdl.domains if item.name == model_ref.domain), None)
421
+ if domain is None:
422
+ raise ValueError(f"Unknown domain: {model_ref.domain}")
423
+ versions = domain.models.get(model_ref.name)
424
+ if not versions:
425
+ raise ValueError(f"Unknown model: {ref}")
426
+ current = next((item for item in versions if item.version == model_ref.version), None)
427
+ if current is None:
428
+ raise ValueError(f"Unknown model version: {ref}")
429
+
430
+ if isinstance(source, Path):
431
+ source_text = source.read_text(encoding="utf-8")
432
+ source_descriptor = str(source)
433
+ else:
434
+ source_text = source
435
+ source_descriptor = "inline"
436
+ imported = import_from_text(source_text, source_format, domain_name=model_ref.domain, source_name=source_name)
437
+ source_hash = hashlib.sha256(source_text.encode("utf-8")).hexdigest()
438
+
439
+ new_fields = _build_attached_fields(current.fields, imported.model_version.fields)
440
+ candidate_version = ModelVersion(
441
+ model_kind=current.model_kind,
442
+ version=current.version,
443
+ change_kind=current.change_kind,
444
+ fields=new_fields,
445
+ )
446
+ changes = compare_model_versions(current, candidate_version)
447
+
448
+ if not changes:
449
+ return AttachResult(
450
+ path=output or source_path,
451
+ source_path=source_path,
452
+ ref=ref,
453
+ original_content=mdl_text,
454
+ content=mdl_text,
455
+ warnings=imported.warnings,
456
+ attached=False,
457
+ from_version=current.version,
458
+ to_version=None,
459
+ change_kind=None,
460
+ changes=[],
461
+ source_format=source_format,
462
+ source_name=imported.source_name,
463
+ source_descriptor=source_descriptor,
464
+ source_hash=source_hash,
465
+ )
466
+
467
+ change_kind = _classify_attach_change_kind(changes)
468
+ next_version_number = max(item.version for item in versions) + 1
469
+ new_version = ModelVersion(
470
+ model_kind=current.model_kind,
471
+ version=next_version_number,
472
+ change_kind=ChangeKind(change_kind),
473
+ fields=new_fields,
474
+ )
475
+ versions.append(new_version)
476
+
477
+ new_text = render_mdl(mdl)
478
+ _, errors = validate_generated_text(new_text)
479
+ if errors:
480
+ raise ValueError("Attached definition failed validation: " + "; ".join(errors))
481
+
482
+ out_path = output or source_path
483
+ if write:
484
+ out_path.write_text(new_text, encoding="utf-8")
485
+
486
+ return AttachResult(
487
+ path=out_path,
488
+ source_path=source_path,
489
+ ref=ref,
490
+ original_content=mdl_text,
491
+ content=new_text,
492
+ warnings=imported.warnings,
493
+ attached=True,
494
+ from_version=current.version,
495
+ to_version=next_version_number,
496
+ change_kind=change_kind,
497
+ changes=changes,
498
+ source_format=source_format,
499
+ source_name=imported.source_name,
500
+ source_descriptor=source_descriptor,
501
+ source_hash=source_hash,
502
+ )
503
+
504
+
505
+ def _build_attached_fields(old_fields: list[FieldDef], candidate_fields: list[FieldDef]) -> list[FieldDef]:
506
+ """Combine the current field set with imported fields, preserving existing annotations."""
507
+ candidate_by_name = {field.name: field for field in candidate_fields}
508
+ old_names = {field.name for field in old_fields}
509
+ new_fields: list[FieldDef] = []
510
+ for old_field in old_fields:
511
+ candidate = candidate_by_name.get(old_field.name)
512
+ if candidate is None:
513
+ continue
514
+ new_fields.append(
515
+ FieldDef(
516
+ name=old_field.name,
517
+ type=candidate.type,
518
+ optional=candidate.optional,
519
+ default=old_field.default,
520
+ annotations=list(old_field.annotations),
521
+ )
522
+ )
523
+ for candidate in candidate_fields:
524
+ if candidate.name not in old_names:
525
+ new_fields.append(
526
+ FieldDef(
527
+ name=candidate.name,
528
+ type=candidate.type,
529
+ optional=candidate.optional,
530
+ default=candidate.default,
531
+ annotations=list(candidate.annotations),
532
+ )
533
+ )
534
+ return new_fields
535
+
536
+
537
+ def _classify_attach_change_kind(changes: list[FieldChange]) -> str:
538
+ for change in changes:
539
+ if change.kind in _BREAKING_ATTACH_CHANGE_KINDS:
540
+ return "breaking"
541
+ if change.kind == "nullability_changed" and change.from_optional and not change.to_optional:
542
+ return "breaking"
543
+ return "additive"
544
+
545
+
546
+ def render_attach_audit_summary(result: AttachResult) -> str:
547
+ return render_write_audit_summary(
548
+ provider="local",
549
+ model="modelable-local",
550
+ validation_status="passed",
551
+ files_written=str(result.path),
552
+ inputs=f"ref={result.ref} source={result.source_descriptor} format={result.source_format}",
553
+ diagnostics_repaired=0,
554
+ )
555
+
556
+
557
+ def _summarize_update_target(workspace, ref: str) -> str:
558
+ model_ref = parse_model_ref(ref)
559
+ domain = next((item for item in workspace.mdl.domains if item.name == model_ref.domain), None)
560
+ if domain is None:
561
+ return f"Unknown domain: {model_ref.domain}"
562
+ if model_ref.name in domain.models:
563
+ return build_model_summary(workspace, ref)
564
+ if model_ref.name in domain.projections:
565
+ return build_projection_summary(workspace, ref)
566
+ return f"Unknown model or projection: {ref}"
567
+
568
+
569
+ def _apply_update_plan_to_model(version: ModelVersion, plan: UpdatePlan) -> tuple[bool, list[str]]:
570
+ if plan.target_kind != "model":
571
+ raise ValueError(f"Update plan target kind '{plan.target_kind}' does not match model version")
572
+ warnings = list(plan.warnings)
573
+ updated = False
574
+ for change in plan.changes:
575
+ changed, change_warnings = _apply_model_change(version, change)
576
+ updated = updated or changed
577
+ warnings.extend(change_warnings)
578
+ return updated, warnings
579
+
580
+
581
+ def _apply_update_plan_to_projection(version: ProjectionVersion, plan: UpdatePlan) -> tuple[bool, list[str]]:
582
+ if plan.target_kind != "projection":
583
+ raise ValueError(f"Update plan target kind '{plan.target_kind}' does not match projection version")
584
+ warnings = list(plan.warnings)
585
+ updated = False
586
+ for change in plan.changes:
587
+ changed, change_warnings = _apply_projection_change(version, change)
588
+ updated = updated or changed
589
+ warnings.extend(change_warnings)
590
+ return updated, warnings
591
+
592
+
593
+ def _apply_model_change(version: ModelVersion, change: UpdateChange) -> tuple[bool, list[str]]:
594
+ field = next((item for item in version.fields if item.name == change.field), None)
595
+ warnings: list[str] = []
596
+ if change.kind == "add_field":
597
+ field_name = change.new_name or change.field
598
+ if any(item.name == field_name for item in version.fields):
599
+ warnings.append(f"Field '{field_name}' already exists; skipped add")
600
+ return False, warnings
601
+ version.fields.append(
602
+ FieldDef(
603
+ name=field_name,
604
+ type=_type_from_text(change.type) or _string_field(),
605
+ optional=False,
606
+ )
607
+ )
608
+ return True, warnings
609
+ if field is None:
610
+ warnings.append(f"Field '{change.field}' not found; skipped {change.kind}")
611
+ return False, warnings
612
+ if change.kind == "make_optional":
613
+ field.optional = True
614
+ return True, warnings
615
+ if change.kind == "make_required":
616
+ field.optional = False
617
+ return True, warnings
618
+ if change.kind == "rename_field":
619
+ if not change.new_name:
620
+ raise ValueError(f"rename_field for '{change.field}' requires new_name")
621
+ field.name = change.new_name
622
+ return True, warnings
623
+ if change.kind == "remove_field":
624
+ version.fields = [item for item in version.fields if item is not field]
625
+ return True, warnings
626
+ if change.kind == "change_type":
627
+ field.type = _type_from_text(change.type) or _string_field()
628
+ return True, warnings
629
+ raise ValueError(f"Unsupported model update change: {change.kind}")
630
+
631
+
632
+ def _apply_projection_change(version: ProjectionVersion, change: UpdateChange) -> tuple[bool, list[str]]:
633
+ field = next((item for item in version.fields if item.name == change.field), None)
634
+ warnings: list[str] = []
635
+ if change.kind == "add_field":
636
+ field_name = change.new_name or change.field
637
+ if any(item.name == field_name for item in version.fields):
638
+ warnings.append(f"Field '{field_name}' already exists; skipped add")
639
+ return False, warnings
640
+ version.fields.append(
641
+ ProjectionField(
642
+ name=field_name,
643
+ mapping=DirectMapping(
644
+ source_alias=version.source.alias,
645
+ source_field=_normalize_source_field(change.source or field_name),
646
+ ),
647
+ )
648
+ )
649
+ return True, warnings
650
+ if field is None:
651
+ warnings.append(f"Field '{change.field}' not found; skipped {change.kind}")
652
+ return False, warnings
653
+ if change.kind == "rename_field":
654
+ if not change.new_name:
655
+ raise ValueError(f"rename_field for '{change.field}' requires new_name")
656
+ field.name = change.new_name
657
+ return True, warnings
658
+ if change.kind == "remove_field":
659
+ version.fields = [item for item in version.fields if item is not field]
660
+ return True, warnings
661
+ if change.kind == "change_source":
662
+ if not isinstance(field.mapping, DirectMapping):
663
+ warnings.append(f"Field '{change.field}' is not a direct mapping; skipped source change")
664
+ return False, warnings
665
+ if not change.source:
666
+ raise ValueError(f"change_source for '{change.field}' requires source")
667
+ field.mapping.source_field = _normalize_source_field(change.source)
668
+ return True, warnings
669
+ if change.kind == "change_type":
670
+ warnings.append(f"Projection field '{change.field}' does not support change_type; skipped")
671
+ return False, warnings
672
+ if change.kind in {"make_optional", "make_required"}:
673
+ warnings.append(f"Projection field '{change.field}' does not support {change.kind}; skipped")
674
+ return False, warnings
675
+ raise ValueError(f"Unsupported projection update change: {change.kind}")
676
+
677
+
678
+ def validate_generated_text(text: str) -> tuple[MdlFile | None, list[str]]:
679
+ try:
680
+ mdl = parse_text_to_ir(text)
681
+ except ParseError as exc:
682
+ return None, [exc.message]
683
+ errors = validate(mdl)
684
+ if errors:
685
+ return mdl, errors
686
+ expanded_errors = expand_auto_projections(mdl)
687
+ if expanded_errors:
688
+ return mdl, expanded_errors
689
+ return mdl, []
690
+
691
+
692
+ def _derive_name_from_prompt(prompt: str) -> str:
693
+ words = [word for word in prompt.replace("/", " ").replace("-", " ").split() if word.isalpha()]
694
+ for word in words:
695
+ if len(word) > 2:
696
+ return word[:1].upper() + word[1:]
697
+ return "GeneratedModel"
698
+
699
+
700
+ def _key_field_name(model_name: str) -> str:
701
+ return model_name[:1].lower() + model_name[1:] + "Id"
702
+
703
+
704
+ def _uuid_field() -> PrimitiveType:
705
+ return PrimitiveType(kind="uuid")
706
+
707
+
708
+ def _string_field() -> PrimitiveType:
709
+ return PrimitiveType(kind="string")
710
+
711
+
712
+ def _split_ref(ref: str) -> tuple[str, str, int]:
713
+ model_ref = parse_model_ref(ref)
714
+ return model_ref.domain, model_ref.name, model_ref.version
715
+
716
+
717
+ def _find_source_path_for_ref(workspace, domain_name: str, model_name: str) -> Path | None:
718
+ for source in workspace.sources:
719
+ domain = next((item for item in source.mdl.domains if item.name == domain_name), None)
720
+ if domain is None:
721
+ continue
722
+ if model_name in domain.models or model_name in domain.projections:
723
+ return source.path
724
+ return None
725
+
726
+
727
+ def _apply_model_update(version: ModelVersion, instruction: str) -> tuple[bool, list[str]]:
728
+ warnings: list[str] = []
729
+ updated = False
730
+ lowered = instruction.lower()
731
+
732
+ for field in list(version.fields):
733
+ if _matches_optional(field.name, lowered):
734
+ field.optional = True
735
+ updated = True
736
+ if _matches_required(field.name, lowered):
737
+ field.optional = False
738
+ updated = True
739
+ rename = _extract_rename(field.name, instruction)
740
+ if rename is not None:
741
+ field.name = rename
742
+ updated = True
743
+ if _matches_remove(field.name, lowered):
744
+ version.fields = [item for item in version.fields if item is not field]
745
+ updated = True
746
+ continue
747
+ change_type = _extract_type_change(field.name, instruction)
748
+ if change_type is not None:
749
+ field.type = change_type
750
+ updated = True
751
+
752
+ add_match = _extract_field_addition(instruction)
753
+ if add_match is not None:
754
+ field_name, field_type, optional = add_match
755
+ if any(field.name == field_name for field in version.fields):
756
+ warnings.append(f"Field '{field_name}' already exists; skipped add")
757
+ else:
758
+ version.fields.append(
759
+ FieldDef(
760
+ name=field_name,
761
+ type=field_type or _string_field(),
762
+ optional=optional,
763
+ )
764
+ )
765
+ updated = True
766
+
767
+ return updated, warnings
768
+
769
+
770
+ def _apply_projection_update(version: ProjectionVersion, instruction: str) -> tuple[bool, list[str]]:
771
+ warnings: list[str] = []
772
+ updated = False
773
+ lowered = instruction.lower()
774
+
775
+ for field in list(version.fields):
776
+ rename = _extract_rename(field.name, instruction)
777
+ if rename is not None:
778
+ field.name = rename
779
+ updated = True
780
+ if _matches_remove(field.name, lowered):
781
+ version.fields = [item for item in version.fields if item is not field]
782
+ updated = True
783
+ continue
784
+ if _matches_source_field_change(field.name, instruction):
785
+ new_source = _extract_source_field(field.name, instruction)
786
+ if new_source is not None and isinstance(field.mapping, DirectMapping):
787
+ field.mapping.source_field = new_source
788
+ updated = True
789
+
790
+ add_match = _extract_projection_field_addition(instruction)
791
+ if add_match is not None:
792
+ field_name, source_field = add_match
793
+ if any(field.name == field_name for field in version.fields):
794
+ warnings.append(f"Field '{field_name}' already exists; skipped add")
795
+ else:
796
+ version.fields.append(
797
+ ProjectionField(
798
+ name=field_name,
799
+ mapping=DirectMapping(
800
+ source_alias=version.source.alias,
801
+ source_field=_normalize_source_field(source_field or field_name),
802
+ ),
803
+ )
804
+ )
805
+ updated = True
806
+
807
+ return updated, warnings
808
+
809
+
810
+ def _extract_field_addition(instruction: str) -> tuple[str, PrimitiveType | None, bool] | None:
811
+ patterns = [
812
+ r"\badd\s+(?:a\s+)?field\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*(?:as|:)?\s*(?P<type>[A-Za-z_][A-Za-z0-9_<>,()]*)?(?P<optional>\s+optional)?\b",
813
+ r"\badd\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*(?:as|:)?\s*(?P<type>[A-Za-z_][A-Za-z0-9_<>,()]*)?(?P<optional>\s+optional)?\b",
814
+ ]
815
+ for pattern in patterns:
816
+ match = re.search(pattern, instruction, re.IGNORECASE)
817
+ if match:
818
+ field_name = match.group("name")
819
+ field_type = _type_from_text(match.group("type")) if match.group("type") else None
820
+ optional = bool(match.group("optional"))
821
+ return field_name, field_type, optional
822
+ return None
823
+
824
+
825
+ def _extract_projection_field_addition(instruction: str) -> tuple[str, str | None] | None:
826
+ patterns = [
827
+ r"\badd\s+(?:a\s+)?projection\s+field\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*(?:from|as|=|:)?\s*(?P<source>[A-Za-z_][A-Za-z0-9_\.]*)?",
828
+ r"\badd\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*(?:from|as|=|:)?\s*(?P<source>[A-Za-z_][A-Za-z0-9_\.]*)?",
829
+ ]
830
+ for pattern in patterns:
831
+ match = re.search(pattern, instruction, re.IGNORECASE)
832
+ if match:
833
+ return match.group("name"), match.group("source")
834
+ return None
835
+
836
+
837
+ def _extract_rename(existing_name: str, instruction: str) -> str | None:
838
+ import re
839
+
840
+ match = re.search(
841
+ rf"\brename\s+{re.escape(existing_name)}\s+to\s+([A-Za-z_][A-Za-z0-9_]*)\b", instruction, re.IGNORECASE
842
+ )
843
+ if match:
844
+ return match.group(1)
845
+ return None
846
+
847
+
848
+ def _extract_type_change(existing_name: str, instruction: str) -> PrimitiveType | None:
849
+ import re
850
+
851
+ match = re.search(
852
+ rf"\b(?:change|set)\s+{re.escape(existing_name)}\s+(?:to|as)\s+([A-Za-z_][A-Za-z0-9_<>,()]*)\b",
853
+ instruction,
854
+ re.IGNORECASE,
855
+ )
856
+ if match:
857
+ return _type_from_text(match.group(1))
858
+ return None
859
+
860
+
861
+ def _matches_source_field_change(field_name: str, instruction: str) -> bool:
862
+ lowered = instruction.lower()
863
+ return f"{field_name.lower()} from" in lowered or f"change {field_name.lower()} source" in lowered
864
+
865
+
866
+ def _extract_source_field(existing_name: str, instruction: str) -> str | None:
867
+ match = re.search(
868
+ rf"\b(?:change|set|update)\s+{re.escape(existing_name)}\s+(?:source\s+)?(?:to|from)\s+([A-Za-z_][A-Za-z0-9_\.]*)",
869
+ instruction,
870
+ re.IGNORECASE,
871
+ )
872
+ if match:
873
+ return _normalize_source_field(match.group(1))
874
+ return None
875
+
876
+
877
+ def _normalize_source_field(source_field: str) -> str:
878
+ return source_field.split(".")[-1]
879
+
880
+
881
+ def _matches_optional(field_name: str, instruction_lower: str) -> bool:
882
+ return (
883
+ f"{field_name.lower()} optional" in instruction_lower
884
+ or f"make {field_name.lower()} optional" in instruction_lower
885
+ )
886
+
887
+
888
+ def _matches_required(field_name: str, instruction_lower: str) -> bool:
889
+ return (
890
+ f"{field_name.lower()} required" in instruction_lower
891
+ or f"make {field_name.lower()} required" in instruction_lower
892
+ )
893
+
894
+
895
+ def _matches_remove(field_name: str, instruction_lower: str) -> bool:
896
+ return f"remove {field_name.lower()}" in instruction_lower or f"delete {field_name.lower()}" in instruction_lower
897
+
898
+
899
+ def _type_from_text(type_name: str | None) -> PrimitiveType | None:
900
+ if type_name is None:
901
+ return None
902
+ normalized = type_name.strip().lower()
903
+ mapping = {
904
+ "string": "string",
905
+ "text": "string",
906
+ "uuid": "uuid",
907
+ "int": "int",
908
+ "integer": "int",
909
+ "float": "float",
910
+ "number": "float",
911
+ "bool": "bool",
912
+ "boolean": "bool",
913
+ "date": "date",
914
+ "time": "time",
915
+ "timestamp": "timestamp",
916
+ "duration": "duration",
917
+ "binary": "binary",
918
+ }
919
+ kind = mapping.get(normalized)
920
+ if kind is None:
921
+ return PrimitiveType(kind="string")
922
+ return PrimitiveType(kind=kind)
923
+
924
+
925
+ def _json_dump(value) -> str:
926
+ import json
927
+
928
+ return json.dumps(value, indent=2, ensure_ascii=False)
929
+
930
+
931
+ def render_update_audit_summary(result: UpdateResult) -> str:
932
+ return render_write_audit_summary(
933
+ provider=result.provider,
934
+ model=result.model,
935
+ validation_status="passed",
936
+ files_written=str(result.path),
937
+ inputs=f"ref={result.ref} source={result.source_path}",
938
+ diagnostics_repaired=result.diagnostics_repaired,
939
+ )
940
+
941
+
942
+ def render_write_audit_summary(
943
+ *,
944
+ provider: str,
945
+ model: str,
946
+ validation_status: str,
947
+ files_written: str,
948
+ inputs: str,
949
+ diagnostics_repaired: int,
950
+ ) -> str:
951
+ lines = [
952
+ "audit:",
953
+ f" provider: {provider}",
954
+ f" model: {model}",
955
+ f" validation: {validation_status}",
956
+ f" files_written: {files_written}",
957
+ f" inputs: {inputs}",
958
+ f" diagnostics_repaired: {diagnostics_repaired}",
959
+ ]
960
+ return "\n".join(lines)
961
+
962
+
963
+ def _build_transform_explanation(*, ref: str, target: str, is_projection: bool) -> str:
964
+ source_kind = "projection" if is_projection else "model"
965
+ target_notes = {
966
+ "json-schema": "non-optional fields become required and optional fields remain optional in the schema.",
967
+ "markdown": "the output is formatted as human-readable domain, field, source, and lineage tables.",
968
+ "typescript": "field optionality and stable interface names are preserved in the generated typings.",
969
+ "csharp": "field shapes are mapped to C# types using the native backend conventions.",
970
+ "java": "field shapes are mapped to Java types using the native backend conventions.",
971
+ "python": "field shapes are mapped to Python types using the native backend conventions.",
972
+ "rust": "field shapes are mapped to Rust types using the native backend conventions.",
973
+ "go": "field shapes are mapped to Go types using the native backend conventions.",
974
+ }
975
+ detail = target_notes.get(target, "the target emitter preserves the normalized workspace graph.")
976
+ return f"Explanation: emitted {target} for {ref} from the normalized {source_kind} graph; {detail}"