metabase-sync 0.2.0__tar.gz → 0.2.2__tar.gz
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-0.2.0 → metabase_sync-0.2.2}/CHANGELOG.md +33 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/PKG-INFO +1 -1
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/pyproject.toml +1 -1
- metabase_sync-0.2.2/src/metabase_sync/__init__.py +1 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_cards.py +9 -2
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_pulses.py +12 -10
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_shared.py +2 -1
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/canon.py +18 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/cards.py +3 -3
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_legacy_query.py +53 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_pulse_diff.py +6 -0
- metabase_sync-0.2.0/src/metabase_sync/__init__.py +0 -1
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/.gitignore +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/LICENSE +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/README.md +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/examples/README.md +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/__main__.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/__init__.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_collections.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_dashboards.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_runner.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_snippets.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/cli.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/client.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/diff.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/errors.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/export.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/models.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/plan.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/__init__.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/collections.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/dashboards.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/databases.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/paths.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/pulses.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/snippets.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/version.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/yamlio.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/settings.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/__init__.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/__init__.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/_builders.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/_mb.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/conftest.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/docker-compose.yml +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_apply_semantics.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_cards.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_cli.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_dashboards.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_export.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_pulses.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_snippets.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_canon.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_card_diff.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_client_retries.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_concurrency.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_dashboard_diff.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_paths.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_paths_resolve.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_preflight.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_recipients.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_state_version.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_template_tags.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_version_check.py +0 -0
- {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_yamlio.py +0 -0
|
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.2] — 2026-07-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Export no longer persists `lib/uuid` (and sibling server bookkeeping)
|
|
15
|
+
inside queries.** Metabase regenerates these MBQL5 values server-side, so
|
|
16
|
+
storing them made re-exports rewrite otherwise-unchanged cards forever. The
|
|
17
|
+
diff already ignored them; writes (export and apply write-backs) now strip
|
|
18
|
+
them from `dataset_query` and `template_tags` too. The first export after
|
|
19
|
+
upgrading removes them from existing state once.
|
|
20
|
+
|
|
21
|
+
### Testing
|
|
22
|
+
|
|
23
|
+
- Unit coverage that written card files carry no `lib/uuid` in either form
|
|
24
|
+
(`test_legacy_query.py`); both integration suites (pinned v0.62.2 and
|
|
25
|
+
v0.55.16) verified.
|
|
26
|
+
|
|
27
|
+
## [0.2.1] — 2026-07-16
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **Pulses no longer plan a perpetual `disable_links: null → False` update on
|
|
32
|
+
Metabase <= v0.55.** Those versions return `disable_links` as null, which
|
|
33
|
+
the 0.2.0 pulse diff compared against the default. A null remote value is
|
|
34
|
+
never diffed against the default, and a null on disk (state exported from
|
|
35
|
+
such a version) is sent as false.
|
|
36
|
+
|
|
37
|
+
### Testing
|
|
38
|
+
|
|
39
|
+
- Unit coverage for the null-remote case (`test_pulse_diff.py`); the full
|
|
40
|
+
integration suite verified against Metabase v0.55.16 and the pinned
|
|
41
|
+
v0.62.2.
|
|
42
|
+
|
|
10
43
|
## [0.2.0] — 2026-07-16
|
|
11
44
|
|
|
12
45
|
Minor (not patch) because output formatting changes: the first export after
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metabase-sync
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Metabase as code: export, plan, and apply dashboards, cards, collections, snippets and pulses via the Metabase REST API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/novucs/metabase-sync
|
|
6
6
|
Project-URL: Repository, https://github.com/novucs/metabase-sync
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "metabase-sync"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "Metabase as code: export, plan, and apply dashboards, cards, collections, snippets and pulses via the Metabase REST API."
|
|
5
5
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
6
6
|
authors = [{ name = "novucs" }]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
|
@@ -18,7 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
20
|
from metabase_sync.plan import Change
|
|
21
|
-
from metabase_sync.serialize.canon import CARD, canonical
|
|
21
|
+
from metabase_sync.serialize.canon import CARD, canonical, strip_query_volatile
|
|
22
22
|
from metabase_sync.serialize.cards import read_card_file
|
|
23
23
|
from metabase_sync.serialize.yamlio import write_frontmatter_sql, write_yaml
|
|
24
24
|
|
|
@@ -251,10 +251,17 @@ def _assert_query_persisted(
|
|
|
251
251
|
def _rewrite_card_file(
|
|
252
252
|
path: Path, fm: dict[str, Any], body: str | None, gui_query: dict[str, Any] | None
|
|
253
253
|
) -> None:
|
|
254
|
+
if "template_tags" in fm:
|
|
255
|
+
fm = fm | {"template_tags": strip_query_volatile(fm["template_tags"])}
|
|
254
256
|
if body is not None:
|
|
255
257
|
write_frontmatter_sql(path, canonical(fm, CARD), body)
|
|
256
258
|
else:
|
|
257
|
-
write_yaml(
|
|
259
|
+
write_yaml(
|
|
260
|
+
path,
|
|
261
|
+
canonical(
|
|
262
|
+
fm | {"dataset_query": strip_query_volatile(gui_query or {})}, CARD
|
|
263
|
+
),
|
|
264
|
+
)
|
|
258
265
|
|
|
259
266
|
|
|
260
267
|
# --- diff ---------------------------------------------------------------------
|
|
@@ -119,15 +119,16 @@ def _pulse_diffs(
|
|
|
119
119
|
diffs = diff_fields(
|
|
120
120
|
desired,
|
|
121
121
|
remote,
|
|
122
|
-
(
|
|
123
|
-
"name",
|
|
124
|
-
"collection_id",
|
|
125
|
-
"dashboard_id",
|
|
126
|
-
"skip_if_empty",
|
|
127
|
-
"disable_links",
|
|
128
|
-
"archived",
|
|
129
|
-
),
|
|
122
|
+
("name", "collection_id", "dashboard_id", "skip_if_empty", "archived"),
|
|
130
123
|
)
|
|
124
|
+
# Metabase <= v0.55 returns disable_links as null; never diff the default
|
|
125
|
+
# against it.
|
|
126
|
+
if remote.get("disable_links") is not None and desired.get(
|
|
127
|
+
"disable_links"
|
|
128
|
+
) != remote.get("disable_links"):
|
|
129
|
+
diffs.append(
|
|
130
|
+
("disable_links", remote.get("disable_links"), desired.get("disable_links"))
|
|
131
|
+
)
|
|
131
132
|
diffs.extend(diff_container_fields(desired, remote, ("parameters",)))
|
|
132
133
|
|
|
133
134
|
desired_cards = [_card_projection(c) for c in desired.get("cards") or []]
|
|
@@ -235,8 +236,9 @@ def _build_pulse_payload(
|
|
|
235
236
|
"collection_id": collection_id,
|
|
236
237
|
"dashboard_id": dashboard_id,
|
|
237
238
|
"skip_if_empty": doc.get("skip_if_empty", False),
|
|
238
|
-
|
|
239
|
-
"
|
|
239
|
+
# `or False`: state exported from v0.55 stores an explicit null.
|
|
240
|
+
"disable_links": doc.get("disable_links") or False,
|
|
241
|
+
"archived": doc.get("archived") or False,
|
|
240
242
|
"parameters": doc.get("parameters", []),
|
|
241
243
|
"cards": cards,
|
|
242
244
|
"channels": channels,
|
|
@@ -15,6 +15,7 @@ from typing import Any, Literal
|
|
|
15
15
|
from metabase_sync.client import MetabaseClient
|
|
16
16
|
from metabase_sync.diff import RemoteIndex
|
|
17
17
|
from metabase_sync.plan import Plan, diff_summary
|
|
18
|
+
from metabase_sync.serialize.canon import QUERY_VOLATILE_KEYS
|
|
18
19
|
|
|
19
20
|
Mode = Literal["plan", "apply"]
|
|
20
21
|
|
|
@@ -86,7 +87,7 @@ def resolve_card_path(
|
|
|
86
87
|
# --- dataset_query normalisation (used by cards diff) ---------------------------
|
|
87
88
|
|
|
88
89
|
|
|
89
|
-
_VOLATILE_KEYS =
|
|
90
|
+
_VOLATILE_KEYS = QUERY_VOLATILE_KEYS
|
|
90
91
|
|
|
91
92
|
|
|
92
93
|
def normalize_dataset_query(dq: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -97,6 +97,24 @@ DASHBOARD_CHILDREN = {"tabs": TAB, "dashcards": DASHCARD}
|
|
|
97
97
|
PULSE_CHILDREN = {"cards": PULSE_CARD, "channels": CHANNEL}
|
|
98
98
|
|
|
99
99
|
|
|
100
|
+
# Server-regenerated bookkeeping inside query structures (dataset_query,
|
|
101
|
+
# template_tags). Never persisted and never diffed; scoped to queries because
|
|
102
|
+
# these words can be legitimate keys in user content elsewhere.
|
|
103
|
+
QUERY_VOLATILE_KEYS = frozenset({"info", "lib/uuid", "lib.convert/converted?"})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def strip_query_volatile(value: Any) -> Any:
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
return {
|
|
109
|
+
k: strip_query_volatile(v)
|
|
110
|
+
for k, v in value.items()
|
|
111
|
+
if k not in QUERY_VOLATILE_KEYS
|
|
112
|
+
}
|
|
113
|
+
if isinstance(value, list):
|
|
114
|
+
return [strip_query_volatile(item) for item in value]
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
|
|
100
118
|
def canonical(
|
|
101
119
|
doc: dict[str, Any],
|
|
102
120
|
template: tuple[str, ...],
|
|
@@ -28,7 +28,7 @@ from pathlib import Path
|
|
|
28
28
|
from typing import Any
|
|
29
29
|
|
|
30
30
|
from metabase_sync.models import Card
|
|
31
|
-
from metabase_sync.serialize.canon import CARD, canonical
|
|
31
|
+
from metabase_sync.serialize.canon import CARD, canonical, strip_query_volatile
|
|
32
32
|
from metabase_sync.serialize.paths import disambiguate, slugify
|
|
33
33
|
from metabase_sync.serialize.yamlio import (
|
|
34
34
|
load_yaml,
|
|
@@ -80,7 +80,7 @@ def write_card(path: Path, card: Card, db_name_by_id: dict[int, str]) -> None:
|
|
|
80
80
|
if native is not None:
|
|
81
81
|
body, template_tags = native
|
|
82
82
|
frontmatter = _common_frontmatter(card, db_name) | {
|
|
83
|
-
"template_tags": template_tags,
|
|
83
|
+
"template_tags": strip_query_volatile(template_tags),
|
|
84
84
|
**extra,
|
|
85
85
|
}
|
|
86
86
|
write_frontmatter_sql(path, canonical(frontmatter, CARD), body)
|
|
@@ -91,7 +91,7 @@ def write_card(path: Path, card: Card, db_name_by_id: dict[int, str]) -> None:
|
|
|
91
91
|
legacy = _legacy_dict(card)
|
|
92
92
|
dataset_query = legacy if legacy else dict(card.dataset_query)
|
|
93
93
|
doc = _common_frontmatter(card, db_name) | {
|
|
94
|
-
"dataset_query": dataset_query,
|
|
94
|
+
"dataset_query": strip_query_volatile(dataset_query),
|
|
95
95
|
**extra,
|
|
96
96
|
}
|
|
97
97
|
write_yaml(path, canonical(doc, CARD))
|
|
@@ -208,6 +208,59 @@ def test_gui_card_round_trip_mbql5_form(tmp_path: Path):
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
|
|
211
|
+
def test_export_strips_volatile_lib_uuid(tmp_path: Path):
|
|
212
|
+
"""Metabase regenerates lib/uuid on every save; persisting them makes
|
|
213
|
+
every export rewrite otherwise-unchanged cards. Written files must carry
|
|
214
|
+
none, in the GUI dataset_query or in native template_tags dimensions."""
|
|
215
|
+
legacy = json.dumps(
|
|
216
|
+
{
|
|
217
|
+
"lib/type": "mbql/query",
|
|
218
|
+
"database": 2,
|
|
219
|
+
"stages": [
|
|
220
|
+
{
|
|
221
|
+
"lib/type": "mbql.stage/mbql",
|
|
222
|
+
"lib/uuid": "aaaa-1111",
|
|
223
|
+
"source-table": 2,
|
|
224
|
+
}
|
|
225
|
+
],
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
gui_card = _make_native_card(name="GUI", legacy_query=legacy, display="bar")
|
|
229
|
+
gui_path = tmp_path / "card.yaml"
|
|
230
|
+
write_card(gui_path, gui_card, db_name_by_id={2: "bigquery"})
|
|
231
|
+
_fm, _body, gui = read_card_file(gui_path)
|
|
232
|
+
assert "lib/uuid" not in gui_path.read_text()
|
|
233
|
+
assert gui["stages"][0]["lib/type"] == "mbql.stage/mbql"
|
|
234
|
+
|
|
235
|
+
native_legacy = json.dumps(
|
|
236
|
+
{
|
|
237
|
+
"type": "native",
|
|
238
|
+
"database": 2,
|
|
239
|
+
"native": {
|
|
240
|
+
"query": "SELECT {{d}}",
|
|
241
|
+
"template-tags": {
|
|
242
|
+
"d": {
|
|
243
|
+
"type": "dimension",
|
|
244
|
+
"name": "d",
|
|
245
|
+
"dimension": [
|
|
246
|
+
"field",
|
|
247
|
+
{"lib/uuid": "bbbb-2222", "base-type": "type/Date"},
|
|
248
|
+
8589,
|
|
249
|
+
],
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
native_card = _make_native_card(legacy_query=native_legacy)
|
|
256
|
+
native_path = tmp_path / "card.sql"
|
|
257
|
+
write_card(native_path, native_card, db_name_by_id={2: "bigquery"})
|
|
258
|
+
assert "lib/uuid" not in native_path.read_text()
|
|
259
|
+
fm, body, _gui = read_card_file(native_path)
|
|
260
|
+
assert body == "SELECT {{d}}"
|
|
261
|
+
assert fm["template_tags"]["d"]["dimension"][2] == 8589
|
|
262
|
+
|
|
263
|
+
|
|
211
264
|
def test_gui_card_backwards_compat_old_on_disk_format(tmp_path: Path):
|
|
212
265
|
"""Files written by an earlier pre-release of the tool stored only the
|
|
213
266
|
classic `query` sub-dict at top level. read_card_file lifts them into the
|
|
@@ -110,3 +110,9 @@ def test_flags_and_parameters_detected():
|
|
|
110
110
|
desired = _desired(disable_links=True, archived=True, parameters=[{"id": "p1"}])
|
|
111
111
|
fields = {f for f, _b, _a in _pulse_diffs(desired, _remote())}
|
|
112
112
|
assert {"disable_links", "archived", "parameters"} <= fields
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_null_remote_disable_links_does_not_diff():
|
|
116
|
+
# Metabase v0.55 returns disable_links as null; the default must not
|
|
117
|
+
# plan an update forever against it.
|
|
118
|
+
assert _pulse_diffs(_desired(), _remote(disable_links=None)) == []
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|