pbi-enterprise-cli 0.1.0.dev0__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.
Files changed (61) hide show
  1. pbi_cli/__init__.py +3 -0
  2. pbi_cli/_audit.py +57 -0
  3. pbi_cli/_snapshot.py +95 -0
  4. pbi_cli/backends/__init__.py +1 -0
  5. pbi_cli/backends/mock_backend.py +323 -0
  6. pbi_cli/backends/pbir_backend.py +813 -0
  7. pbi_cli/backends/protocol.py +52 -0
  8. pbi_cli/backends/tom_backend.py +650 -0
  9. pbi_cli/backends/xmla_backend.py +627 -0
  10. pbi_cli/cli.py +332 -0
  11. pbi_cli/commands/__init__.py +1 -0
  12. pbi_cli/commands/_doctor.py +84 -0
  13. pbi_cli/commands/_shared.py +88 -0
  14. pbi_cli/commands/calendar_cmd.py +186 -0
  15. pbi_cli/commands/connections.py +153 -0
  16. pbi_cli/commands/custom_visual.py +325 -0
  17. pbi_cli/commands/database.py +76 -0
  18. pbi_cli/commands/dax.py +174 -0
  19. pbi_cli/commands/deploy.py +193 -0
  20. pbi_cli/commands/docs.py +57 -0
  21. pbi_cli/commands/filter_cmd.py +235 -0
  22. pbi_cli/commands/govern.py +124 -0
  23. pbi_cli/commands/layout.py +104 -0
  24. pbi_cli/commands/measure.py +185 -0
  25. pbi_cli/commands/model.py +499 -0
  26. pbi_cli/commands/partition.py +89 -0
  27. pbi_cli/commands/repl.py +209 -0
  28. pbi_cli/commands/report.py +561 -0
  29. pbi_cli/commands/security.py +90 -0
  30. pbi_cli/commands/server_cmd.py +30 -0
  31. pbi_cli/commands/skills_cmd.py +168 -0
  32. pbi_cli/commands/source.py +581 -0
  33. pbi_cli/commands/theme.py +60 -0
  34. pbi_cli/commands/trace.py +142 -0
  35. pbi_cli/commands/visual.py +507 -0
  36. pbi_cli/commands/watch.py +145 -0
  37. pbi_cli/docs_gen/__init__.py +1 -0
  38. pbi_cli/docs_gen/confluence.py +24 -0
  39. pbi_cli/docs_gen/markdown.py +36 -0
  40. pbi_cli/governance/__init__.py +1 -0
  41. pbi_cli/governance/engine.py +70 -0
  42. pbi_cli/governance/rules/__init__.py +85 -0
  43. pbi_cli/governance/rules/measure_brackets.py +27 -0
  44. pbi_cli/governance/rules/measure_description.py +41 -0
  45. pbi_cli/governance/rules/measure_format.py +38 -0
  46. pbi_cli/governance/rules/measure_naming.py +93 -0
  47. pbi_cli/governance/rules/table_pascal_case.py +44 -0
  48. pbi_cli/intelligence/__init__.py +1 -0
  49. pbi_cli/intelligence/layout_engine.py +192 -0
  50. pbi_cli/intelligence/measure_generator.py +40 -0
  51. pbi_cli/intelligence/theme_generator.py +193 -0
  52. pbi_cli/intelligence/visual_builder.py +429 -0
  53. pbi_cli/intelligence/visual_recommender.py +42 -0
  54. pbi_cli/server/__init__.py +1 -0
  55. pbi_cli/server/api.py +185 -0
  56. pbi_enterprise_cli-0.1.0.dev0.dist-info/METADATA +103 -0
  57. pbi_enterprise_cli-0.1.0.dev0.dist-info/RECORD +61 -0
  58. pbi_enterprise_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
  59. pbi_enterprise_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  60. pbi_enterprise_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  61. pbi_enterprise_cli-0.1.0.dev0.dist-info/top_level.txt +1 -0
