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.
- pytableau/__init__.py +93 -0
- pytableau/_compat.py +46 -0
- pytableau/_version.py +1 -0
- pytableau/cli/__init__.py +15 -0
- pytableau/cli/main.py +137 -0
- pytableau/constants.py +212 -0
- pytableau/core/__init__.py +38 -0
- pytableau/core/dashboard.py +337 -0
- pytableau/core/datasource.py +470 -0
- pytableau/core/fields.py +398 -0
- pytableau/core/filters.py +331 -0
- pytableau/core/formatting.py +7 -0
- pytableau/core/workbook.py +352 -0
- pytableau/core/worksheet.py +464 -0
- pytableau/data/__init__.py +23 -0
- pytableau/data/bridge.py +157 -0
- pytableau/data/extract.py +114 -0
- pytableau/data/types.py +81 -0
- pytableau/exceptions.py +144 -0
- pytableau/inspect/__init__.py +19 -0
- pytableau/inspect/catalog.py +100 -0
- pytableau/inspect/diff.py +7 -0
- pytableau/inspect/lineage.py +121 -0
- pytableau/inspect/report.py +119 -0
- pytableau/package/__init__.py +12 -0
- pytableau/package/assets.py +7 -0
- pytableau/package/manager.py +146 -0
- pytableau/py.typed +0 -0
- pytableau/server/__init__.py +13 -0
- pytableau/server/client.py +174 -0
- pytableau/server/workflows.py +67 -0
- pytableau/templates/__init__.py +24 -0
- pytableau/templates/engine.py +131 -0
- pytableau/templates/library/__init__.py +58 -0
- pytableau/templates/library/bar_chart.twb +28 -0
- pytableau/templates/library/heatmap.twb +29 -0
- pytableau/templates/library/kpi_dashboard.twb +25 -0
- pytableau/templates/library/line_chart.twb +28 -0
- pytableau/templates/library/map.twb +28 -0
- pytableau/templates/library/scatter_plot.twb +29 -0
- pytableau/templates/library/treemap.twb +29 -0
- pytableau/templates/mapping.py +65 -0
- pytableau/xml/__init__.py +7 -0
- pytableau/xml/differ.py +7 -0
- pytableau/xml/discovery/__init__.py +11 -0
- pytableau/xml/discovery/controlled_diff.py +43 -0
- pytableau/xml/discovery/corpus.py +53 -0
- pytableau/xml/engine.py +130 -0
- pytableau/xml/proxy.py +37 -0
- pytableau/xml/schemas/__init__.py +11 -0
- pytableau/xml/schemas/base.py +7 -0
- pytableau/xml/schemas/v2022.py +7 -0
- pytableau/xml/schemas/v2023.py +7 -0
- pytableau/xml/schemas/v2024.py +7 -0
- pytableau/xml/schemas/v2025.py +7 -0
- pytableau/xml/writer.py +7 -0
- pytableau-0.1.0.dist-info/METADATA +177 -0
- pytableau-0.1.0.dist-info/RECORD +61 -0
- pytableau-0.1.0.dist-info/WHEEL +4 -0
- pytableau-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|