pytableau 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.
Files changed (61) hide show
  1. pytableau/__init__.py +93 -0
  2. pytableau/_compat.py +46 -0
  3. pytableau/_version.py +1 -0
  4. pytableau/cli/__init__.py +15 -0
  5. pytableau/cli/main.py +137 -0
  6. pytableau/constants.py +212 -0
  7. pytableau/core/__init__.py +38 -0
  8. pytableau/core/dashboard.py +337 -0
  9. pytableau/core/datasource.py +470 -0
  10. pytableau/core/fields.py +398 -0
  11. pytableau/core/filters.py +331 -0
  12. pytableau/core/formatting.py +7 -0
  13. pytableau/core/workbook.py +352 -0
  14. pytableau/core/worksheet.py +464 -0
  15. pytableau/data/__init__.py +23 -0
  16. pytableau/data/bridge.py +157 -0
  17. pytableau/data/extract.py +114 -0
  18. pytableau/data/types.py +81 -0
  19. pytableau/exceptions.py +144 -0
  20. pytableau/inspect/__init__.py +19 -0
  21. pytableau/inspect/catalog.py +100 -0
  22. pytableau/inspect/diff.py +7 -0
  23. pytableau/inspect/lineage.py +121 -0
  24. pytableau/inspect/report.py +119 -0
  25. pytableau/package/__init__.py +12 -0
  26. pytableau/package/assets.py +7 -0
  27. pytableau/package/manager.py +146 -0
  28. pytableau/py.typed +0 -0
  29. pytableau/server/__init__.py +13 -0
  30. pytableau/server/client.py +174 -0
  31. pytableau/server/workflows.py +67 -0
  32. pytableau/templates/__init__.py +24 -0
  33. pytableau/templates/engine.py +131 -0
  34. pytableau/templates/library/__init__.py +58 -0
  35. pytableau/templates/library/bar_chart.twb +28 -0
  36. pytableau/templates/library/heatmap.twb +29 -0
  37. pytableau/templates/library/kpi_dashboard.twb +25 -0
  38. pytableau/templates/library/line_chart.twb +28 -0
  39. pytableau/templates/library/map.twb +28 -0
  40. pytableau/templates/library/scatter_plot.twb +29 -0
  41. pytableau/templates/library/treemap.twb +29 -0
  42. pytableau/templates/mapping.py +65 -0
  43. pytableau/xml/__init__.py +7 -0
  44. pytableau/xml/differ.py +7 -0
  45. pytableau/xml/discovery/__init__.py +11 -0
  46. pytableau/xml/discovery/controlled_diff.py +43 -0
  47. pytableau/xml/discovery/corpus.py +53 -0
  48. pytableau/xml/engine.py +130 -0
  49. pytableau/xml/proxy.py +37 -0
  50. pytableau/xml/schemas/__init__.py +11 -0
  51. pytableau/xml/schemas/base.py +7 -0
  52. pytableau/xml/schemas/v2022.py +7 -0
  53. pytableau/xml/schemas/v2023.py +7 -0
  54. pytableau/xml/schemas/v2024.py +7 -0
  55. pytableau/xml/schemas/v2025.py +7 -0
  56. pytableau/xml/writer.py +7 -0
  57. pytableau-0.1.0.dist-info/METADATA +177 -0
  58. pytableau-0.1.0.dist-info/RECORD +61 -0
  59. pytableau-0.1.0.dist-info/WHEEL +4 -0
  60. pytableau-0.1.0.dist-info/entry_points.txt +2 -0
  61. pytableau-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,337 @@
