metabase-sync 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.
- metabase_sync/__init__.py +1 -0
- metabase_sync/__main__.py +4 -0
- metabase_sync/apply/__init__.py +30 -0
- metabase_sync/apply/_cards.py +261 -0
- metabase_sync/apply/_collections.py +99 -0
- metabase_sync/apply/_dashboards.py +257 -0
- metabase_sync/apply/_pulses.py +183 -0
- metabase_sync/apply/_runner.py +249 -0
- metabase_sync/apply/_shared.py +107 -0
- metabase_sync/apply/_snippets.py +81 -0
- metabase_sync/cli.py +354 -0
- metabase_sync/client.py +222 -0
- metabase_sync/diff.py +104 -0
- metabase_sync/errors.py +33 -0
- metabase_sync/export.py +206 -0
- metabase_sync/models.py +154 -0
- metabase_sync/plan.py +135 -0
- metabase_sync/serialize/__init__.py +0 -0
- metabase_sync/serialize/cards.py +184 -0
- metabase_sync/serialize/collections.py +93 -0
- metabase_sync/serialize/dashboards.py +132 -0
- metabase_sync/serialize/databases.py +37 -0
- metabase_sync/serialize/paths.py +106 -0
- metabase_sync/serialize/pulses.py +123 -0
- metabase_sync/serialize/snippets.py +91 -0
- metabase_sync/serialize/version.py +56 -0
- metabase_sync/serialize/yamlio.py +91 -0
- metabase_sync/settings.py +35 -0
- metabase_sync-0.1.0.dist-info/METADATA +315 -0
- metabase_sync-0.1.0.dist-info/RECORD +33 -0
- metabase_sync-0.1.0.dist-info/WHEEL +4 -0
- metabase_sync-0.1.0.dist-info/entry_points.txt +2 -0
- metabase_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""`metabase-sync apply` orchestration.
|
|
2
|
+
|
|
3
|
+
Public entry point is `run()`. The rest of this package is internal: each
|
|
4
|
+
`_<resource>.py` module contains a single `apply_<resource>(ctx)` function
|
|
5
|
+
that mutates the plan and (in apply mode) calls the Metabase API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from metabase_sync.diff import build_remote_index
|
|
9
|
+
|
|
10
|
+
from ._pulses import _build_pulse_payload # re-exported for unit tests
|
|
11
|
+
from ._runner import (
|
|
12
|
+
_concurrency_check,
|
|
13
|
+
_database_preflight,
|
|
14
|
+
_reference_preflight,
|
|
15
|
+
run,
|
|
16
|
+
)
|
|
17
|
+
from ._shared import ApplyContext
|
|
18
|
+
from ._shared import resolve_card_path as _resolve_card_path
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ApplyContext",
|
|
22
|
+
"run",
|
|
23
|
+
"build_remote_index",
|
|
24
|
+
# Internals re-exported for test access only — not part of the supported API.
|
|
25
|
+
"_build_pulse_payload",
|
|
26
|
+
"_concurrency_check",
|
|
27
|
+
"_database_preflight",
|
|
28
|
+
"_reference_preflight",
|
|
29
|
+
"_resolve_card_path",
|
|
30
|
+
]
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Apply cards — native + GUI.
|
|
2
|
+
|
|
3
|
+
Two interesting bits:
|
|
4
|
+
|
|
5
|
+
* `dataset_query` is rebuilt from disk: SQL body for native cards, the stored
|
|
6
|
+
full dataset_query dict for GUI cards (we accept either classic or MBQL5).
|
|
7
|
+
* `result_metadata` is reset to `[]` only when the dataset_query itself
|
|
8
|
+
changed. For cosmetic edits (display, viz settings) we pass the server's
|
|
9
|
+
existing metadata through so Metabase doesn't re-run the underlying query.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import difflib
|
|
15
|
+
import json
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from metabase_sync.plan import Change
|
|
21
|
+
from metabase_sync.serialize.cards import read_card_file
|
|
22
|
+
from metabase_sync.serialize.yamlio import write_frontmatter_sql, write_yaml
|
|
23
|
+
|
|
24
|
+
from ._shared import (
|
|
25
|
+
ApplyContext,
|
|
26
|
+
normalize,
|
|
27
|
+
normalize_dataset_query,
|
|
28
|
+
summarize_diffs,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Diff field labels that indicate the dataset_query itself changed, so we
|
|
32
|
+
# must reset result_metadata. Anything else is metadata-only.
|
|
33
|
+
_DATASET_QUERY_DIFF_FIELDS = frozenset(
|
|
34
|
+
{"SQL", "query_type", "MBQL query structure", "template_tags"}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def apply_cards(ctx: ApplyContext) -> None:
|
|
39
|
+
for path, fm, body, gui_query in _iter_card_files(ctx.state_dir):
|
|
40
|
+
relpath = str(path.relative_to(ctx.state_dir))
|
|
41
|
+
eid = fm.get("entity_id")
|
|
42
|
+
remote_card = ctx.remote.cards_by_entity.get(eid) if eid else None
|
|
43
|
+
collection_id = _resolve_collection_for_card(
|
|
44
|
+
path, ctx.collection_id_by_disk_path
|
|
45
|
+
)
|
|
46
|
+
db_name = fm.get("database")
|
|
47
|
+
db_id = ctx.db_id_by_name.get(db_name) if db_name else None
|
|
48
|
+
dataset_query = _build_dataset_query(fm, body, gui_query, db_id)
|
|
49
|
+
|
|
50
|
+
desired: dict[str, Any] = {
|
|
51
|
+
"name": fm["name"],
|
|
52
|
+
"description": fm.get("description"),
|
|
53
|
+
"type": fm.get("type", "question"),
|
|
54
|
+
"display": fm.get("display", "table"),
|
|
55
|
+
"visualization_settings": fm.get("visualization_settings", {}),
|
|
56
|
+
"parameters": fm.get("parameters", []),
|
|
57
|
+
"collection_id": collection_id,
|
|
58
|
+
"database_id": db_id,
|
|
59
|
+
"dataset_query": dataset_query,
|
|
60
|
+
"enable_embedding": fm.get("enable_embedding", False),
|
|
61
|
+
"embedding_params": fm.get("embedding_params"),
|
|
62
|
+
"cache_ttl": fm.get("cache_ttl"),
|
|
63
|
+
"archived": fm.get("archived", False),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
is_model = fm.get("type") == "model"
|
|
67
|
+
|
|
68
|
+
if remote_card is None:
|
|
69
|
+
# New card: ship stored result_metadata for models so curated
|
|
70
|
+
# column descriptions land on first apply; let Metabase recompute
|
|
71
|
+
# for ordinary questions.
|
|
72
|
+
desired["result_metadata"] = (
|
|
73
|
+
list(fm.get("result_metadata") or []) if is_model else []
|
|
74
|
+
)
|
|
75
|
+
ctx.plan.add(
|
|
76
|
+
Change(
|
|
77
|
+
resource="cards",
|
|
78
|
+
action="create",
|
|
79
|
+
relpath=relpath,
|
|
80
|
+
name=fm["name"],
|
|
81
|
+
summary=_create_summary(fm, body),
|
|
82
|
+
details={"display": desired["display"], "database": db_name},
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
if ctx.mode == "apply":
|
|
86
|
+
created = ctx.client.post("/api/card", desired)
|
|
87
|
+
ctx.card_id_by_disk_path[path.resolve()] = int(created["id"])
|
|
88
|
+
fm["entity_id"] = created.get("entity_id")
|
|
89
|
+
_rewrite_card_file(path, fm, body, gui_query)
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
ctx.card_id_by_disk_path[path.resolve()] = int(remote_card["id"])
|
|
93
|
+
diffs = _diff_card(desired, remote_card)
|
|
94
|
+
if diffs:
|
|
95
|
+
dq_changed = any(d[0] in _DATASET_QUERY_DIFF_FIELDS for d in diffs)
|
|
96
|
+
if is_model:
|
|
97
|
+
# Never wipe a model's curated result_metadata. If the user
|
|
98
|
+
# has stored an updated copy on disk, prefer it; otherwise
|
|
99
|
+
# pass the remote's existing one through.
|
|
100
|
+
desired["result_metadata"] = list(
|
|
101
|
+
fm.get("result_metadata")
|
|
102
|
+
or remote_card.get("result_metadata")
|
|
103
|
+
or []
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
desired["result_metadata"] = (
|
|
107
|
+
[] if dq_changed else remote_card.get("result_metadata") or []
|
|
108
|
+
)
|
|
109
|
+
ctx.plan.add(
|
|
110
|
+
Change(
|
|
111
|
+
resource="cards",
|
|
112
|
+
action="update",
|
|
113
|
+
relpath=relpath,
|
|
114
|
+
name=fm["name"],
|
|
115
|
+
summary=summarize_diffs(diffs),
|
|
116
|
+
details={"changes": diffs},
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
if ctx.mode == "apply":
|
|
120
|
+
ctx.client.put(f"/api/card/{remote_card['id']}", desired)
|
|
121
|
+
else:
|
|
122
|
+
ctx.plan.add(
|
|
123
|
+
Change(
|
|
124
|
+
resource="cards", action="skip", relpath=relpath, name=fm["name"]
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- iteration + path resolution -----------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _iter_card_files(
|
|
133
|
+
state_dir: Path,
|
|
134
|
+
) -> Iterator[tuple[Path, dict, str | None, dict | None]]:
|
|
135
|
+
root = state_dir / "collections"
|
|
136
|
+
if not root.exists():
|
|
137
|
+
return
|
|
138
|
+
for path in sorted(root.rglob("cards/*.sql")):
|
|
139
|
+
fm, body, gui = read_card_file(path)
|
|
140
|
+
yield path, fm, body, gui
|
|
141
|
+
for path in sorted(root.rglob("cards/*.yaml")):
|
|
142
|
+
fm, body, gui = read_card_file(path)
|
|
143
|
+
yield path, fm, body, gui
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_collection_for_card(
|
|
147
|
+
card_path: Path, collection_id_by_disk_path: dict[Path, int]
|
|
148
|
+
) -> int | None:
|
|
149
|
+
cards_dir = card_path.parent
|
|
150
|
+
parent = cards_dir.parent
|
|
151
|
+
# Dashboard-internal card path: <collection>/dashboards/<dash>/cards/<card>.sql
|
|
152
|
+
if parent.parent.name == "dashboards":
|
|
153
|
+
return collection_id_by_disk_path.get(parent.parent.parent.resolve())
|
|
154
|
+
return collection_id_by_disk_path.get(parent.resolve())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- dataset_query build + write-back ------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_dataset_query(
|
|
161
|
+
fm: dict[str, Any],
|
|
162
|
+
body: str | None,
|
|
163
|
+
gui_query: dict[str, Any] | None,
|
|
164
|
+
db_id: int | None,
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
if body is not None:
|
|
167
|
+
return {
|
|
168
|
+
"database": db_id,
|
|
169
|
+
"type": "native",
|
|
170
|
+
"native": {
|
|
171
|
+
"query": body,
|
|
172
|
+
"template-tags": fm.get("template_tags") or {},
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
dq = dict(gui_query or {})
|
|
176
|
+
dq["database"] = db_id
|
|
177
|
+
return dq
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _rewrite_card_file(
|
|
181
|
+
path: Path, fm: dict[str, Any], body: str | None, gui_query: dict[str, Any] | None
|
|
182
|
+
) -> None:
|
|
183
|
+
if body is not None:
|
|
184
|
+
write_frontmatter_sql(path, fm, body)
|
|
185
|
+
else:
|
|
186
|
+
write_yaml(path, fm | {"query": gui_query or {}})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- diff ---------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _diff_card(
|
|
193
|
+
desired: dict[str, Any], remote: dict[str, Any]
|
|
194
|
+
) -> list[tuple[str, Any, Any]]:
|
|
195
|
+
diffs: list[tuple[str, Any, Any]] = []
|
|
196
|
+
for k in ("name", "description", "display", "collection_id", "archived"):
|
|
197
|
+
if desired.get(k) != remote.get(k):
|
|
198
|
+
diffs.append((k, remote.get(k), desired.get(k)))
|
|
199
|
+
remote_legacy = remote.get("legacy_query")
|
|
200
|
+
if remote_legacy:
|
|
201
|
+
try:
|
|
202
|
+
remote_dq = json.loads(remote_legacy)
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
diffs.append(("dataset_query", "<unparseable>", "<rebuilt>"))
|
|
205
|
+
return diffs
|
|
206
|
+
if normalize_dataset_query(desired["dataset_query"]) != normalize_dataset_query(
|
|
207
|
+
remote_dq
|
|
208
|
+
):
|
|
209
|
+
diffs.extend(_dataset_query_diffs(remote_dq, desired["dataset_query"]))
|
|
210
|
+
return diffs
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _dataset_query_diffs(
|
|
214
|
+
remote_dq: dict[str, Any], desired_dq: dict[str, Any]
|
|
215
|
+
) -> list[tuple[str, Any, Any]]:
|
|
216
|
+
"""Break a dataset_query change into specific sub-diffs so the plan report
|
|
217
|
+
is useful (e.g. 'SQL: 76 → 78 lines, +2 −0' rather than '<old> → <new>')."""
|
|
218
|
+
diffs: list[tuple[str, Any, Any]] = []
|
|
219
|
+
if remote_dq.get("type") != desired_dq.get("type"):
|
|
220
|
+
diffs.append(("query_type", remote_dq.get("type"), desired_dq.get("type")))
|
|
221
|
+
return diffs
|
|
222
|
+
|
|
223
|
+
if desired_dq.get("type") == "native":
|
|
224
|
+
remote_native = remote_dq.get("native") or {}
|
|
225
|
+
desired_native = desired_dq.get("native") or {}
|
|
226
|
+
remote_sql = remote_native.get("query") or ""
|
|
227
|
+
desired_sql = desired_native.get("query") or ""
|
|
228
|
+
if remote_sql != desired_sql:
|
|
229
|
+
diffs.append(("SQL", _sql_change_summary(remote_sql, desired_sql), None))
|
|
230
|
+
if (remote_native.get("template-tags") or {}) != (
|
|
231
|
+
desired_native.get("template-tags") or {}
|
|
232
|
+
):
|
|
233
|
+
r = list((remote_native.get("template-tags") or {}).keys())
|
|
234
|
+
d = list((desired_native.get("template-tags") or {}).keys())
|
|
235
|
+
diffs.append(("template_tags", r, d))
|
|
236
|
+
else:
|
|
237
|
+
remote_q = remote_dq.get("query") or {}
|
|
238
|
+
desired_q = desired_dq.get("query") or {}
|
|
239
|
+
if normalize(remote_q) != normalize(desired_q):
|
|
240
|
+
diffs.append(("MBQL query structure", "(changed)", None))
|
|
241
|
+
return diffs
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _sql_change_summary(before: str, after: str) -> str:
|
|
245
|
+
before_lines = before.splitlines()
|
|
246
|
+
after_lines = after.splitlines()
|
|
247
|
+
added = removed = 0
|
|
248
|
+
for line in difflib.ndiff(before_lines, after_lines):
|
|
249
|
+
if line.startswith("+ "):
|
|
250
|
+
added += 1
|
|
251
|
+
elif line.startswith("- "):
|
|
252
|
+
removed += 1
|
|
253
|
+
return f"{len(before_lines)} → {len(after_lines)} lines, +{added} −{removed}"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _create_summary(fm: dict[str, Any], body: str | None) -> str:
|
|
257
|
+
base = fm.get("name", "")
|
|
258
|
+
if body is not None:
|
|
259
|
+
preview = body.strip().split("\n", 1)[0][:60]
|
|
260
|
+
return f"{base} — {preview}"
|
|
261
|
+
return f"{base} (GUI)"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Apply collections — POST new, PUT changed, persist server-allocated entity_ids."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from metabase_sync.diff import RemoteIndex
|
|
8
|
+
from metabase_sync.plan import Change
|
|
9
|
+
from metabase_sync.serialize.collections import read_collections
|
|
10
|
+
from metabase_sync.serialize.yamlio import write_yaml
|
|
11
|
+
|
|
12
|
+
from ._shared import ApplyContext, diff_fields, summarize_diffs
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apply_collections(ctx: ApplyContext) -> None:
|
|
16
|
+
for directory, manifest in read_collections(ctx.state_dir):
|
|
17
|
+
if directory.name == "root" and directory.parent.name == "collections":
|
|
18
|
+
continue
|
|
19
|
+
parent_dir = directory.parent
|
|
20
|
+
parent_id: int | None = (
|
|
21
|
+
ctx.collection_id_by_disk_path.get(parent_dir.resolve())
|
|
22
|
+
if parent_dir.name != "collections"
|
|
23
|
+
else None
|
|
24
|
+
)
|
|
25
|
+
relpath = str(directory.relative_to(ctx.state_dir))
|
|
26
|
+
|
|
27
|
+
eid = manifest.get("entity_id")
|
|
28
|
+
remote_collection = (
|
|
29
|
+
ctx.remote.collections_by_entity.get(eid)
|
|
30
|
+
if eid
|
|
31
|
+
else _find_by_name_and_parent(ctx.remote, parent_id, manifest["name"])
|
|
32
|
+
)
|
|
33
|
+
desired = {
|
|
34
|
+
"name": manifest["name"],
|
|
35
|
+
"description": manifest.get("description"),
|
|
36
|
+
"parent_id": parent_id,
|
|
37
|
+
"authority_level": manifest.get("authority_level"),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if remote_collection is None:
|
|
41
|
+
ctx.plan.add(
|
|
42
|
+
Change(
|
|
43
|
+
resource="collections",
|
|
44
|
+
action="create",
|
|
45
|
+
relpath=relpath,
|
|
46
|
+
name=manifest["name"],
|
|
47
|
+
summary=manifest["name"],
|
|
48
|
+
details={"desired": desired},
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
if ctx.mode == "apply":
|
|
52
|
+
created = ctx.client.post("/api/collection", desired)
|
|
53
|
+
ctx.collection_id_by_disk_path[directory.resolve()] = int(created["id"])
|
|
54
|
+
manifest["entity_id"] = created.get("entity_id")
|
|
55
|
+
write_yaml(directory / "_collection.yaml", manifest)
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
ctx.collection_id_by_disk_path[directory.resolve()] = int(
|
|
59
|
+
remote_collection["id"]
|
|
60
|
+
)
|
|
61
|
+
diffs = diff_fields(
|
|
62
|
+
desired,
|
|
63
|
+
remote_collection,
|
|
64
|
+
("name", "description", "parent_id", "authority_level"),
|
|
65
|
+
)
|
|
66
|
+
if diffs:
|
|
67
|
+
ctx.plan.add(
|
|
68
|
+
Change(
|
|
69
|
+
resource="collections",
|
|
70
|
+
action="update",
|
|
71
|
+
relpath=relpath,
|
|
72
|
+
name=manifest["name"],
|
|
73
|
+
summary=summarize_diffs(diffs),
|
|
74
|
+
details={"changes": diffs},
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
if ctx.mode == "apply":
|
|
78
|
+
ctx.client.put(f"/api/collection/{remote_collection['id']}", desired)
|
|
79
|
+
else:
|
|
80
|
+
ctx.plan.add(
|
|
81
|
+
Change(
|
|
82
|
+
resource="collections",
|
|
83
|
+
action="skip",
|
|
84
|
+
relpath=relpath,
|
|
85
|
+
name=manifest["name"],
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
if eid != remote_collection.get("entity_id") and ctx.mode == "apply":
|
|
89
|
+
manifest["entity_id"] = remote_collection.get("entity_id")
|
|
90
|
+
write_yaml(directory / "_collection.yaml", manifest)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _find_by_name_and_parent(
|
|
94
|
+
remote: RemoteIndex, parent_id: int | None, name: str
|
|
95
|
+
) -> dict[str, Any] | None:
|
|
96
|
+
for c in remote.collections_by_id.values():
|
|
97
|
+
if c.get("parent_id") == parent_id and c.get("name") == name:
|
|
98
|
+
return c
|
|
99
|
+
return None
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Apply dashboards — single PUT carries metadata + tabs + dashcards together
|
|
2
|
+
so a dashboard can never be left half-applied."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from metabase_sync.client import MetabaseClient
|
|
10
|
+
from metabase_sync.diff import RemoteIndex
|
|
11
|
+
from metabase_sync.errors import ReferenceResolutionError
|
|
12
|
+
from metabase_sync.plan import Change
|
|
13
|
+
from metabase_sync.serialize.dashboards import read_dashboard_files
|
|
14
|
+
from metabase_sync.serialize.yamlio import write_yaml
|
|
15
|
+
|
|
16
|
+
from ._shared import ApplyContext, diff_fields, resolve_card_path, summarize_diffs
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def apply_dashboards(ctx: ApplyContext) -> None:
|
|
20
|
+
for directory, doc in read_dashboard_files(ctx.state_dir):
|
|
21
|
+
dashboard_file = directory / "dashboard.yaml"
|
|
22
|
+
relpath = str(dashboard_file.relative_to(ctx.state_dir))
|
|
23
|
+
eid = doc.get("entity_id")
|
|
24
|
+
collection_id = _resolve_collection_for_dashboard(
|
|
25
|
+
directory, ctx.collection_id_by_disk_path
|
|
26
|
+
)
|
|
27
|
+
remote_dashboard = (
|
|
28
|
+
ctx.remote.dashboards_by_entity.get(eid)
|
|
29
|
+
if eid
|
|
30
|
+
else _find_by_collection_and_name(ctx.remote, collection_id, doc["name"])
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
desired: dict[str, Any] = {
|
|
34
|
+
"name": doc["name"],
|
|
35
|
+
"description": doc.get("description"),
|
|
36
|
+
"collection_id": collection_id,
|
|
37
|
+
"parameters": doc.get("parameters", []),
|
|
38
|
+
"auto_apply_filters": doc.get("auto_apply_filters", True),
|
|
39
|
+
"cache_ttl": doc.get("cache_ttl"),
|
|
40
|
+
"enable_embedding": doc.get("enable_embedding", False),
|
|
41
|
+
"embedding_params": doc.get("embedding_params"),
|
|
42
|
+
"width": doc.get("width", "fixed"),
|
|
43
|
+
"archived": doc.get("archived", False),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if remote_dashboard is None:
|
|
47
|
+
ctx.plan.add(
|
|
48
|
+
Change(
|
|
49
|
+
resource="dashboards",
|
|
50
|
+
action="create",
|
|
51
|
+
relpath=relpath,
|
|
52
|
+
name=doc["name"],
|
|
53
|
+
summary=f"{doc['name']} ({len(doc.get('dashcards') or [])} dashcards)",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
if ctx.mode == "apply":
|
|
57
|
+
# Two-call create: POST allocates an id; PUT installs the full
|
|
58
|
+
# metadata + tabs + dashcards in one go (the create POST
|
|
59
|
+
# doesn't accept nested dashcards on most Metabase versions).
|
|
60
|
+
created = ctx.client.post(
|
|
61
|
+
"/api/dashboard",
|
|
62
|
+
{"name": doc["name"], "collection_id": collection_id},
|
|
63
|
+
)
|
|
64
|
+
dashboard_id = int(created["id"])
|
|
65
|
+
ctx.dashboard_id_by_disk_path[directory.resolve()] = dashboard_id
|
|
66
|
+
doc["entity_id"] = created.get("entity_id")
|
|
67
|
+
write_yaml(dashboard_file, doc)
|
|
68
|
+
_put_full(
|
|
69
|
+
ctx.client,
|
|
70
|
+
dashboard_id,
|
|
71
|
+
desired,
|
|
72
|
+
doc,
|
|
73
|
+
directory,
|
|
74
|
+
ctx.card_id_by_disk_path,
|
|
75
|
+
)
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
ctx.dashboard_id_by_disk_path[directory.resolve()] = int(remote_dashboard["id"])
|
|
79
|
+
diffs = diff_fields(
|
|
80
|
+
desired,
|
|
81
|
+
remote_dashboard,
|
|
82
|
+
("name", "description", "collection_id", "auto_apply_filters", "archived"),
|
|
83
|
+
)
|
|
84
|
+
contents_diff = _contents_diff(
|
|
85
|
+
doc, remote_dashboard, directory, ctx.card_id_by_disk_path
|
|
86
|
+
)
|
|
87
|
+
if diffs or contents_diff:
|
|
88
|
+
summary_parts = []
|
|
89
|
+
if diffs:
|
|
90
|
+
summary_parts.append(summarize_diffs(diffs))
|
|
91
|
+
if contents_diff:
|
|
92
|
+
summary_parts.append(contents_diff)
|
|
93
|
+
ctx.plan.add(
|
|
94
|
+
Change(
|
|
95
|
+
resource="dashboards",
|
|
96
|
+
action="update",
|
|
97
|
+
relpath=relpath,
|
|
98
|
+
name=doc["name"],
|
|
99
|
+
summary="; ".join(summary_parts),
|
|
100
|
+
details={"changes": diffs, "contents": contents_diff},
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
if ctx.mode == "apply":
|
|
104
|
+
_put_full(
|
|
105
|
+
ctx.client,
|
|
106
|
+
int(remote_dashboard["id"]),
|
|
107
|
+
desired,
|
|
108
|
+
doc,
|
|
109
|
+
directory,
|
|
110
|
+
ctx.card_id_by_disk_path,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
ctx.plan.add(
|
|
114
|
+
Change(
|
|
115
|
+
resource="dashboards",
|
|
116
|
+
action="skip",
|
|
117
|
+
relpath=relpath,
|
|
118
|
+
name=doc["name"],
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _resolve_collection_for_dashboard(
|
|
124
|
+
dashboard_dir: Path, collection_id_by_disk_path: dict[Path, int]
|
|
125
|
+
) -> int | None:
|
|
126
|
+
return collection_id_by_disk_path.get(dashboard_dir.parent.parent.resolve())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _find_by_collection_and_name(
|
|
130
|
+
remote: RemoteIndex, collection_id: int | None, name: str
|
|
131
|
+
) -> dict[str, Any] | None:
|
|
132
|
+
for d in remote.dashboards_by_id.values():
|
|
133
|
+
if d.get("collection_id") == collection_id and d.get("name") == name:
|
|
134
|
+
return d
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _contents_diff(
|
|
139
|
+
doc: dict[str, Any],
|
|
140
|
+
remote_dashboard: dict[str, Any],
|
|
141
|
+
dashboard_dir: Path,
|
|
142
|
+
card_id_by_disk_path: dict[Path, int],
|
|
143
|
+
) -> str:
|
|
144
|
+
"""One-line summary if disk dashcards/tabs differ from the remote, else ''.
|
|
145
|
+
|
|
146
|
+
Surfaces both tabs and dashcards drift in the same summary so a dashboard
|
|
147
|
+
that changed both doesn't show only the tabs delta.
|
|
148
|
+
"""
|
|
149
|
+
parts: list[str] = []
|
|
150
|
+
|
|
151
|
+
desired_tabs = [(t.get("name"), t.get("position")) for t in (doc.get("tabs") or [])]
|
|
152
|
+
remote_tabs = [
|
|
153
|
+
(t.get("name"), t.get("position")) for t in (remote_dashboard.get("tabs") or [])
|
|
154
|
+
]
|
|
155
|
+
if desired_tabs != remote_tabs:
|
|
156
|
+
parts.append(f"tabs: {len(remote_tabs)} → {len(desired_tabs)}")
|
|
157
|
+
|
|
158
|
+
desired_dc = [
|
|
159
|
+
(
|
|
160
|
+
resolve_card_path(dc.get("card_path"), dashboard_dir, card_id_by_disk_path),
|
|
161
|
+
dc.get("row"),
|
|
162
|
+
dc.get("col"),
|
|
163
|
+
dc.get("size_x"),
|
|
164
|
+
dc.get("size_y"),
|
|
165
|
+
)
|
|
166
|
+
for dc in (doc.get("dashcards") or [])
|
|
167
|
+
]
|
|
168
|
+
remote_dc = [
|
|
169
|
+
(
|
|
170
|
+
dc.get("card_id"),
|
|
171
|
+
dc.get("row"),
|
|
172
|
+
dc.get("col"),
|
|
173
|
+
dc.get("size_x"),
|
|
174
|
+
dc.get("size_y"),
|
|
175
|
+
)
|
|
176
|
+
for dc in (remote_dashboard.get("dashcards") or [])
|
|
177
|
+
]
|
|
178
|
+
if desired_dc != remote_dc:
|
|
179
|
+
parts.append(f"dashcards: {len(remote_dc)} → {len(desired_dc)}")
|
|
180
|
+
|
|
181
|
+
return "; ".join(parts)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- write side --------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _put_full(
|
|
188
|
+
client: MetabaseClient,
|
|
189
|
+
dashboard_id: int,
|
|
190
|
+
metadata: dict[str, Any],
|
|
191
|
+
doc: dict[str, Any],
|
|
192
|
+
dashboard_dir: Path,
|
|
193
|
+
card_id_by_disk_path: dict[Path, int],
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Single PUT carrying metadata + tabs + dashcards.
|
|
196
|
+
|
|
197
|
+
Metabase's dashboard PUT accepts all these fields in one request, so we
|
|
198
|
+
fire one call rather than two — eliminates the half-applied window where
|
|
199
|
+
metadata had been updated but dashcards hadn't.
|
|
200
|
+
"""
|
|
201
|
+
tabs_payload, pos_to_temp_id = _build_tabs_payload(doc)
|
|
202
|
+
dashcards_payload = _build_dashcards_payload(
|
|
203
|
+
doc, dashboard_dir, card_id_by_disk_path, pos_to_temp_id
|
|
204
|
+
)
|
|
205
|
+
body = {**metadata, "tabs": tabs_payload, "dashcards": dashcards_payload}
|
|
206
|
+
client.put(f"/api/dashboard/{dashboard_id}", body)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _build_tabs_payload(
|
|
210
|
+
doc: dict[str, Any],
|
|
211
|
+
) -> tuple[list[dict[str, Any]], dict[int, int]]:
|
|
212
|
+
tabs_payload: list[dict[str, Any]] = []
|
|
213
|
+
pos_to_temp_id: dict[int, int] = {}
|
|
214
|
+
for i, tab in enumerate(doc.get("tabs", []) or []):
|
|
215
|
+
temp_id = -(i + 1)
|
|
216
|
+
pos_to_temp_id[int(tab["position"])] = temp_id
|
|
217
|
+
tabs_payload.append(
|
|
218
|
+
{"id": temp_id, "name": tab["name"], "position": tab["position"]}
|
|
219
|
+
)
|
|
220
|
+
return tabs_payload, pos_to_temp_id
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _build_dashcards_payload(
|
|
224
|
+
doc: dict[str, Any],
|
|
225
|
+
dashboard_dir: Path,
|
|
226
|
+
card_id_by_disk_path: dict[Path, int],
|
|
227
|
+
pos_to_temp_id: dict[int, int],
|
|
228
|
+
) -> list[dict[str, Any]]:
|
|
229
|
+
out: list[dict[str, Any]] = []
|
|
230
|
+
for i, dc in enumerate(doc.get("dashcards", []) or []):
|
|
231
|
+
card_id = resolve_card_path(
|
|
232
|
+
dc.get("card_path"), dashboard_dir, card_id_by_disk_path
|
|
233
|
+
)
|
|
234
|
+
if card_id is None and dc.get("card_path") is not None:
|
|
235
|
+
raise ReferenceResolutionError(
|
|
236
|
+
f"dashboard {dashboard_dir}: dashcard references "
|
|
237
|
+
f"card_path={dc['card_path']} but no such card file exists"
|
|
238
|
+
)
|
|
239
|
+
tab_pos = dc.get("tab_position")
|
|
240
|
+
out.append(
|
|
241
|
+
{
|
|
242
|
+
"id": -(i + 1),
|
|
243
|
+
"card_id": card_id,
|
|
244
|
+
"dashboard_tab_id": pos_to_temp_id.get(tab_pos)
|
|
245
|
+
if tab_pos is not None
|
|
246
|
+
else None,
|
|
247
|
+
"row": dc["row"],
|
|
248
|
+
"col": dc["col"],
|
|
249
|
+
"size_x": dc["size_x"],
|
|
250
|
+
"size_y": dc["size_y"],
|
|
251
|
+
"parameter_mappings": dc.get("parameter_mappings", []),
|
|
252
|
+
"visualization_settings": dc.get("visualization_settings", {}),
|
|
253
|
+
"series": dc.get("series", []),
|
|
254
|
+
"action_id": dc.get("action_id"),
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
return out
|