modelith-dbt 0.1.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.
Files changed (80) hide show
  1. mdl_adapter_collibra/__init__.py +8 -0
  2. mdl_adapter_collibra/adapter.py +177 -0
  3. mdl_cli/__init__.py +1 -0
  4. mdl_cli/collab.py +291 -0
  5. mdl_cli/main.py +1190 -0
  6. mdl_cli/scaffold.py +108 -0
  7. mdl_core/__init__.py +51 -0
  8. mdl_core/commands.py +532 -0
  9. mdl_core/diagnostics.py +52 -0
  10. mdl_core/fingerprint.py +40 -0
  11. mdl_core/governance_import.py +78 -0
  12. mdl_core/ids.py +63 -0
  13. mdl_core/ir.py +450 -0
  14. mdl_core/merge.py +185 -0
  15. mdl_core/merge_driver.py +184 -0
  16. mdl_core/naming.py +137 -0
  17. mdl_core/patterns.py +26 -0
  18. mdl_core/regions.py +154 -0
  19. mdl_core/repo.py +147 -0
  20. mdl_core/state.py +99 -0
  21. mdl_core/validate.py +263 -0
  22. mdl_core/yaml_io.py +50 -0
  23. mdl_emit_dbt/__init__.py +31 -0
  24. mdl_emit_dbt/emitter.py +552 -0
  25. mdl_emit_dbt/macros.py +65 -0
  26. mdl_emit_dbt/platforms.py +258 -0
  27. mdl_emit_semantic/__init__.py +29 -0
  28. mdl_emit_semantic/import_osi.py +103 -0
  29. mdl_emit_semantic/joinability.py +104 -0
  30. mdl_emit_semantic/metricflow.py +100 -0
  31. mdl_emit_semantic/osi/__init__.py +21 -0
  32. mdl_emit_semantic/osi/v0_1_1.py +240 -0
  33. mdl_governance/__init__.py +52 -0
  34. mdl_governance/conformance.py +101 -0
  35. mdl_governance/graph.py +137 -0
  36. mdl_governance/lineage.py +65 -0
  37. mdl_governance/profile.py +173 -0
  38. mdl_governance/spi.py +122 -0
  39. mdl_lsp/__init__.py +5 -0
  40. mdl_lsp/commands.py +113 -0
  41. mdl_lsp/features.py +423 -0
  42. mdl_lsp/server.py +139 -0
  43. mdl_lsp/workspace.py +168 -0
  44. mdl_ontology/__init__.py +29 -0
  45. mdl_ontology/layers.py +254 -0
  46. mdl_ontology/lock.py +57 -0
  47. mdl_ontology/rdf_export.py +137 -0
  48. mdl_ontology/registry.py +270 -0
  49. mdl_reverse/__init__.py +33 -0
  50. mdl_reverse/drift.py +271 -0
  51. mdl_reverse/erwin.py +211 -0
  52. mdl_reverse/ledger.py +132 -0
  53. mdl_reverse/lifting.py +169 -0
  54. mdl_reverse/manifest.py +200 -0
  55. mdl_reverse/projection.py +122 -0
  56. mdl_reverse/reconcile.py +112 -0
  57. mdl_reverse/regions_strip.py +20 -0
  58. mdl_reverse/render.py +123 -0
  59. mdl_reverse/reverse.py +345 -0
  60. mdl_reverse/schema_reader.py +83 -0
  61. mdl_reverse/writer.py +60 -0
  62. mdl_server/__init__.py +10 -0
  63. mdl_server/app.py +193 -0
  64. mdl_server/commands.py +13 -0
  65. mdl_server/git_api.py +241 -0
  66. mdl_server/glossary_api.py +124 -0
  67. mdl_server/ontology_api.py +180 -0
  68. mdl_server/projection.py +199 -0
  69. mdl_server/static/assets/api-DzBdH2Xe.js +40 -0
  70. mdl_server/static/assets/main-BK8BUP3j.js +23 -0
  71. mdl_server/static/assets/main-D8ywi1-L.css +1 -0
  72. mdl_server/static/assets/sme-C4-YfiO4.js +4 -0
  73. mdl_server/static/assets/sme-CzQ2Ct56.css +1 -0
  74. mdl_server/static/index.html +14 -0
  75. mdl_server/static/sme.html +14 -0
  76. modelith_dbt-0.1.0.dist-info/METADATA +269 -0
  77. modelith_dbt-0.1.0.dist-info/RECORD +80 -0
  78. modelith_dbt-0.1.0.dist-info/WHEEL +4 -0
  79. modelith_dbt-0.1.0.dist-info/entry_points.txt +2 -0
  80. modelith_dbt-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,8 @@