pbi_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """pbi-cli: Power BI one-stop-shop platform for AI-driven development."""
2
+
3
+ __version__ = "0.1.0.dev0"
pbi_cli/_audit.py ADDED
@@ -0,0 +1,57 @@
1
+ """Append-only audit log for all pbi-cli write operations (Epic D6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import getpass
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ _AUDIT_FILE = Path.home() / ".pbi-cli" / "audit.jsonl"
12
+
13
+
14
+ def write_audit_entry(
15
+ command: str,
16
+ before: Any = None,
17
+ after: Any = None,
18
+ extra: dict[str, Any] | None = None,
19
+ ) -> None:
20
+ """Append one JSON line to ~/.pbi-cli/audit.jsonl."""
21
+ _AUDIT_FILE.parent.mkdir(parents=True, exist_ok=True)
22
+ entry: dict[str, Any] = {
23
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
24
+ "user": _get_user(),
25
+ "command": command,
26
+ }
27
+ if before is not None:
28
+ entry["before"] = before
29
+ if after is not None:
30
+ entry["after"] = after
31
+ if extra:
32
+ entry.update(extra)
33
+ with _AUDIT_FILE.open("a", encoding="utf-8") as f:
34
+ f.write(json.dumps(entry, default=str) + "\n")
35
+
36
+
37
+ def read_audit_log(limit: int = 50) -> list[dict[str, Any]]:
38
+ """Return the last *limit* audit entries (most-recent last)."""
39
+ if not _AUDIT_FILE.exists():
40
+ return []
41
+ lines = _AUDIT_FILE.read_text(encoding="utf-8").splitlines()
42
+ entries = []
43
+ for line in lines:
44
+ line = line.strip()
45
+ if line:
46
+ try:
47
+ entries.append(json.loads(line))
48
+ except json.JSONDecodeError:
49
+ pass
50
+ return entries[-limit:]
51
+
52
+
53
+ def _get_user() -> str:
54
+ try:
55
+ return getpass.getuser()
56
+ except Exception:
57
+ return "unknown"
pbi_cli/_snapshot.py ADDED
@@ -0,0 +1,95 @@
1
+ """Snapshot capture and restore for pbi undo (Epic F2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ _SNAPSHOT_DIR = Path.home() / ".pbi-cli" / "snapshots"
11
+ _MAX_SNAPSHOTS = 20
12
+
13
+
14
+ def capture_snapshot(backend: Any) -> Path:
15
+ """Capture the current backend state to a timestamped JSON file.
16
+
17
+ Returns the path of the written snapshot.
18
+ """
19
+ _SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
20
+
21
+ state: dict[str, Any] = {}
22
+ try:
23
+ state["model"] = backend.model_info()
24
+ except Exception:
25
+ state["model"] = {}
26
+ try:
27
+ state["tables"] = backend.table_list()
28
+ except Exception:
29
+ state["tables"] = []
30
+ try:
31
+ state["columns"] = backend.column_list()
32
+ except Exception:
33
+ state["columns"] = []
34
+ try:
35
+ state["relationships"] = backend.relationship_list()
36
+ except Exception:
37
+ state["relationships"] = []
38
+ try:
39
+ state["measures"] = backend.measure_list()
40
+ except Exception:
41
+ state["measures"] = []
42
+
43
+ ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%f")
44
+ snapshot_path = _SNAPSHOT_DIR / f"{ts}.json"
45
+ snapshot_path.write_text(json.dumps(state, indent=2, default=str), encoding="utf-8")
46
+
47
+ _prune_old_snapshots()
48
+ return snapshot_path
49
+
50
+
51
+ def latest_snapshot() -> Path | None:
52
+ """Return the path of the most recent snapshot, or None."""
53
+ if not _SNAPSHOT_DIR.exists():
54
+ return None
55
+ snapshots = sorted(_SNAPSHOT_DIR.glob("*.json"))
56
+ return snapshots[-1] if snapshots else None
57
+
58
+
59
+ def restore_snapshot(snapshot_path: Path, backend: Any) -> dict[str, Any]:
60
+ """Apply the measures from a snapshot to the backend.
61
+
62
+ Returns a summary of what was restored.
63
+ """
64
+ state = json.loads(snapshot_path.read_text(encoding="utf-8"))
65
+ restored: dict[str, Any] = {"measures_restored": 0, "tables_restored": 0}
66
+
67
+ # Restore measures — safest operation: delete all current, re-add from snapshot
68
+ try:
69
+ current = backend.measure_list()
70
+ for m in current:
71
+ try:
72
+ backend.measure_delete(m["table"], m["name"])
73
+ except Exception:
74
+ pass
75
+ for m in state.get("measures", []):
76
+ kwargs = {k: v for k, v in m.items() if k not in ("table", "name", "expression")}
77
+ try:
78
+ backend.measure_add(m["table"], m["name"], m["expression"], **kwargs)
79
+ restored["measures_restored"] += 1
80
+ except Exception:
81
+ pass
82
+ except Exception:
83
+ pass
84
+
85
+ return restored
86
+
87
+
88
+ def _prune_old_snapshots() -> None:
89
+ """Keep only the most recent _MAX_SNAPSHOTS snapshots."""
90
+ snapshots = sorted(_SNAPSHOT_DIR.glob("*.json"))
91
+ for old in snapshots[:-_MAX_SNAPSHOTS]:
92
+ try:
93
+ old.unlink()
94
+ except Exception:
95
+ pass
@@ -0,0 +1 @@
1
+ """TOM backends for pbi-cli."""
@@ -0,0 +1,323 @@
1
+ """MockTomBackend — fixture-based mock for testing without Power BI Desktop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ DEFAULT_FIXTURE: dict[str, Any] = {
10
+ "model": {"name": "MockModel", "compatibility_level": 1600},
11
+ "tables": [
12
+ {"name": "Sales", "isHidden": False},
13
+ {"name": "Products", "isHidden": False},
14
+ {"name": "Customers", "isHidden": False},
15
+ {"name": "Calendar", "isHidden": False},
16
+ ],
17
+ "columns": [
18
+ {"table": "Sales", "name": "SalesKey", "dataType": "Int64", "isHidden": False},
19
+ {"table": "Sales", "name": "ProductKey", "dataType": "Int64", "isHidden": False},
20
+ {"table": "Sales", "name": "CustomerKey", "dataType": "Int64", "isHidden": False},
21
+ {"table": "Sales", "name": "DateKey", "dataType": "Int64", "isHidden": False},
22
+ {"table": "Sales", "name": "Revenue", "dataType": "Decimal", "isHidden": False},
23
+ {"table": "Sales", "name": "Units", "dataType": "Int64", "isHidden": False},
24
+ {"table": "Products", "name": "ProductKey", "dataType": "Int64", "isHidden": False},
25
+ {"table": "Products", "name": "ProductName", "dataType": "String", "isHidden": False},
26
+ {"table": "Products", "name": "Category", "dataType": "String", "isHidden": False},
27
+ {"table": "Customers", "name": "CustomerKey", "dataType": "Int64", "isHidden": False},
28
+ {"table": "Customers", "name": "CustomerName", "dataType": "String", "isHidden": False},
29
+ {"table": "Customers", "name": "Region", "dataType": "String", "isHidden": False},
30
+ {"table": "Calendar", "name": "DateKey", "dataType": "Int64", "isHidden": False},
31
+ {"table": "Calendar", "name": "Date", "dataType": "DateTime", "isHidden": False},
32
+ {"table": "Calendar", "name": "Year", "dataType": "Int64", "isHidden": False},
33
+ {"table": "Calendar", "name": "Month", "dataType": "Int64", "isHidden": False},
34
+ {"table": "Calendar", "name": "Quarter", "dataType": "String", "isHidden": False},
35
+ ],
36
+ "relationships": [
37
+ {"from": "Sales[ProductKey]", "to": "Products[ProductKey]", "cardinality": "ManyToOne"},
38
+ {"from": "Sales[CustomerKey]", "to": "Customers[CustomerKey]", "cardinality": "ManyToOne"},
39
+ {"from": "Sales[DateKey]", "to": "Calendar[DateKey]", "cardinality": "ManyToOne"},
40
+ ],
41
+ "measures": [
42
+ {
43
+ "table": "Sales",
44
+ "name": "Total Revenue",
45
+ "expression": "SUM(Sales[Revenue])",
46
+ "formatString": "#,0.00",
47
+ },
48
+ {
49
+ "table": "Sales",
50
+ "name": "Total Units",
51
+ "expression": "SUM(Sales[Units])",
52
+ "formatString": "#,0",
53
+ },
54
+ ],
55
+ }
56
+
57
+
58
+ class MockTomBackend:
59
+ """Fixture-based TOM backend for unit and integration testing.
60
+
61
+ Requires no Power BI Desktop, no Windows, no DLLs.
62
+ All writes go to in-memory state and are inspectable via get_state().
63
+ """
64
+
65
+ def __init__(
66
+ self, fixture: dict[str, Any] | None = None, fixture_path: Path | None = None
67
+ ) -> None:
68
+ if fixture_path is not None:
69
+ self._state: dict[str, Any] = json.loads(fixture_path.read_text(encoding="utf-8"))
70
+ elif fixture is not None:
71
+ self._state = json.loads(json.dumps(fixture)) # deep copy
72
+ else:
73
+ self._state = json.loads(json.dumps(DEFAULT_FIXTURE))
74
+ self._connected = False
75
+ self._write_log: list[dict[str, Any]] = []
76
+
77
+ # --- Connection ---
78
+
79
+ def connect(self, **kwargs: Any) -> None:
80
+ self._connected = True
81
+
82
+ def disconnect(self) -> None:
83
+ self._connected = False
84
+
85
+ def is_connected(self) -> bool:
86
+ return self._connected
87
+
88
+ def get_state(self) -> dict[str, Any]:
89
+ """Return current in-memory state (for test assertions)."""
90
+ return self._state
91
+
92
+ def get_write_log(self) -> list[dict[str, Any]]:
93
+ """Return all write operations performed (for test assertions)."""
94
+ return self._write_log
95
+
96
+ # --- Model info ---
97
+
98
+ def model_info(self) -> dict[str, Any]:
99
+ return dict(self._state.get("model", {}))
100
+
101
+ def table_list(self) -> list[dict[str, Any]]:
102
+ return list(self._state.get("tables", []))
103
+
104
+ def column_list(self, table: str | None = None) -> list[dict[str, Any]]:
105
+ cols = self._state.get("columns", [])
106
+ if table:
107
+ return [c for c in cols if c["table"] == table]
108
+ return list(cols)
109
+
110
+ def relationship_list(self) -> list[dict[str, Any]]:
111
+ return list(self._state.get("relationships", []))
112
+
113
+ # --- Measures ---
114
+
115
+ def measure_list(self, table: str | None = None) -> list[dict[str, Any]]:
116
+ measures = self._state.get("measures", [])
117
+ if table:
118
+ return [m for m in measures if m["table"] == table]
119
+ return list(measures)
120
+
121
+ def measure_add(self, table: str, name: str, expression: str, **kwargs: Any) -> dict[str, Any]:
122
+ record = {"table": table, "name": name, "expression": expression, **kwargs}
123
+ self._state.setdefault("measures", []).append(record)
124
+ self._write_log.append({"op": "measure_add", "data": record})
125
+ return dict(record)
126
+
127
+ def measure_update(self, table: str, name: str, **kwargs: Any) -> dict[str, Any]:
128
+ for m in self._state.get("measures", []):
129
+ if m["table"] == table and m["name"] == name:
130
+ # Handle rename specially so the key stays consistent
131
+ new_name = kwargs.pop("new_name", None)
132
+ if new_name:
133
+ m["name"] = new_name
134
+ m.update(kwargs)
135
+ self._write_log.append(
136
+ {"op": "measure_update", "data": {"table": table, "name": name, **kwargs}}
137
+ )
138
+ return dict(m)
139
+ raise KeyError(f"Measure '{table}'['{name}'] not found.")
140
+
141
+ def measure_delete(self, table: str, name: str) -> None:
142
+ measures = self._state.get("measures", [])
143
+ before = len(measures)
144
+ self._state["measures"] = [
145
+ m for m in measures if not (m["table"] == table and m["name"] == name)
146
+ ]
147
+ if len(self._state["measures"]) == before:
148
+ raise KeyError(f"Measure '{table}'['{name}'] not found.")
149
+ self._write_log.append({"op": "measure_delete", "data": {"table": table, "name": name}})
150
+
151
+ # --- Tables ---
152
+
153
+ def table_add(self, name: str, **kwargs: Any) -> dict[str, Any]:
154
+ record = {"name": name, **kwargs}
155
+ self._state.setdefault("tables", []).append(record)
156
+ self._write_log.append({"op": "table_add", "data": record})
157
+ return dict(record)
158
+
159
+ def table_delete(self, name: str) -> None:
160
+ tables = self._state.get("tables", [])
161
+ self._state["tables"] = [t for t in tables if t["name"] != name]
162
+ self._write_log.append({"op": "table_delete", "data": {"name": name}})
163
+
164
+ # --- Columns ---
165
+
166
+ def column_add(self, table: str, name: str, data_type: str, **kwargs: Any) -> dict[str, Any]:
167
+ record = {"table": table, "name": name, "dataType": data_type, **kwargs}
168
+ self._state.setdefault("columns", []).append(record)
169
+ self._write_log.append({"op": "column_add", "data": record})
170
+ return dict(record)
171
+
172
+ def column_delete(self, table: str, name: str) -> None:
173
+ cols = self._state.get("columns", [])
174
+ self._state["columns"] = [
175
+ c for c in cols if not (c["table"] == table and c["name"] == name)
176
+ ]
177
+ self._write_log.append({"op": "column_delete", "data": {"table": table, "name": name}})
178
+
179
+ # --- Relationships ---
180
+
181
+ def relationship_add(
182
+ self, from_table: str, from_column: str, to_table: str, to_column: str, **kwargs: Any
183
+ ) -> dict[str, Any]:
184
+ record = {
185
+ "from": f"{from_table}[{from_column}]",
186
+ "to": f"{to_table}[{to_column}]",
187
+ "cardinality": kwargs.get("cardinality", "ManyToOne"),
188
+ **kwargs,
189
+ }
190
+ self._state.setdefault("relationships", []).append(record)
191
+ self._write_log.append({"op": "relationship_add", "data": record})
192
+ return dict(record)
193
+
194
+ # --- Hierarchies ---
195
+
196
+ def hierarchy_list(self, table: str | None = None) -> list[dict[str, Any]]:
197
+ hierarchies = self._state.get("hierarchies", [])
198
+ if table:
199
+ return [h for h in hierarchies if h["table"] == table]
200
+ return list(hierarchies)
201
+
202
+ def hierarchy_add(self, table: str, name: str, levels: list[dict[str, Any]]) -> dict[str, Any]:
203
+ record = {"table": table, "name": name, "levels": levels}
204
+ self._state.setdefault("hierarchies", []).append(record)
205
+ self._write_log.append({"op": "hierarchy_add", "data": record})
206
+ return dict(record)
207
+
208
+ def hierarchy_delete(self, table: str, name: str) -> None:
209
+ self._state["hierarchies"] = [
210
+ h
211
+ for h in self._state.get("hierarchies", [])
212
+ if not (h["table"] == table and h["name"] == name)
213
+ ]
214
+ self._write_log.append({"op": "hierarchy_delete", "data": {"table": table, "name": name}})
215
+
216
+ # --- Calculation Groups ---
217
+
218
+ def calc_group_list(self) -> list[dict[str, Any]]:
219
+ return list(self._state.get("calc_groups", []))
220
+
221
+ def calc_group_add(self, name: str, precedence: int = 0) -> dict[str, Any]:
222
+ record = {"table": name, "precedence": precedence, "items": []}
223
+ self._state.setdefault("calc_groups", []).append(record)
224
+ self._write_log.append({"op": "calc_group_add", "data": record})
225
+ return dict(record)
226
+
227
+ def calc_item_add(
228
+ self, group_table: str, name: str, expression: str, ordinal: int = 0
229
+ ) -> dict[str, Any]:
230
+ record = {"group": group_table, "name": name, "expression": expression, "ordinal": ordinal}
231
+ for cg in self._state.get("calc_groups", []):
232
+ if cg["table"] == group_table:
233
+ cg.setdefault("items", []).append(record)
234
+ self._write_log.append({"op": "calc_item_add", "data": record})
235
+ return dict(record)
236
+
237
+ def calc_item_delete(self, group_table: str, name: str) -> None:
238
+ for cg in self._state.get("calc_groups", []):
239
+ if cg["table"] == group_table:
240
+ cg["items"] = [i for i in cg.get("items", []) if i["name"] != name]
241
+ self._write_log.append(
242
+ {"op": "calc_item_delete", "data": {"group": group_table, "name": name}}
243
+ )
244
+
245
+ # --- RLS Roles ---
246
+
247
+ def role_list(self) -> list[dict[str, Any]]:
248
+ return list(self._state.get("roles", []))
249
+
250
+ def role_add(self, name: str, table: str, filter_expression: str) -> dict[str, Any]:
251
+ record = {
252
+ "name": name,
253
+ "modelPermission": "Read",
254
+ "tablePermissions": [{"table": table, "filterExpression": filter_expression}],
255
+ }
256
+ self._state.setdefault("roles", []).append(record)
257
+ self._write_log.append({"op": "role_add", "data": record})
258
+ return dict(record)
259
+
260
+ def role_delete(self, name: str) -> None:
261
+ self._state["roles"] = [r for r in self._state.get("roles", []) if r["name"] != name]
262
+ self._write_log.append({"op": "role_delete", "data": {"name": name}})
263
+
264
+ def role_test(self, role_name: str, dax_expression: str) -> dict[str, Any]:
265
+ return {
266
+ "role": role_name,
267
+ "rowCount": 1,
268
+ "rows": [{"__mock": True, "expression": dax_expression}],
269
+ }
270
+
271
+ # --- Partitions ---
272
+
273
+ def partition_list(self, table: str | None = None) -> list[dict[str, Any]]:
274
+ partitions = self._state.get("partitions", [])
275
+ if table:
276
+ return [p for p in partitions if p["table"] == table]
277
+ return list(partitions)
278
+
279
+ def partition_add(self, table: str, name: str, query: str) -> dict[str, Any]:
280
+ record = {"table": table, "name": name, "mode": "Import", "state": "Ready", "source": query}
281
+ self._state.setdefault("partitions", []).append(record)
282
+ self._write_log.append({"op": "partition_add", "data": record})
283
+ return dict(record)
284
+
285
+ def partition_delete(self, table: str, name: str) -> None:
286
+ self._state["partitions"] = [
287
+ p
288
+ for p in self._state.get("partitions", [])
289
+ if not (p["table"] == table and p["name"] == name)
290
+ ]
291
+ self._write_log.append({"op": "partition_delete", "data": {"table": table, "name": name}})
292
+
293
+ def partition_refresh(self, table: str, name: str) -> dict[str, Any]:
294
+ self._write_log.append({"op": "partition_refresh", "data": {"table": table, "name": name}})
295
+ return {"table": table, "partition": name, "status": "refresh_requested"}
296
+
297
+ # --- Model Diff ---
298
+
299
+ def model_diff(self, snapshot_path: str) -> dict[str, Any]:
300
+ return {
301
+ "snapshot": snapshot_path,
302
+ "added": [],
303
+ "removed": [],
304
+ "changed": [],
305
+ "unchanged_count": 0,
306
+ "has_changes": False,
307
+ }
308
+
309
+ # --- DAX ---
310
+
311
+ def dax_query(self, expression: str) -> list[dict[str, Any]]:
312
+ return [{"__result": "mock", "expression": expression}]
313
+
314
+ def dax_validate(self, expression: str) -> dict[str, Any]:
315
+ return {"valid": True, "expression": expression}
316
+
317
+ # --- TMDL ---
318
+
319
+ def tmdl_export(self, path: str) -> None:
320
+ self._write_log.append({"op": "tmdl_export", "data": {"path": path}})
321
+
322
+ def tmdl_import(self, path: str) -> None:
323
+ self._write_log.append({"op": "tmdl_import", "data": {"path": path}})