1
+ """Dashboard, Zone, Action, and DashboardObject objects.
2
+
3
+ .. note::
4
+ Full implementation is tracked in Phases 1–2 of the development plan.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING
11
+
12
+ from lxml import etree
13
+
14
+ from pytableau.constants import ActionType, DashboardSizeType
15
+ from pytableau.core.datasource import _normalise_field_name
16
+ from pytableau.xml.proxy import XMLNodeProxy
17
+
18
+ if TYPE_CHECKING:
19
+ from pytableau.core.workbook import Workbook
20
+
21
+
22
+ @dataclass
23
+ class DashboardSize:
24
+ width: int | float | None
25
+ height: int | float | None
26
+ type: str
27
+
28
+
29
+ @dataclass
30
+ class Zone:
31
+ zone_type: str
32
+ name: str
33
+ x: int | float | None
34
+ y: int | float | None
35
+ w: int | float | None
36
+ h: int | float | None
37
+ children: list[Zone]
38
+
39
+ def replace_field_reference(self, old: str, new: str) -> None:
40
+ _ = old, new # future extension point
41
+
42
+
43
+ @dataclass
44
+ class Action:
45
+ action_type: str
46
+ name: str | None
47
+ target_sheet: str | None
48
+ source_sheet: str | None
49
+ xml_node: etree._Element
50
+
51
+ @property
52
+ def fields(self) -> list[str]:
53
+ field = self.xml_node.get("field")
54
+ if field:
55
+ return [_normalise_field_name(field)]
56
+ out: list[str] = []
57
+ for field_node in self.xml_node.findall("field"):
58
+ if field_node.get("name"):
59
+ out.append(_normalise_field_name(field_node.get("name") or ""))
60
+ elif field_node.text:
61
+ out.append(_normalise_field_name(field_node.text))
62
+ return out
63
+
64
+ def replace_field_reference(self, old: str, new: str) -> None:
65
+ old_norm = _normalise_field_name(old)
66
+ new_token = _normalise_field_name(new)
67
+ if self.xml_node.get("field") and _normalise_field_name(self.xml_node.get("field", "")) == old_norm:
68
+ self.xml_node.set("field", f"[{new_token}]")
69
+ for node in self.xml_node.findall("field"):
70
+ if _normalise_field_name(node.get("name", node.text or "")) == old_norm:
71
+ if node.get("name") is not None:
72
+ node.set("name", f"[{new_token}]")
73
+ if node.text is not None:
74
+ node.text = f"[{new_token}]"
75
+
76
+ def remove_field_reference(self, field: str) -> None:
77
+ target = _normalise_field_name(field)
78
+ if self.xml_node.get("field") and _normalise_field_name(self.xml_node.get("field") or "") == target:
79
+ self.xml_node.attrib.pop("field", None)
80
+ for node in self.xml_node.findall("field"):
81
+ if _normalise_field_name(node.get("name", node.text or "")) == target:
82
+ parent = node.getparent()
83
+ if parent is not None:
84
+ parent.remove(node)
85
+
86
+
87
+ class Dashboard(XMLNodeProxy):
88
+ """Read-only dashboard wrapper with simple parsing helpers."""
89
+
90
+ def __init__(self, node: etree._Element, workbook: Workbook | None = None) -> None:
91
+ super().__init__(node)
92
+ self._workbook = workbook
93
+ self.name = self.xml_node.get("name", "")
94
+ self.size = self._read_size()
95
+ self.zones = self._read_zones()
96
+ self.actions = self._read_actions()
97
+
98
+ def _read_size(self) -> DashboardSize:
99
+ size_node = self.xml_node.find("size")
100
+ if size_node is None:
101
+ return DashboardSize(None, None, DashboardSizeType.AUTOMATIC.value)
102
+ return DashboardSize(
103
+ width=_to_number(size_node.get("width")),
104
+ height=_to_number(size_node.get("height")),
105
+ type=size_node.get("type", DashboardSizeType.AUTOMATIC.value),
106
+ )
107
+
108
+ def _read_zones(self) -> list[Zone]:
109
+ zone_nodes = self.xml_node.findall("zone")
110
+ return [self._read_zone(node) for node in zone_nodes]
111
+
112
+ def _read_zone(self, node: etree._Element) -> Zone:
113
+ children = [self._read_zone(child) for child in node.findall("zone")]
114
+ return Zone(
115
+ zone_type=node.get("type", ""),
116
+ name=node.get("name", ""),
117
+ x=_to_number(node.get("x")),
118
+ y=_to_number(node.get("y")),
119
+ w=_to_number(node.get("w")),
120
+ h=_to_number(node.get("h")),
121
+ children=children,
122
+ )
123
+
124
+ def _read_actions(self) -> list[Action]:
125
+ actions_node = self.xml_node.find("actions")
126
+ if actions_node is None:
127
+ return []
128
+ out: list[Action] = []
129
+ for action in actions_node.findall("action"):
130
+ out.append(
131
+ Action(
132
+ action_type=action.get("type") or action.get("class") or ActionType.FILTER.value,
133
+ name=action.get("name"),
134
+ target_sheet=action.get("target-sheet") or action.get("targetSheet"),
135
+ source_sheet=action.get("source-sheet") or action.get("sourceSheet"),
136
+ xml_node=action,
137
+ )
138
+ )
139
+ return out
140
+
141
+ def _ensure_actions_node(self) -> etree._Element:
142
+ actions = self.xml_node.find("actions")
143
+ if actions is None:
144
+ actions = etree.SubElement(self.xml_node, "actions")
145
+ return actions
146
+
147
+ def _append_action(self, action_type: str, name: str, field: str | None = None) -> Action:
148
+ actions = self._ensure_actions_node()
149
+ payload = {"type": action_type, "name": name}
150
+ if field is not None:
151
+ payload["field"] = field
152
+ etree.SubElement(actions, "action", attrib=payload)
153
+ self.actions = self._read_actions()
154
+ action = self.actions[-1]
155
+ return action
156
+
157
+ def add_filter_action(
158
+ self,
159
+ name: str,
160
+ *,
161
+ field: str,
162
+ source_sheet: str | None = None,
163
+ target_sheet: str | None = None,
164
+ ) -> Action:
165
+ action_node = self._append_action(ActionType.FILTER.value, name, field=f"[{field}]")
166
+ if source_sheet:
167
+ action_node.xml_node.set("source-sheet", source_sheet)
168
+ if target_sheet:
169
+ action_node.xml_node.set("target-sheet", target_sheet)
170
+ return action_node
171
+
172
+ def add_highlight_action(
173
+ self,
174
+ name: str,
175
+ *,
176
+ field: str,
177
+ source_sheet: str | None = None,
178
+ target_sheet: str | None = None,
179
+ ) -> Action:
180
+ action_node = self._append_action(ActionType.HIGHLIGHT.value, name, field=f"[{field}]")
181
+ if source_sheet:
182
+ action_node.xml_node.set("source-sheet", source_sheet)
183
+ if target_sheet:
184
+ action_node.xml_node.set("target-sheet", target_sheet)
185
+ return action_node
186
+
187
+ def add_url_action(self, name: str, url: str, *, source_sheet: str | None = None) -> Action:
188
+ action_node = self._append_action(ActionType.URL.value, name)
189
+ action_node.xml_node.set("url", url)
190
+ if source_sheet:
191
+ action_node.xml_node.set("source-sheet", source_sheet)
192
+ return action_node
193
+
194
+ def remove_action(self, name: str) -> int:
195
+ removed = 0
196
+ target = self.xml_node.find("actions")
197
+ if target is None:
198
+ return 0
199
+ for action in list(target.findall("action")):
200
+ if action.get("name") == name:
201
+ target.remove(action)
202
+ removed += 1
203
+ if removed:
204
+ self.actions = self._read_actions()
205
+ return removed
206
+
207
+ def add_zone(
208
+ self,
209
+ zone_type: str,
210
+ name: str,
211
+ *,
212
+ x: int | float | None = None,
213
+ y: int | float | None = None,
214
+ w: int | float | None = None,
215
+ h: int | float | None = None,
216
+ parent_name: str | None = None,
217
+ ) -> Zone:
218
+ attrs = {
219
+ "type": zone_type,
220
+ "name": name,
221
+ }
222
+ if x is not None:
223
+ attrs["x"] = str(x)
224
+ if y is not None:
225
+ attrs["y"] = str(y)
226
+ if w is not None:
227
+ attrs["w"] = str(w)
228
+ if h is not None:
229
+ attrs["h"] = str(h)
230
+
231
+ parent = None
232
+ if parent_name is not None:
233
+ for candidate in self.xml_node.findall(".//zone"):
234
+ if candidate.get("name") == parent_name:
235
+ parent = candidate
236
+ break
237
+ if parent is None:
238
+ parent = self.xml_node
239
+ etree.SubElement(parent, "zone", attrib=attrs)
240
+ self.zones = self._read_zones()
241
+ return Zone(
242
+ zone_type=zone_type,
243
+ name=name,
244
+ x=x,
245
+ y=y,
246
+ w=w,
247
+ h=h,
248
+ children=[],
249
+ )
250
+
251
+ def remove_zone(self, name: str) -> int:
252
+ removed = 0
253
+ for node in list(self.xml_node.findall(".//zone")):
254
+ if node.get("name") == name:
255
+ parent = node.getparent()
256
+ if parent is not None:
257
+ parent.remove(node)
258
+ removed += 1
259
+ if removed:
260
+ self.zones = self._read_zones()
261
+ return removed
262
+
263
+ def move_zone(
264
+ self,
265
+ name: str,
266
+ *,
267
+ x: int | float | None = None,
268
+ y: int | float | None = None,
269
+ ) -> int:
270
+ count = 0
271
+ for node in self.xml_node.findall(".//zone"):
272
+ if node.get("name") == name:
273
+ if x is not None:
274
+ node.set("x", str(x))
275
+ if y is not None:
276
+ node.set("y", str(y))
277
+ count += 1
278
+ if count:
279
+ self.zones = self._read_zones()
280
+ return count
281
+
282
+ def replace_field_reference(self, old: str, new: str) -> None:
283
+ old_norm = _normalise_field_name(old)
284
+ new_token = _normalise_field_name(new)
285
+ for action in self.actions:
286
+ action.replace_field_reference(old_norm, new_token)
287
+
288
+ def remove_field_reference(self, field: str) -> None:
289
+ for action in list(self.actions):
290
+ action.remove_field_reference(field)
291
+
292
+
293
+ class DashboardCollection:
294
+ """Ordered, dict-like dashboard collection."""
295
+
296
+ def __init__(self, dashboards: list[Dashboard]) -> None:
297
+ self._items = list(dashboards)
298
+
299
+ @property
300
+ def names(self) -> list[str]:
301
+ return [dashboard.name for dashboard in self._items]
302
+
303
+ def __iter__(self):
304
+ return iter(self._items)
305
+
306
+ def __len__(self) -> int:
307
+ return len(self._items)
308
+
309
+ def __getitem__(self, key: int | str) -> Dashboard:
310
+ if isinstance(key, int):
311
+ return self._items[key]
312
+ if not isinstance(key, str):
313
+ raise TypeError("dashboard key must be int index or dashboard name")
314
+ for dashboard in self._items:
315
+ if dashboard.name == key:
316
+ return dashboard
317
+ raise KeyError(key)
318
+
319
+
320
+ def _to_number(value: str | None) -> int | float | None:
321
+ if value is None:
322
+ return None
323
+ try:
324
+ if "." in value:
325
+ return float(value)
326
+ return int(value)
327
+ except ValueError:
328
+ return None
329
+
330
+
331
+ __all__ = [
332
+ "Dashboard",
333
+ "DashboardCollection",
334
+ "DashboardSize",
335
+ "Zone",
336
+ "Action",
337
+ ]