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.
- mdl_adapter_collibra/__init__.py +8 -0
- mdl_adapter_collibra/adapter.py +177 -0
- mdl_cli/__init__.py +1 -0
- mdl_cli/collab.py +291 -0
- mdl_cli/main.py +1190 -0
- mdl_cli/scaffold.py +108 -0
- mdl_core/__init__.py +51 -0
- mdl_core/commands.py +532 -0
- mdl_core/diagnostics.py +52 -0
- mdl_core/fingerprint.py +40 -0
- mdl_core/governance_import.py +78 -0
- mdl_core/ids.py +63 -0
- mdl_core/ir.py +450 -0
- mdl_core/merge.py +185 -0
- mdl_core/merge_driver.py +184 -0
- mdl_core/naming.py +137 -0
- mdl_core/patterns.py +26 -0
- mdl_core/regions.py +154 -0
- mdl_core/repo.py +147 -0
- mdl_core/state.py +99 -0
- mdl_core/validate.py +263 -0
- mdl_core/yaml_io.py +50 -0
- mdl_emit_dbt/__init__.py +31 -0
- mdl_emit_dbt/emitter.py +552 -0
- mdl_emit_dbt/macros.py +65 -0
- mdl_emit_dbt/platforms.py +258 -0
- mdl_emit_semantic/__init__.py +29 -0
- mdl_emit_semantic/import_osi.py +103 -0
- mdl_emit_semantic/joinability.py +104 -0
- mdl_emit_semantic/metricflow.py +100 -0
- mdl_emit_semantic/osi/__init__.py +21 -0
- mdl_emit_semantic/osi/v0_1_1.py +240 -0
- mdl_governance/__init__.py +52 -0
- mdl_governance/conformance.py +101 -0
- mdl_governance/graph.py +137 -0
- mdl_governance/lineage.py +65 -0
- mdl_governance/profile.py +173 -0
- mdl_governance/spi.py +122 -0
- mdl_lsp/__init__.py +5 -0
- mdl_lsp/commands.py +113 -0
- mdl_lsp/features.py +423 -0
- mdl_lsp/server.py +139 -0
- mdl_lsp/workspace.py +168 -0
- mdl_ontology/__init__.py +29 -0
- mdl_ontology/layers.py +254 -0
- mdl_ontology/lock.py +57 -0
- mdl_ontology/rdf_export.py +137 -0
- mdl_ontology/registry.py +270 -0
- mdl_reverse/__init__.py +33 -0
- mdl_reverse/drift.py +271 -0
- mdl_reverse/erwin.py +211 -0
- mdl_reverse/ledger.py +132 -0
- mdl_reverse/lifting.py +169 -0
- mdl_reverse/manifest.py +200 -0
- mdl_reverse/projection.py +122 -0
- mdl_reverse/reconcile.py +112 -0
- mdl_reverse/regions_strip.py +20 -0
- mdl_reverse/render.py +123 -0
- mdl_reverse/reverse.py +345 -0
- mdl_reverse/schema_reader.py +83 -0
- mdl_reverse/writer.py +60 -0
- mdl_server/__init__.py +10 -0
- mdl_server/app.py +193 -0
- mdl_server/commands.py +13 -0
- mdl_server/git_api.py +241 -0
- mdl_server/glossary_api.py +124 -0
- mdl_server/ontology_api.py +180 -0
- mdl_server/projection.py +199 -0
- mdl_server/static/assets/api-DzBdH2Xe.js +40 -0
- mdl_server/static/assets/main-BK8BUP3j.js +23 -0
- mdl_server/static/assets/main-D8ywi1-L.css +1 -0
- mdl_server/static/assets/sme-C4-YfiO4.js +4 -0
- mdl_server/static/assets/sme-CzQ2Ct56.css +1 -0
- mdl_server/static/index.html +14 -0
- mdl_server/static/sme.html +14 -0
- modelith_dbt-0.1.0.dist-info/METADATA +269 -0
- modelith_dbt-0.1.0.dist-info/RECORD +80 -0
- modelith_dbt-0.1.0.dist-info/WHEEL +4 -0
- modelith_dbt-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|