1
+ """Collibra governance adapter (spec §9.4).
2
+
3
+ Depends only on `modelith-governance` (layering §1.3): it never imports core.
4
+ """
5
+
6
+ from mdl_adapter_collibra.adapter import CollibraAdapter, CollibraTransport, MockTransport
7
+
8
+ __all__ = ["CollibraAdapter", "CollibraTransport", "MockTransport"]
@@ -0,0 +1,177 @@
1
+ """Collibra adapter implementation (spec §9.4).
2
+
3
+ - Uses the Collibra Import API with idempotent external IDs. Batch, resumable,
4
+ rate-limit aware.
5
+ - `plan` never writes: it diffs the mapped graph against what the catalog already
6
+ has (fetched read-only) and returns a SyncPlan.
7
+ - `apply` refuses a plan it did not produce (signature check) and executes the
8
+ Import API calls in batches through a Transport.
9
+ - `pull` reads governance-owned fields (steward, classification, sensitivity,
10
+ retention) back into a WritebackSet.
11
+
12
+ Transport is injectable: `CollibraTransport` is the real HTTP client (thin,
13
+ requests-based); `MockTransport` records calls for tests and dry-runs so the whole
14
+ plan/apply/pull path is exercised without a live tenant.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from typing import Protocol
21
+
22
+ from mdl_governance import (
23
+ AdapterCapabilities,
24
+ ForeignPlanError,
25
+ GovernanceGraph,
26
+ Profile,
27
+ SyncPlan,
28
+ SyncResult,
29
+ WritebackSet,
30
+ WritebackValue,
31
+ build_changes,
32
+ map_graph,
33
+ )
34
+ from mdl_governance.spi import ChangeType
35
+
36
+ ADAPTER_NAME = "collibra"
37
+ _BATCH_SIZE = 200
38
+
39
+
40
+ class Transport(Protocol):
41
+ def existing_external_ids(self) -> set[str]: ...
42
+ def import_batch(self, payload: list[dict]) -> None: ...
43
+ def read_attributes(self, attribute_names: list[str]) -> list[dict]: ...
44
+
45
+
46
+ @dataclass
47
+ class MockTransport:
48
+ """In-memory transport for tests / dry-run. Records everything."""
49
+
50
+ preexisting: set[str] = field(default_factory=set)
51
+ imported_batches: list[list[dict]] = field(default_factory=list)
52
+ writeback_rows: list[dict] = field(default_factory=list)
53
+
54
+ def existing_external_ids(self) -> set[str]:
55
+ return set(self.preexisting)
56
+
57
+ def import_batch(self, payload: list[dict]) -> None:
58
+ self.imported_batches.append(payload)
59
+
60
+ def read_attributes(self, attribute_names: list[str]) -> list[dict]:
61
+ return list(self.writeback_rows)
62
+
63
+ @property
64
+ def imported_count(self) -> int:
65
+ return sum(len(b) for b in self.imported_batches)
66
+
67
+
68
+ class CollibraTransport:
69
+ """Real Collibra Import API transport (thin). Kept import-light so the adapter
70
+ package has no hard dependency on requests unless actually used against a tenant."""
71
+
72
+ def __init__(self, base_url: str, token: str) -> None:
73
+ self.base_url = base_url.rstrip("/")
74
+ self.token = token
75
+
76
+ def _session(self):
77
+ import requests # local import: only needed for live calls
78
+
79
+ s = requests.Session()
80
+ s.headers["Authorization"] = f"Bearer {self.token}"
81
+ return s
82
+
83
+ def existing_external_ids(self) -> set[str]: # pragma: no cover - needs live tenant
84
+ s = self._session()
85
+ r = s.get(f"{self.base_url}/rest/2.0/assets", params={"limit": 1000})
86
+ r.raise_for_status()
87
+ return {a.get("externalId") for a in r.json().get("results", []) if a.get("externalId")}
88
+
89
+ def import_batch(self, payload: list[dict]) -> None: # pragma: no cover - live tenant
90
+ s = self._session()
91
+ r = s.post(f"{self.base_url}/rest/2.0/import/json-job", json=payload)
92
+ r.raise_for_status()
93
+
94
+ def read_attributes(self, attribute_names: list[str]) -> list[dict]: # pragma: no cover
95
+ s = self._session()
96
+ r = s.get(f"{self.base_url}/rest/2.0/attributes", params={"names": attribute_names})
97
+ r.raise_for_status()
98
+ return r.json().get("results", [])
99
+
100
+
101
+ @dataclass
102
+ class CollibraAdapter:
103
+ transport: Transport
104
+ community: str = "Data Governance Council"
105
+
106
+ def capabilities(self) -> AdapterCapabilities:
107
+ return AdapterCapabilities(
108
+ name=ADAPTER_NAME, supports_writeback=True, supports_lineage=True, batch=True
109
+ )
110
+
111
+ def plan(self, graph: GovernanceGraph, profile: Profile) -> SyncPlan:
112
+ """Diff the mapped graph against the catalog. NEVER writes (§9.3)."""
113
+ mapped = map_graph(graph, profile)
114
+ existing = self.transport.existing_external_ids()
115
+ changes = build_changes(mapped.assets, existing)
116
+ plan = SyncPlan(adapter=ADAPTER_NAME, profile_name=profile.profile, changes=changes)
117
+ plan.signature = plan.compute_signature()
118
+ return plan
119
+
120
+ def apply(self, plan: SyncPlan) -> SyncResult:
121
+ """Execute a plan this adapter produced. Refuses foreign/edited plans (§9.3)."""
122
+ if plan.adapter != ADAPTER_NAME:
123
+ raise ForeignPlanError(f"plan is for adapter {plan.adapter!r}, not {ADAPTER_NAME!r}")
124
+ if plan.signature != plan.compute_signature():
125
+ raise ForeignPlanError("plan signature mismatch — plan was edited after generation")
126
+
127
+ created = updated = 0
128
+ batch: list[dict] = []
129
+ for change in plan.changes:
130
+ if change.change == ChangeType.noop:
131
+ continue
132
+ batch.append(_to_import_json(change, self.community))
133
+ if change.change == ChangeType.create:
134
+ created += 1
135
+ else:
136
+ updated += 1
137
+ if len(batch) >= _BATCH_SIZE:
138
+ self.transport.import_batch(batch)
139
+ batch = []
140
+ if batch:
141
+ self.transport.import_batch(batch)
142
+
143
+ return SyncResult(applied=created + updated, created=created, updated=updated)
144
+
145
+ def pull(self, profile: Profile) -> WritebackSet:
146
+ """Read governance-owned fields back into the model (§9.4 writeback loop)."""
147
+ wb = WritebackSet()
148
+ if not profile.writeback:
149
+ return wb
150
+ names = [w.external_attribute for w in profile.writeback]
151
+ rows = self.transport.read_attributes(names)
152
+ by_attr = {w.external_attribute: w.model_path for w in profile.writeback}
153
+ for row in rows:
154
+ attr = row.get("attribute")
155
+ ext = row.get("externalId") or row.get("external_id")
156
+ path = by_attr.get(attr)
157
+ if path and ext:
158
+ wb.values.append(
159
+ WritebackValue(external_id=ext, model_path=path, value=row.get("value"))
160
+ )
161
+ return wb
162
+
163
+
164
+ def _to_import_json(change, community: str) -> dict:
165
+ """Shape a PlannedChange into a Collibra Import API JSON row (idempotent by
166
+ externalId, §9.4)."""
167
+ return {
168
+ "resourceType": "Asset",
169
+ "identifier": {"name": change.name, "community": {"name": community}},
170
+ "externalId": change.external_id,
171
+ "type": {"name": change.target_type},
172
+ "attributes": {k: [{"value": v}] for k, v in change.attributes.items()},
173
+ "relations": [
174
+ {"type": rel_type, "target": {"externalId": target}}
175
+ for rel_type, target in change.relations
176
+ ],
177
+ }
mdl_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Modelith CLI package."""
mdl_cli/collab.py ADDED
@@ -0,0 +1,291 @@
1
+ """Collaboration-model mechanics (docs/collaboration-model.md).
2
+
3
+ - classify_paths: §4 change routes A–E from the paths a PR touches
4
+ - debt ledger: §7 the valve that stops the gate becoming a blocker
5
+ - ensure_git_hooks: §6.1 semantic merge driver wiring
6
+ - scaffold_workspace: §2.1 one repo, two sibling roots
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as _dt
12
+ import subprocess
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ from mdl_core.yaml_io import dump_str, load_str
17
+
18
+ # --- §4 change routes ---------------------------------------------------------
19
+
20
+ _ROUTE_META = {
21
+ "A": {
22
+ "name": "Meaning",
23
+ "reviewers": ["data-stewards"],
24
+ "gates": ["mdl validate", "mdl ontology check"],
25
+ },
26
+ "B": {
27
+ "name": "Structure",
28
+ "reviewers": ["data-architects", "analytics-engineers"],
29
+ "gates": ["mdl validate", "mdl generate --dry-run", "mdl drift --check"],
30
+ },
31
+ "C": {
32
+ "name": "Implementation",
33
+ "reviewers": ["analytics-engineers"],
34
+ "gates": ["mdl drift --check"],
35
+ },
36
+ "E": {
37
+ "name": "Governance",
38
+ "reviewers": ["data-governance", "data-architects"],
39
+ "gates": ["mdl gov conformance", "mdl gov plan"],
40
+ },
41
+ }
42
+ # Precedence when a PR spans routes: the strictest gate wins the headline.
43
+ _PRECEDENCE = ["B", "E", "C", "A"]
44
+
45
+
46
+ @dataclass
47
+ class Classification:
48
+ routes: list[str] = field(default_factory=list)
49
+ primary: str | None = None
50
+ gates: list[str] = field(default_factory=list)
51
+ reviewers: list[str] = field(default_factory=list)
52
+ unmatched: list[str] = field(default_factory=list)
53
+
54
+ def to_dict(self) -> dict:
55
+ return {
56
+ "routes": self.routes,
57
+ "primary": self.primary,
58
+ "primary_name": _ROUTE_META[self.primary]["name"] if self.primary else None,
59
+ "gates": self.gates,
60
+ "reviewers": self.reviewers,
61
+ "unmatched": self.unmatched,
62
+ }
63
+
64
+
65
+ def classify_paths(
66
+ paths: list[str], *, model_root: str = "model", transform_root: str = "transform"
67
+ ) -> Classification:
68
+ routes: set[str] = set()
69
+ unmatched: list[str] = []
70
+ m = model_root.rstrip("/")
71
+ t = transform_root.rstrip("/")
72
+ for p in paths:
73
+ p = p.strip().lstrip("./")
74
+ if not p:
75
+ continue
76
+ if p.startswith((f"{m}/conceptual/",)):
77
+ routes.add("A")
78
+ elif p.startswith((f"{m}/logical/", f"{m}/patterns/")):
79
+ routes.add("B")
80
+ elif p.startswith((f"{t}/", f"{m}/physical/", f"{m}/semantic/")):
81
+ routes.add("C")
82
+ elif (
83
+ p.endswith("governance-profile.yaml")
84
+ or p.endswith(".mdl/lock.yaml")
85
+ or p.endswith(f"{m}/mdl-project.yaml")
86
+ or p.endswith("mdl-project.yaml")
87
+ ):
88
+ routes.add("E")
89
+ elif ".mdl/state/" in p or ".mdl/decisions.yaml" in p or ".mdl/debt.yaml" in p:
90
+ continue # bot/tool-owned state rides along with whatever else changed
91
+ else:
92
+ unmatched.append(p)
93
+
94
+ ordered = [r for r in _PRECEDENCE if r in routes]
95
+ gates: list[str] = []
96
+ reviewers: list[str] = []
97
+ for r in ordered:
98
+ for g in _ROUTE_META[r]["gates"]:
99
+ if g not in gates:
100
+ gates.append(g)
101
+ for rv in _ROUTE_META[r]["reviewers"]:
102
+ if rv not in reviewers:
103
+ reviewers.append(rv)
104
+ return Classification(
105
+ routes=sorted(routes),
106
+ primary=ordered[0] if ordered else None,
107
+ gates=gates,
108
+ reviewers=reviewers,
109
+ unmatched=unmatched,
110
+ )
111
+
112
+
113
+ def changed_paths(base: str) -> list[str]:
114
+ proc = subprocess.run(
115
+ ["git", "diff", "--name-only", f"{base}...HEAD"],
116
+ capture_output=True,
117
+ text=True,
118
+ timeout=30,
119
+ )
120
+ if proc.returncode != 0:
121
+ raise RuntimeError(proc.stderr.strip() or "git diff failed")
122
+ return [line for line in proc.stdout.splitlines() if line.strip()]
123
+
124
+
125
+ # --- §7 the debt valve ---------------------------------------------------------
126
+
127
+ DEBT_REL = ".mdl/debt.yaml"
128
+
129
+
130
+ def add_debt(model_dir: Path, entity: str, reason: str, expires_days: int) -> dict:
131
+ p = model_dir / DEBT_REL
132
+ doc = load_str(p.read_text(encoding="utf-8")) if p.exists() else None
133
+ doc = doc or {"debt": []}
134
+ today = _dt.date.today()
135
+ entry = {
136
+ "entity": entity,
137
+ "reason": reason,
138
+ "created": today.isoformat(),
139
+ "expires": (today + _dt.timedelta(days=expires_days)).isoformat(),
140
+ }
141
+ doc["debt"] = [d for d in (doc.get("debt") or []) if d.get("entity") != entity]
142
+ doc["debt"].append(entry)
143
+ p.parent.mkdir(parents=True, exist_ok=True)
144
+ p.write_text(dump_str(doc), encoding="utf-8")
145
+ return entry
146
+
147
+
148
+ def load_debt(model_dir: Path) -> list[dict]:
149
+ p = model_dir / DEBT_REL
150
+ if not p.exists():
151
+ return []
152
+ doc = load_str(p.read_text(encoding="utf-8")) or {}
153
+ return list(doc.get("debt") or [])
154
+
155
+
156
+ def expired_debt(model_dir: Path) -> list[dict]:
157
+ today = _dt.date.today().isoformat()
158
+ return [d for d in load_debt(model_dir) if str(d.get("expires", "")) < today]
159
+
160
+
161
+ # --- §6.1 merge-driver wiring ---------------------------------------------------
162
+
163
+ _GITATTRIBUTES_LINES = [
164
+ "{model}/**/*.yaml merge=mdl",
165
+ "**/.mdl/decisions.yaml merge=mdl",
166
+ "**/.mdl/state/**/*.json merge=mdl-state",
167
+ ]
168
+
169
+
170
+ def ensure_git_hooks(repo_root: Path, model_root: str = "model") -> list[str]:
171
+ """Idempotently wire .gitattributes + the git merge-driver config."""
172
+ actions: list[str] = []
173
+ ga = repo_root / ".gitattributes"
174
+ existing = ga.read_text(encoding="utf-8") if ga.exists() else ""
175
+ additions = []
176
+ for line in _GITATTRIBUTES_LINES:
177
+ line = line.format(model=model_root.rstrip("/"))
178
+ if line not in existing:
179
+ additions.append(line)
180
+ if additions:
181
+ body = existing.rstrip("\n") + ("\n" if existing else "") + "\n".join(additions)
182
+ ga.write_text(body + "\n")
183
+ actions.append(f".gitattributes += {len(additions)} rule(s)")
184
+
185
+ def _git(*args: str) -> bool:
186
+ return (
187
+ subprocess.run(
188
+ ["git", "-C", str(repo_root), *args], capture_output=True, timeout=15
189
+ ).returncode
190
+ == 0
191
+ )
192
+
193
+ if _git("rev-parse", "--git-dir"):
194
+ _git("config", "merge.mdl.name", "Modelith semantic model merge")
195
+ _git("config", "merge.mdl.driver", "mdl merge-driver %O %A %B")
196
+ _git("config", "merge.mdl-state.name", "Modelith generation-state merge")
197
+ _git("config", "merge.mdl-state.driver", "mdl merge-driver --state %O %A %B")
198
+ actions.append("git merge drivers configured (merge.mdl, merge.mdl-state)")
199
+ else:
200
+ actions.append("not a git repo — .gitattributes written, run again after `git init`")
201
+ return actions
202
+
203
+
204
+ # --- §2.1 workspace scaffold -----------------------------------------------------
205
+
206
+ _CODEOWNERS = """\
207
+ # Ownership mirrors the layer stack (collaboration model §3).
208
+ # Replace the placeholder teams with your org's.
209
+
210
+ # Conceptual: the business owns meaning
211
+ /{model}/conceptual/terms/ @data-stewards
212
+ /{model}/conceptual/entities/ @data-architects @data-stewards
213
+ /{model}/conceptual/subject-areas/ @data-architects
214
+
215
+ # Logical: architecture owns structure
216
+ /{model}/logical/ @data-architects
217
+ /{model}/patterns/ @data-architects
218
+
219
+ # Physical + semantic: shared, architects hold the contract
220
+ /{model}/physical/ @analytics-engineers @data-architects
221
+ /{model}/semantic/ @data-architects @analytics-engineers
222
+
223
+ # Transformation code: engineering owns implementation
224
+ /transform/ @analytics-engineers
225
+
226
+ # Governance mapping and ontology: central, high blast radius
227
+ /governance-profile.yaml @data-governance @data-architects
228
+ /ontologies/ @data-architects
229
+ /{model}/mdl-project.yaml @data-architects
230
+ /{model}/.mdl/lock.yaml @data-architects
231
+
232
+ # Generated state: tool-owned, humans should rarely touch
233
+ /transform/**/.mdl/state/ @data-platform
234
+ """
235
+
236
+ _WORKSPACE = """\
237
+ {{
238
+ "folders": [
239
+ {{ "name": "model", "path": "{model}" }},
240
+ {{ "name": "transform", "path": "transform/warehouse" }},
241
+ {{ "name": "repo", "path": "." }}
242
+ ],
243
+ "settings": {{
244
+ "modelith.modelDir": "{model}",
245
+ "modelith.dbtProjectDir": "transform/warehouse"
246
+ }}
247
+ }}
248
+ """
249
+
250
+ _DBT_PROJECT_STUB = """\
251
+ name: warehouse
252
+ version: "1.0.0"
253
+ profile: warehouse
254
+ model-paths: ["models"]
255
+ macro-paths: ["macros"]
256
+ target-path: target
257
+ """
258
+
259
+
260
+ def scaffold_workspace(root: Path, project_name: str, scaffold_model) -> list[str]:
261
+ """One repo, two sibling roots (§2.1): model/ + transform/warehouse/, with
262
+ the collaboration plumbing (CODEOWNERS, .code-workspace, merge driver,
263
+ classify CI). `scaffold_model` is the existing model scaffolder."""
264
+ written: list[str] = []
265
+ model_root = "model" # §2.1 naming caution: singular removes the ambiguity
266
+
267
+ scaffold_model(root / model_root, project_name=project_name)
268
+ written.append(f"{model_root}/ (model repo)")
269
+
270
+ tw = root / "transform" / "warehouse"
271
+ (tw / "models" / "staging").mkdir(parents=True, exist_ok=True)
272
+ (tw / "macros").mkdir(parents=True, exist_ok=True)
273
+ if not (tw / "dbt_project.yml").exists():
274
+ (tw / "dbt_project.yml").write_text(_DBT_PROJECT_STUB)
275
+ written.append("transform/warehouse/ (dbt project)")
276
+
277
+ (root / "ontologies" / "industry").mkdir(parents=True, exist_ok=True)
278
+
279
+ gh = root / ".github"
280
+ gh.mkdir(exist_ok=True)
281
+ if not (gh / "CODEOWNERS").exists():
282
+ (gh / "CODEOWNERS").write_text(_CODEOWNERS.format(model=model_root))
283
+ written.append(".github/CODEOWNERS")
284
+
285
+ ws = root / f"{project_name}.code-workspace"
286
+ if not ws.exists():
287
+ ws.write_text(_WORKSPACE.format(model=model_root))
288
+ written.append(ws.name)
289
+
290
+ written += ensure_git_hooks(root, model_root)
291
+ return written