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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from metabase_sync.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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