tableau-cli 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.
- tableau_cli/__init__.py +0 -0
- tableau_cli/api/__init__.py +0 -0
- tableau_cli/api/client.py +429 -0
- tableau_cli/auth/__init__.py +0 -0
- tableau_cli/auth/with_auth.py +38 -0
- tableau_cli/cli.py +120 -0
- tableau_cli/commands/__init__.py +0 -0
- tableau_cli/commands/config_cmd.py +48 -0
- tableau_cli/commands/convert_cmd.py +135 -0
- tableau_cli/commands/datasources_cmd.py +132 -0
- tableau_cli/commands/search_cmd.py +39 -0
- tableau_cli/commands/views_cmd.py +83 -0
- tableau_cli/commands/workbooks_cmd.py +59 -0
- tableau_cli/config/__init__.py +0 -0
- tableau_cli/config/store.py +59 -0
- tableau_cli/config/types.py +19 -0
- tableau_cli/errors/__init__.py +0 -0
- tableau_cli/errors/cli_error.py +44 -0
- tableau_cli/errors/vds_error_handler.py +155 -0
- tableau_cli/output/__init__.py +0 -0
- tableau_cli/output/format.py +13 -0
- tableau_cli/output/json_output.py +9 -0
- tableau_cli/output/table_output.py +60 -0
- tableau_cli/utils/__init__.py +0 -0
- tableau_cli/utils/datasource_metadata_utils.py +229 -0
- tableau_cli/utils/paginate.py +42 -0
- tableau_cli/utils/search_content_utils.py +104 -0
- tableau_cli-0.1.0.dist-info/METADATA +226 -0
- tableau_cli-0.1.0.dist-info/RECORD +33 -0
- tableau_cli-0.1.0.dist-info/WHEEL +4 -0
- tableau_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tableau_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
- tableau_cli-0.1.0.dist-info/licenses/NOTICE +8 -0
tableau_cli/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ..errors.cli_error import CliError, FeatureDisabledError
|
|
10
|
+
from ..errors.vds_error_handler import handle_vds_error
|
|
11
|
+
|
|
12
|
+
API_VERSION = "3.24"
|
|
13
|
+
TIMEOUT_S = 120
|
|
14
|
+
|
|
15
|
+
VDS_DISABLED_MESSAGE = (
|
|
16
|
+
"The VizQL Data Service is disabled on this Tableau Server. "
|
|
17
|
+
"To enable it, use TSM using the instructions at "
|
|
18
|
+
"https://help.tableau.com/current/server-linux/en-us/cli_configuration-set_tsm.htm"
|
|
19
|
+
"#featuresvizqldataservicedeploywithtsm."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Credentials:
|
|
25
|
+
site_id: str
|
|
26
|
+
user_id: str
|
|
27
|
+
token: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RestApi:
|
|
31
|
+
def __init__(self, host: str) -> None:
|
|
32
|
+
self._host = host.rstrip("/")
|
|
33
|
+
self._creds: Credentials | None = None
|
|
34
|
+
self._client = httpx.Client(
|
|
35
|
+
timeout=TIMEOUT_S,
|
|
36
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def _base_url(self) -> str:
|
|
41
|
+
return f"{self._host}/api/{API_VERSION}"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def _base_url_no_version(self) -> str:
|
|
45
|
+
return f"{self._host}/api/-"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def _creds_required(self) -> Credentials:
|
|
49
|
+
if not self._creds:
|
|
50
|
+
raise RuntimeError("Not authenticated. Call sign_in() first.")
|
|
51
|
+
return self._creds
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def site_id(self) -> str:
|
|
55
|
+
return self._creds_required.site_id
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def user_id(self) -> str:
|
|
59
|
+
return self._creds_required.user_id
|
|
60
|
+
|
|
61
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
62
|
+
return {"X-Tableau-Auth": self._creds_required.token}
|
|
63
|
+
|
|
64
|
+
# ── Authentication ──────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def sign_in(self, *, site_name: str, pat_name: str, pat_value: str) -> None:
|
|
67
|
+
url = f"{self._base_url}/auth/signin"
|
|
68
|
+
body = {
|
|
69
|
+
"credentials": {
|
|
70
|
+
"site": {"contentUrl": site_name},
|
|
71
|
+
"personalAccessTokenName": pat_name,
|
|
72
|
+
"personalAccessTokenSecret": pat_value,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
resp = self._client.post(url, json=body)
|
|
76
|
+
resp.raise_for_status()
|
|
77
|
+
creds = resp.json()["credentials"]
|
|
78
|
+
self._creds = Credentials(
|
|
79
|
+
site_id=creds["site"]["id"],
|
|
80
|
+
user_id=creds["user"]["id"],
|
|
81
|
+
token=creds["token"],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def sign_out(self) -> None:
|
|
85
|
+
if not self._creds:
|
|
86
|
+
return
|
|
87
|
+
url = f"{self._base_url}/auth/signout"
|
|
88
|
+
with contextlib.suppress(Exception):
|
|
89
|
+
self._client.post(url, headers=self._auth_headers())
|
|
90
|
+
self._creds = None
|
|
91
|
+
|
|
92
|
+
# ── Datasources (REST API) ──────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def list_datasources(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
site_id: str,
|
|
98
|
+
filter_: str = "",
|
|
99
|
+
page_size: int | None = None,
|
|
100
|
+
page_number: int | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
url = f"{self._base_url}/sites/{site_id}/datasources"
|
|
103
|
+
params: dict[str, Any] = {}
|
|
104
|
+
if filter_:
|
|
105
|
+
params["filter"] = filter_
|
|
106
|
+
if page_size is not None:
|
|
107
|
+
params["pageSize"] = page_size
|
|
108
|
+
if page_number is not None:
|
|
109
|
+
params["pageNumber"] = page_number
|
|
110
|
+
resp = self._client.get(url, params=params, headers=self._auth_headers())
|
|
111
|
+
resp.raise_for_status()
|
|
112
|
+
data = resp.json()
|
|
113
|
+
raw_list = data.get("datasources", {}).get("datasource") or []
|
|
114
|
+
return {
|
|
115
|
+
"pagination": _parse_pagination(data["pagination"]),
|
|
116
|
+
"datasources": [_shape_datasource(d) for d in raw_list],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def download_datasource(self, *, datasource_id: str, site_id: str) -> tuple[bytes, str]:
|
|
120
|
+
"""Download datasource content. Returns (data, filename)."""
|
|
121
|
+
url = f"{self._base_url}/sites/{site_id}/datasources/{datasource_id}/content"
|
|
122
|
+
resp = self._client.get(url, headers=self._auth_headers())
|
|
123
|
+
resp.raise_for_status()
|
|
124
|
+
|
|
125
|
+
# Extract filename from Content-Disposition header
|
|
126
|
+
filename = f"{datasource_id}.tdsx"
|
|
127
|
+
cd = resp.headers.get("content-disposition", "")
|
|
128
|
+
if "filename*=" in cd:
|
|
129
|
+
# RFC 5987: filename*=UTF-8''encoded_name.tdsx
|
|
130
|
+
part = cd.split("filename*=", 1)[1].split(";", 1)[0].strip()
|
|
131
|
+
if "''" in part:
|
|
132
|
+
from urllib.parse import unquote
|
|
133
|
+
|
|
134
|
+
filename = unquote(part.split("''", 1)[1])
|
|
135
|
+
elif "filename=" in cd:
|
|
136
|
+
from urllib.parse import unquote_plus
|
|
137
|
+
|
|
138
|
+
part = cd.split("filename=", 1)[1].split(";", 1)[0].strip()
|
|
139
|
+
filename = unquote_plus(part.strip('"'))
|
|
140
|
+
|
|
141
|
+
return resp.content, filename
|
|
142
|
+
|
|
143
|
+
# ── Views (REST API) ────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def query_views_for_site(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
site_id: str,
|
|
149
|
+
filter_: str = "",
|
|
150
|
+
page_size: int | None = None,
|
|
151
|
+
page_number: int | None = None,
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
url = f"{self._base_url}/sites/{site_id}/views"
|
|
154
|
+
params: dict[str, Any] = {"includeUsageStatistics": "true"}
|
|
155
|
+
if filter_:
|
|
156
|
+
params["filter"] = filter_
|
|
157
|
+
if page_size is not None:
|
|
158
|
+
params["pageSize"] = page_size
|
|
159
|
+
if page_number is not None:
|
|
160
|
+
params["pageNumber"] = page_number
|
|
161
|
+
resp = self._client.get(url, params=params, headers=self._auth_headers())
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
data = resp.json()
|
|
164
|
+
raw_list = data.get("views", {}).get("view") or []
|
|
165
|
+
return {
|
|
166
|
+
"pagination": _parse_pagination(data["pagination"]),
|
|
167
|
+
"views": [_shape_view(v) for v in raw_list],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def query_view_data(self, *, view_id: str, site_id: str) -> str:
|
|
171
|
+
url = f"{self._base_url}/sites/{site_id}/views/{view_id}/data"
|
|
172
|
+
resp = self._client.get(url, headers=self._auth_headers())
|
|
173
|
+
resp.raise_for_status()
|
|
174
|
+
return resp.text
|
|
175
|
+
|
|
176
|
+
def query_view_image(
|
|
177
|
+
self,
|
|
178
|
+
*,
|
|
179
|
+
view_id: str,
|
|
180
|
+
site_id: str,
|
|
181
|
+
width: int | None = None,
|
|
182
|
+
height: int | None = None,
|
|
183
|
+
format_: str = "PNG",
|
|
184
|
+
) -> bytes:
|
|
185
|
+
url = f"{self._base_url}/sites/{site_id}/views/{view_id}/image"
|
|
186
|
+
params: dict[str, Any] = {"resolution": "high"}
|
|
187
|
+
if width is not None:
|
|
188
|
+
params["vizWidth"] = width
|
|
189
|
+
if height is not None:
|
|
190
|
+
params["vizHeight"] = height
|
|
191
|
+
if format_:
|
|
192
|
+
params["format"] = format_
|
|
193
|
+
resp = self._client.get(url, params=params, headers=self._auth_headers())
|
|
194
|
+
if resp.status_code >= 400:
|
|
195
|
+
error_data: dict[str, Any] = {}
|
|
196
|
+
try:
|
|
197
|
+
error_data = resp.json()
|
|
198
|
+
except Exception:
|
|
199
|
+
try:
|
|
200
|
+
error_data = {"error": None}
|
|
201
|
+
text = resp.text
|
|
202
|
+
import json as _json
|
|
203
|
+
|
|
204
|
+
error_data = _json.loads(text)
|
|
205
|
+
except Exception:
|
|
206
|
+
resp.raise_for_status()
|
|
207
|
+
if error_data.get("error", {}).get("code") == "403157":
|
|
208
|
+
is_svg = format_ == "SVG"
|
|
209
|
+
raise FeatureDisabledError(
|
|
210
|
+
"SVG format is not supported on this Tableau Server. It requires Tableau 2026.2.0 or later."
|
|
211
|
+
if is_svg
|
|
212
|
+
else "The image format feature is disabled on this Tableau Server.",
|
|
213
|
+
hint="Retry with --img-format PNG instead." if is_svg else None,
|
|
214
|
+
)
|
|
215
|
+
if error_data.get("error"):
|
|
216
|
+
err = error_data["error"]
|
|
217
|
+
summary = err.get("summary", "")
|
|
218
|
+
detail = err.get("detail", "")
|
|
219
|
+
raise CliError(
|
|
220
|
+
error_type="view-image-error",
|
|
221
|
+
message=f"{summary}: {detail}" if detail else summary,
|
|
222
|
+
)
|
|
223
|
+
resp.raise_for_status()
|
|
224
|
+
return resp.content
|
|
225
|
+
|
|
226
|
+
def query_views_for_workbook(self, *, workbook_id: str, site_id: str) -> list[dict[str, Any]]:
|
|
227
|
+
url = f"{self._base_url}/sites/{site_id}/workbooks/{workbook_id}/views"
|
|
228
|
+
params = {"includeUsageStatistics": "true"}
|
|
229
|
+
resp = self._client.get(url, params=params, headers=self._auth_headers())
|
|
230
|
+
resp.raise_for_status()
|
|
231
|
+
raw_list = resp.json()["views"]["view"]
|
|
232
|
+
return [_shape_view(v) for v in raw_list]
|
|
233
|
+
|
|
234
|
+
# ── Workbooks (REST API) ────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def get_workbook(self, *, workbook_id: str, site_id: str) -> dict[str, Any]:
|
|
237
|
+
url = f"{self._base_url}/sites/{site_id}/workbooks/{workbook_id}"
|
|
238
|
+
resp = self._client.get(url, headers=self._auth_headers())
|
|
239
|
+
resp.raise_for_status()
|
|
240
|
+
return _shape_workbook(resp.json()["workbook"])
|
|
241
|
+
|
|
242
|
+
def query_workbooks_for_site(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
site_id: str,
|
|
246
|
+
filter_: str = "",
|
|
247
|
+
page_size: int | None = None,
|
|
248
|
+
page_number: int | None = None,
|
|
249
|
+
) -> dict[str, Any]:
|
|
250
|
+
url = f"{self._base_url}/sites/{site_id}/workbooks"
|
|
251
|
+
params: dict[str, Any] = {}
|
|
252
|
+
if filter_:
|
|
253
|
+
params["filter"] = filter_
|
|
254
|
+
if page_size is not None:
|
|
255
|
+
params["pageSize"] = page_size
|
|
256
|
+
if page_number is not None:
|
|
257
|
+
params["pageNumber"] = page_number
|
|
258
|
+
resp = self._client.get(url, params=params, headers=self._auth_headers())
|
|
259
|
+
resp.raise_for_status()
|
|
260
|
+
data = resp.json()
|
|
261
|
+
raw_list = data.get("workbooks", {}).get("workbook") or []
|
|
262
|
+
return {
|
|
263
|
+
"pagination": _parse_pagination(data["pagination"]),
|
|
264
|
+
"workbooks": [_shape_workbook(w) for w in raw_list],
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# ── Content Exploration (Search) ────────────────────────────────
|
|
268
|
+
|
|
269
|
+
def search_content(
|
|
270
|
+
self,
|
|
271
|
+
*,
|
|
272
|
+
terms: str | None = None,
|
|
273
|
+
page: int | None = None,
|
|
274
|
+
limit: int | None = None,
|
|
275
|
+
order_by: str | None = None,
|
|
276
|
+
filter_: str | None = None,
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
url = f"{self._base_url_no_version}/search"
|
|
279
|
+
params: dict[str, Any] = {}
|
|
280
|
+
if terms is not None:
|
|
281
|
+
params["terms"] = terms
|
|
282
|
+
if page is not None:
|
|
283
|
+
params["page"] = page
|
|
284
|
+
if limit is not None:
|
|
285
|
+
params["limit"] = limit
|
|
286
|
+
if order_by is not None:
|
|
287
|
+
params["order_by"] = order_by
|
|
288
|
+
if filter_ is not None:
|
|
289
|
+
params["filter"] = filter_
|
|
290
|
+
resp = self._client.get(url, params=params, headers=self._auth_headers())
|
|
291
|
+
resp.raise_for_status()
|
|
292
|
+
return resp.json()["hits"]
|
|
293
|
+
|
|
294
|
+
# ── VizQL Data Service ──────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
def read_metadata(self, *, datasource_luid: str) -> dict[str, Any]:
|
|
297
|
+
url = f"{self._host}/api/v1/vizql-data-service/read-metadata"
|
|
298
|
+
body = {"datasource": {"datasourceLuid": datasource_luid}}
|
|
299
|
+
try:
|
|
300
|
+
resp = self._client.post(url, json=body, headers=self._auth_headers())
|
|
301
|
+
except httpx.TransportError as exc:
|
|
302
|
+
raise FeatureDisabledError(VDS_DISABLED_MESSAGE) from exc
|
|
303
|
+
if resp.status_code == 404:
|
|
304
|
+
raise FeatureDisabledError(VDS_DISABLED_MESSAGE)
|
|
305
|
+
resp.raise_for_status()
|
|
306
|
+
return resp.json()
|
|
307
|
+
|
|
308
|
+
def query_datasource(self, *, datasource_luid: str, query: dict, options: dict | None = None) -> dict[str, Any]:
|
|
309
|
+
url = f"{self._host}/api/v1/vizql-data-service/query-datasource"
|
|
310
|
+
body: dict[str, Any] = {
|
|
311
|
+
"datasource": {"datasourceLuid": datasource_luid},
|
|
312
|
+
"query": query,
|
|
313
|
+
}
|
|
314
|
+
if options:
|
|
315
|
+
body["options"] = options
|
|
316
|
+
try:
|
|
317
|
+
resp = self._client.post(url, json=body, headers=self._auth_headers())
|
|
318
|
+
except httpx.TransportError as exc:
|
|
319
|
+
raise FeatureDisabledError(VDS_DISABLED_MESSAGE) from exc
|
|
320
|
+
if resp.status_code == 404:
|
|
321
|
+
raise FeatureDisabledError(VDS_DISABLED_MESSAGE)
|
|
322
|
+
if resp.status_code >= 400:
|
|
323
|
+
try:
|
|
324
|
+
data = resp.json()
|
|
325
|
+
except Exception:
|
|
326
|
+
resp.raise_for_status()
|
|
327
|
+
return {} # unreachable
|
|
328
|
+
error_message = data.get("message", "Unknown Tableau error")
|
|
329
|
+
error_code = data.get("errorCode") or data.get("tab-error-code")
|
|
330
|
+
raise handle_vds_error(error_message, resp.status_code, error_code)
|
|
331
|
+
return resp.json()
|
|
332
|
+
|
|
333
|
+
# ── Metadata API (GraphQL) ──────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
def graphql(self, query: str) -> dict[str, Any]:
|
|
336
|
+
url = f"{self._host}/api/metadata/graphql"
|
|
337
|
+
resp = self._client.post(url, json={"query": query}, headers=self._auth_headers())
|
|
338
|
+
resp.raise_for_status()
|
|
339
|
+
return resp.json()
|
|
340
|
+
|
|
341
|
+
def close(self) -> None:
|
|
342
|
+
self._client.close()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _parse_pagination(p: dict[str, Any]) -> dict[str, int]:
|
|
346
|
+
return {
|
|
347
|
+
"pageNumber": int(p["pageNumber"]),
|
|
348
|
+
"pageSize": int(p["pageSize"]),
|
|
349
|
+
"totalAvailable": int(p["totalAvailable"]),
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ── Response shaping (equivalent to Zod schemas in TS version) ──────────
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _shape_project(raw: Any) -> dict[str, str]:
|
|
357
|
+
if not isinstance(raw, dict):
|
|
358
|
+
return {"name": "", "id": ""}
|
|
359
|
+
return {"name": raw.get("name", ""), "id": raw.get("id", "")}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _shape_tags(raw: Any) -> dict[str, Any]:
|
|
363
|
+
if not isinstance(raw, dict):
|
|
364
|
+
return {}
|
|
365
|
+
tag = raw.get("tag")
|
|
366
|
+
if isinstance(tag, list):
|
|
367
|
+
return {"tag": [{"label": t["label"]} for t in tag if isinstance(t, dict) and "label" in t]}
|
|
368
|
+
return {}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _shape_datasource(raw: dict[str, Any]) -> dict[str, Any]:
|
|
372
|
+
"""Match TS dataSourceSchema: {id, name, description?, project, tags}"""
|
|
373
|
+
result: dict[str, Any] = {
|
|
374
|
+
"id": raw.get("id", ""),
|
|
375
|
+
"name": raw.get("name", ""),
|
|
376
|
+
}
|
|
377
|
+
if "description" in raw:
|
|
378
|
+
result["description"] = raw["description"]
|
|
379
|
+
result["project"] = _shape_project(raw.get("project"))
|
|
380
|
+
result["tags"] = _shape_tags(raw.get("tags"))
|
|
381
|
+
return result
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _shape_view(raw: dict[str, Any]) -> dict[str, Any]:
|
|
385
|
+
"""Match TS viewSchema: {id, name, createdAt, updatedAt, workbook?, owner?, project?, tags, usage?}"""
|
|
386
|
+
result: dict[str, Any] = {
|
|
387
|
+
"id": raw.get("id", ""),
|
|
388
|
+
"name": raw.get("name", ""),
|
|
389
|
+
"createdAt": raw.get("createdAt", ""),
|
|
390
|
+
"updatedAt": raw.get("updatedAt", ""),
|
|
391
|
+
}
|
|
392
|
+
if "workbook" in raw and isinstance(raw["workbook"], dict):
|
|
393
|
+
result["workbook"] = {"id": raw["workbook"].get("id", "")}
|
|
394
|
+
if "owner" in raw and isinstance(raw["owner"], dict):
|
|
395
|
+
result["owner"] = {"id": raw["owner"].get("id", "")}
|
|
396
|
+
if "project" in raw and isinstance(raw["project"], dict):
|
|
397
|
+
result["project"] = {"id": raw["project"].get("id", "")}
|
|
398
|
+
result["tags"] = _shape_tags(raw.get("tags"))
|
|
399
|
+
if "usage" in raw and isinstance(raw["usage"], dict):
|
|
400
|
+
result["usage"] = {"totalViewCount": int(raw["usage"].get("totalViewCount", 0))}
|
|
401
|
+
return result
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _shape_workbook(raw: dict[str, Any]) -> dict[str, Any]:
|
|
405
|
+
"""Match TS workbookSchema: id, name, description?, webpageUrl?, contentUrl, project?, showTabs, etc."""
|
|
406
|
+
result: dict[str, Any] = {
|
|
407
|
+
"id": raw.get("id", ""),
|
|
408
|
+
"name": raw.get("name", ""),
|
|
409
|
+
}
|
|
410
|
+
if "description" in raw:
|
|
411
|
+
result["description"] = raw["description"]
|
|
412
|
+
if "webpageUrl" in raw:
|
|
413
|
+
result["webpageUrl"] = raw["webpageUrl"]
|
|
414
|
+
result["contentUrl"] = raw.get("contentUrl", "")
|
|
415
|
+
if "project" in raw:
|
|
416
|
+
result["project"] = _shape_project(raw.get("project"))
|
|
417
|
+
# z.coerce.boolean() — convert string "true"/"false" to bool
|
|
418
|
+
show_tabs = raw.get("showTabs", False)
|
|
419
|
+
if isinstance(show_tabs, str):
|
|
420
|
+
result["showTabs"] = show_tabs.lower() == "true"
|
|
421
|
+
else:
|
|
422
|
+
result["showTabs"] = bool(show_tabs)
|
|
423
|
+
if "defaultViewId" in raw:
|
|
424
|
+
result["defaultViewId"] = raw["defaultViewId"]
|
|
425
|
+
result["tags"] = _shape_tags(raw.get("tags"))
|
|
426
|
+
if "views" in raw and isinstance(raw["views"], dict):
|
|
427
|
+
views_list = raw["views"].get("view", [])
|
|
428
|
+
result["views"] = {"view": [_shape_view(v) for v in views_list] if isinstance(views_list, list) else []}
|
|
429
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ..api.client import RestApi
|
|
9
|
+
from ..config.types import Config
|
|
10
|
+
from ..errors.cli_error import AuthenticationError
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def with_auth(config: Config, fn: Callable[[RestApi], T]) -> T:
|
|
16
|
+
api = RestApi(config.server)
|
|
17
|
+
try:
|
|
18
|
+
api.sign_in(
|
|
19
|
+
site_name=config.site_name,
|
|
20
|
+
pat_name=config.pat_name,
|
|
21
|
+
pat_value=config.pat_value,
|
|
22
|
+
)
|
|
23
|
+
except httpx.HTTPStatusError as e:
|
|
24
|
+
status = e.response.status_code
|
|
25
|
+
if status in (401, 403):
|
|
26
|
+
raise AuthenticationError(f"Sign-in failed (HTTP {status}). The PAT may be expired or invalid.") from e
|
|
27
|
+
raise
|
|
28
|
+
except httpx.TransportError as e:
|
|
29
|
+
# Covers ConnectError, ConnectTimeout, ReadTimeout, PoolTimeout, DNS failures, etc.
|
|
30
|
+
# Equivalent to TS version's `if (!error.response)` check.
|
|
31
|
+
raise AuthenticationError(
|
|
32
|
+
f"Cannot connect to Tableau Server at {config.server}: {e}. Check the server URL."
|
|
33
|
+
) from e
|
|
34
|
+
try:
|
|
35
|
+
return fn(api)
|
|
36
|
+
finally:
|
|
37
|
+
api.sign_out()
|
|
38
|
+
api.close()
|
tableau_cli/cli.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors.cli_error import CliError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_error_output(err: Exception) -> dict[str, Any]:
|
|
14
|
+
# Our own structured errors
|
|
15
|
+
if isinstance(err, CliError):
|
|
16
|
+
return err.to_output()
|
|
17
|
+
|
|
18
|
+
# httpx HTTP errors (from Tableau REST API)
|
|
19
|
+
if isinstance(err, httpx.HTTPStatusError):
|
|
20
|
+
status = err.response.status_code
|
|
21
|
+
try:
|
|
22
|
+
data = err.response.json()
|
|
23
|
+
except Exception:
|
|
24
|
+
data = None
|
|
25
|
+
|
|
26
|
+
# Tableau REST API error format: { error: { summary, detail, code } }
|
|
27
|
+
if isinstance(data, dict) and data.get("error"):
|
|
28
|
+
error_info = data["error"]
|
|
29
|
+
summary = error_info.get("summary", "")
|
|
30
|
+
detail = error_info.get("detail", "")
|
|
31
|
+
code = error_info.get("code")
|
|
32
|
+
return {
|
|
33
|
+
"isError": True,
|
|
34
|
+
"errorType": "tableau-api-error",
|
|
35
|
+
"message": f"{summary}: {detail}" if detail else (summary or str(err)),
|
|
36
|
+
**({"details": f"Tableau error code: {code}"} if code else {}),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
"isError": True,
|
|
41
|
+
"errorType": "http-error",
|
|
42
|
+
"message": f"HTTP {status}: {err}",
|
|
43
|
+
**({"details": err.response.text} if err.response.text else {}),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# httpx transport errors (network unreachable, timeout, DNS failure, etc.)
|
|
47
|
+
# Equivalent to TS: isAxiosError(err) && !err.response
|
|
48
|
+
if isinstance(err, httpx.TransportError):
|
|
49
|
+
return {
|
|
50
|
+
"isError": True,
|
|
51
|
+
"errorType": "http-error",
|
|
52
|
+
"message": f"Network error: {err}",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Config validation errors
|
|
56
|
+
if isinstance(err, RuntimeError) and str(err).startswith("Missing required config:"):
|
|
57
|
+
return {
|
|
58
|
+
"isError": True,
|
|
59
|
+
"errorType": "config-error",
|
|
60
|
+
"message": str(err),
|
|
61
|
+
"hint": "Run `tableau-cli config set` to configure, or set environment variables.",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# JSON parse errors (bad --query input)
|
|
65
|
+
if isinstance(err, json.JSONDecodeError):
|
|
66
|
+
return {
|
|
67
|
+
"isError": True,
|
|
68
|
+
"errorType": "invalid-input",
|
|
69
|
+
"message": str(err),
|
|
70
|
+
"hint": "Check that your --query argument is valid JSON.",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Fallback
|
|
74
|
+
return {
|
|
75
|
+
"isError": True,
|
|
76
|
+
"errorType": "unknown-error",
|
|
77
|
+
"message": str(err),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TableauCli(click.Group):
|
|
82
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
83
|
+
try:
|
|
84
|
+
return super().invoke(ctx)
|
|
85
|
+
except click.exceptions.Exit:
|
|
86
|
+
raise
|
|
87
|
+
except click.exceptions.Abort:
|
|
88
|
+
raise
|
|
89
|
+
except click.UsageError:
|
|
90
|
+
raise
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
output = to_error_output(exc)
|
|
93
|
+
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
|
|
94
|
+
ctx.exit(1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@click.group(cls=TableauCli)
|
|
98
|
+
@click.version_option("0.1.0")
|
|
99
|
+
def cli():
|
|
100
|
+
"""CLI tool for interacting with Tableau Server/Cloud."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main() -> None:
|
|
104
|
+
from .commands.config_cmd import config_group
|
|
105
|
+
from .commands.convert_cmd import convert_command
|
|
106
|
+
from .commands.datasources_cmd import datasources_group
|
|
107
|
+
from .commands.search_cmd import search_command
|
|
108
|
+
from .commands.views_cmd import views_group
|
|
109
|
+
from .commands.workbooks_cmd import workbooks_group
|
|
110
|
+
|
|
111
|
+
cli.add_command(config_group)
|
|
112
|
+
cli.add_command(convert_command)
|
|
113
|
+
cli.add_command(datasources_group)
|
|
114
|
+
cli.add_command(datasources_group, name="ds") # alias
|
|
115
|
+
cli.add_command(views_group)
|
|
116
|
+
cli.add_command(workbooks_group)
|
|
117
|
+
cli.add_command(workbooks_group, name="wb") # alias
|
|
118
|
+
cli.add_command(search_command)
|
|
119
|
+
|
|
120
|
+
cli()
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ..config.store import load_file_config, save_file_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group("config")
|
|
12
|
+
def config_group():
|
|
13
|
+
"""Manage CLI configuration."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@config_group.command("set")
|
|
17
|
+
@click.option("--server", default=None, help="Tableau server URL")
|
|
18
|
+
@click.option("--site-name", default=None, help="Tableau site name")
|
|
19
|
+
@click.option("--pat-name", default=None, help="Personal Access Token name")
|
|
20
|
+
@click.option("--pat-value", default=None, help="Personal Access Token value")
|
|
21
|
+
def config_set(server, site_name, pat_name, pat_value):
|
|
22
|
+
"""Set configuration values."""
|
|
23
|
+
existing = load_file_config()
|
|
24
|
+
if server is not None:
|
|
25
|
+
existing.server = server
|
|
26
|
+
if site_name is not None:
|
|
27
|
+
existing.site_name = site_name
|
|
28
|
+
if pat_name is not None:
|
|
29
|
+
existing.pat_name = pat_name
|
|
30
|
+
if pat_value is not None:
|
|
31
|
+
existing.pat_value = pat_value
|
|
32
|
+
save_file_config(existing)
|
|
33
|
+
sys.stderr.write("Configuration saved.\n")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@config_group.command("show")
|
|
37
|
+
def config_show():
|
|
38
|
+
"""Show current configuration."""
|
|
39
|
+
config = load_file_config()
|
|
40
|
+
display = {}
|
|
41
|
+
if config.server is not None:
|
|
42
|
+
display["server"] = config.server
|
|
43
|
+
if config.site_name is not None:
|
|
44
|
+
display["siteName"] = config.site_name
|
|
45
|
+
if config.pat_name is not None:
|
|
46
|
+
display["patName"] = config.pat_name
|
|
47
|
+
display["patValue"] = "****" if config.pat_value else None
|
|
48
|
+
print(json.dumps(display, indent=2))
|