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.
Files changed (65) hide show
  1. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/CHANGELOG.md +33 -0
  2. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/PKG-INFO +1 -1
  3. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/pyproject.toml +1 -1
  4. metabase_sync-0.2.2/src/metabase_sync/__init__.py +1 -0
  5. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_cards.py +9 -2
  6. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_pulses.py +12 -10
  7. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_shared.py +2 -1
  8. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/canon.py +18 -0
  9. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/cards.py +3 -3
  10. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_legacy_query.py +53 -0
  11. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_pulse_diff.py +6 -0
  12. metabase_sync-0.2.0/src/metabase_sync/__init__.py +0 -1
  13. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/.gitignore +0 -0
  14. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/LICENSE +0 -0
  15. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/README.md +0 -0
  16. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/examples/README.md +0 -0
  17. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/__main__.py +0 -0
  18. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/__init__.py +0 -0
  19. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_collections.py +0 -0
  20. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_dashboards.py +0 -0
  21. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_runner.py +0 -0
  22. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/apply/_snippets.py +0 -0
  23. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/cli.py +0 -0
  24. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/client.py +0 -0
  25. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/diff.py +0 -0
  26. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/errors.py +0 -0
  27. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/export.py +0 -0
  28. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/models.py +0 -0
  29. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/plan.py +0 -0
  30. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/__init__.py +0 -0
  31. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/collections.py +0 -0
  32. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/dashboards.py +0 -0
  33. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/databases.py +0 -0
  34. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/paths.py +0 -0
  35. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/pulses.py +0 -0
  36. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/snippets.py +0 -0
  37. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/version.py +0 -0
  38. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/serialize/yamlio.py +0 -0
  39. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/src/metabase_sync/settings.py +0 -0
  40. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/__init__.py +0 -0
  41. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/__init__.py +0 -0
  42. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/_builders.py +0 -0
  43. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/_mb.py +0 -0
  44. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/conftest.py +0 -0
  45. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/docker-compose.yml +0 -0
  46. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_apply_semantics.py +0 -0
  47. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_cards.py +0 -0
  48. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_cli.py +0 -0
  49. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_dashboards.py +0 -0
  50. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_export.py +0 -0
  51. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_pulses.py +0 -0
  52. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/integration/test_snippets.py +0 -0
  53. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_canon.py +0 -0
  54. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_card_diff.py +0 -0
  55. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_client_retries.py +0 -0
  56. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_concurrency.py +0 -0
  57. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_dashboard_diff.py +0 -0
  58. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_paths.py +0 -0
  59. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_paths_resolve.py +0 -0
  60. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_preflight.py +0 -0
  61. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_recipients.py +0 -0
  62. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_state_version.py +0 -0
  63. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_template_tags.py +0 -0
  64. {metabase_sync-0.2.0 → metabase_sync-0.2.2}/tests/test_version_check.py +0 -0
  65. {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.0
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.0"
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(path, canonical(fm | {"dataset_query": gui_query or {}}, CARD))
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
- "disable_links": doc.get("disable_links", False),
239
- "archived": doc.get("archived", False),
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 = frozenset({"info", "lib/uuid", "lib.convert/converted?"})
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