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.
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli_error import CliError
4
+
5
+ # condition and details texts are aligned word-for-word with MCP's queryDatasourceErrorHandler.ts.
6
+ # hint is CLI-specific added value (agent-friendly actionable guidance).
7
+ VDS_ERROR_MAP: dict[str, dict[str, str | None]] = {
8
+ "400000": {
9
+ "condition": "Bad request",
10
+ "details": "The content of the request body is invalid. Check for missing or incomplete JSON.",
11
+ "hint": "Verify the --query JSON is valid and complete.",
12
+ },
13
+ "400800": {
14
+ "condition": "Invalid formula for calculation",
15
+ "details": "Invalid custom calculation syntax. For help, see https://help.tableau.com/current/pro/desktop/en-us/functions_operators.htm",
16
+ "hint": None,
17
+ },
18
+ "400802": {
19
+ "condition": "Invalid API request",
20
+ "details": "The incoming request isn't valid per the OpenAPI specification.",
21
+ "hint": None,
22
+ },
23
+ "400803": {
24
+ "condition": "Validation failed",
25
+ "details": "The incoming request isn't valid per the validation rules.",
26
+ "hint": (
27
+ "Check that all field names and filter values match the datasource metadata. "
28
+ "Use `tableau-cli datasources metadata <luid>` to verify available fields."
29
+ ),
30
+ },
31
+ "400804": {
32
+ "condition": "Response too large",
33
+ "details": "The response value exceeds the limit. You must apply a filter in your request.",
34
+ "hint": "Use --limit to restrict row count, or add filters to your query.",
35
+ },
36
+ "401001": {
37
+ "condition": "Login error",
38
+ "details": "The login failed for the given user.",
39
+ "hint": "Check your PAT credentials with `tableau-cli config show`.",
40
+ },
41
+ "401002": {
42
+ "condition": "Invalid authorization credentials",
43
+ "details": "The provided auth token is formatted incorrectly.",
44
+ "hint": (
45
+ "Re-authenticate by updating your PAT with `tableau-cli config set --pat-name <name> --pat-value <value>`."
46
+ ),
47
+ },
48
+ "403157": {
49
+ "condition": "Feature disabled",
50
+ "details": "The feature is disabled.",
51
+ "hint": (
52
+ "Ask your Tableau Server admin to enable VizQL Data Service via TSM. "
53
+ "See https://help.tableau.com/current/server-linux/en-us/cli_configuration-set_tsm.htm"
54
+ ),
55
+ },
56
+ "403800": {
57
+ "condition": "API access permission denied",
58
+ "details": (
59
+ "The user doesn't have API Access granted on the given data source. "
60
+ "Set the API Access capability for the given data source to Allowed. "
61
+ "For help, see https://help.tableau.com/current/online/en-us/permissions_capabilities.htm"
62
+ ),
63
+ "hint": None,
64
+ },
65
+ "404934": {
66
+ "condition": "Unknown field",
67
+ "details": "The requested field doesn't exist.",
68
+ "hint": ("Use `tableau-cli datasources metadata <luid>` to see available fields and their exact names."),
69
+ },
70
+ "404950": {
71
+ "condition": "API endpoint not found",
72
+ "details": "The request endpoint doesn't exist.",
73
+ "hint": "This feature may not be available on your Tableau Server version.",
74
+ },
75
+ "408000": {
76
+ "condition": "Request timeout",
77
+ "details": "The request timed out.",
78
+ "hint": "Try adding filters to reduce the data volume, or simplify your query.",
79
+ },
80
+ "429000": {
81
+ "condition": "Too many requests",
82
+ "details": (
83
+ "Too many requests in the allotted amount of time. "
84
+ "For help, see https://help.tableau.com/current/api/vizql-data-service/en-us/docs/vds_limitations.html#licensing-and-data-transfer"
85
+ ),
86
+ "hint": "Wait before retrying.",
87
+ },
88
+ "409000": {
89
+ "condition": "User already on site",
90
+ "details": "HTTP status conflict.",
91
+ "hint": None,
92
+ },
93
+ "500000": {
94
+ "condition": "Internal server error",
95
+ "details": "The request could not be completed.",
96
+ "hint": None,
97
+ },
98
+ "500810": {
99
+ "condition": "VDS empty table response",
100
+ "details": "The underlying data engine returned empty data value response.",
101
+ "hint": "The datasource may have no data, or your filters may be too restrictive.",
102
+ },
103
+ "500811": {
104
+ "condition": "VDS missing table",
105
+ "details": "The underlying data engine returned empty metadata associated with response.",
106
+ "hint": None,
107
+ },
108
+ "500812": {
109
+ "condition": "Error while processing an error",
110
+ "details": "Internal processing error.",
111
+ "hint": None,
112
+ },
113
+ "501000": {
114
+ "condition": "Not implemented",
115
+ "details": "Can't find response from upstream server.",
116
+ "hint": None,
117
+ },
118
+ "503800": {
119
+ "condition": "VDS unavailable",
120
+ "details": "The underlying data engine is unavailable.",
121
+ "hint": "The server may be under maintenance. Try again later.",
122
+ },
123
+ "503801": {
124
+ "condition": "VDS discovery error",
125
+ "details": "The upstream service can't be found.",
126
+ "hint": "The server may be under maintenance. Try again later.",
127
+ },
128
+ "504000": {
129
+ "condition": "Gateway timeout",
130
+ "details": "The upstream service response timed out.",
131
+ "hint": "Try a simpler query with fewer fields or more restrictive filters.",
132
+ },
133
+ }
134
+
135
+
136
+ def handle_vds_error(
137
+ error_message: str,
138
+ http_status: int,
139
+ tableau_error_code: str | None,
140
+ ) -> CliError:
141
+ entry = VDS_ERROR_MAP.get(tableau_error_code or "")
142
+
143
+ if entry:
144
+ condition = entry["condition"]
145
+ return CliError(
146
+ error_type=condition or "tableau-error",
147
+ message=f"{condition}: {error_message}" if condition else error_message,
148
+ details=entry.get("details"),
149
+ hint=entry.get("hint"),
150
+ )
151
+
152
+ return CliError(
153
+ error_type="tableau-error",
154
+ message=error_message,
155
+ )
File without changes
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .json_output import output_json
6
+ from .table_output import output_table
7
+
8
+
9
+ def output(data: Any, fmt: str) -> None:
10
+ if fmt == "table" and isinstance(data, list):
11
+ output_table(data)
12
+ else:
13
+ output_json(data)
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any
6
+
7
+
8
+ def output_json(data: Any) -> None:
9
+ sys.stdout.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any
6
+
7
+
8
+ def _format_value(value: Any) -> str:
9
+ if value is None:
10
+ return ""
11
+ if isinstance(value, str):
12
+ return value
13
+ if isinstance(value, (int, float, bool)):
14
+ return str(value)
15
+ if isinstance(value, list):
16
+ if len(value) == 0:
17
+ return ""
18
+ # Handle tags: [{"label": "x"}, ...]
19
+ if isinstance(value[0], dict) and "label" in value[0]:
20
+ return ", ".join(v.get("label", "") for v in value)
21
+ return ", ".join(_format_value(v) for v in value)
22
+ if isinstance(value, dict):
23
+ # Handle tags object: {"tag": [{"label": "x"}, ...]}
24
+ if "tag" in value:
25
+ tag = value["tag"]
26
+ if isinstance(tag, list):
27
+ return ", ".join(t.get("label", "") for t in tag)
28
+ return ""
29
+ # Handle empty-like objects
30
+ non_null = {k: v for k, v in value.items() if v is not None}
31
+ if not non_null:
32
+ return ""
33
+ # Handle common nested objects
34
+ if "name" in value and value["name"]:
35
+ return str(value["name"])
36
+ if "id" in value and value["id"]:
37
+ return str(value["id"])
38
+ return json.dumps(value, ensure_ascii=False)
39
+ return str(value)
40
+
41
+
42
+ def output_table(rows: list[dict[str, Any]]) -> None:
43
+ if not rows:
44
+ sys.stdout.write("(no results)\n")
45
+ return
46
+
47
+ columns = list(rows[0].keys())
48
+ formatted = [{col: _format_value(row.get(col)) for col in columns} for row in rows]
49
+
50
+ widths = [max(len(col), *(len(row[col]) for row in formatted)) for col in columns]
51
+
52
+ header = " ".join(col.upper().ljust(w) for col, w in zip(columns, widths, strict=True))
53
+ separator = " ".join("-" * w for w in widths)
54
+
55
+ sys.stdout.write(header + "\n")
56
+ sys.stdout.write(separator + "\n")
57
+
58
+ for row in formatted:
59
+ line = " ".join(row[col].ljust(w) for col, w in zip(columns, widths, strict=True))
60
+ sys.stdout.write(line + "\n")
File without changes
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def get_graphql_query(datasource_luid: str) -> str:
7
+ return f"""
8
+ query datasourceFieldInfo {{
9
+ publishedDatasources(filter: {{ luid: "{datasource_luid}" }}) {{
10
+ name
11
+ description
12
+ owner {{
13
+ name
14
+ }}
15
+ fields {{
16
+ name
17
+ isHidden
18
+ description
19
+ descriptionInherited {{
20
+ attribute
21
+ value
22
+ }}
23
+ fullyQualifiedName
24
+ __typename
25
+ upstreamTables {{
26
+ name
27
+ }}
28
+ ... on AnalyticsField {{
29
+ __typename
30
+ }}
31
+ ... on ColumnField {{
32
+ dataCategory
33
+ role
34
+ dataType
35
+ defaultFormat
36
+ semanticRole
37
+ aggregation
38
+ aggregationParam
39
+ }}
40
+ ... on CalculatedField {{
41
+ dataCategory
42
+ role
43
+ dataType
44
+ defaultFormat
45
+ semanticRole
46
+ aggregation
47
+ aggregationParam
48
+ formula
49
+ isAutoGenerated
50
+ hasUserReference
51
+ }}
52
+ ... on BinField {{
53
+ dataCategory
54
+ role
55
+ dataType
56
+ formula
57
+ binSize
58
+ }}
59
+ ... on GroupField {{
60
+ dataCategory
61
+ role
62
+ dataType
63
+ hasOther
64
+ }}
65
+ ... on CombinedSetField {{
66
+ delimiter
67
+ combinationType
68
+ }}
69
+ }}
70
+ }}
71
+ }}"""
72
+
73
+
74
+ def _normalize_logical_table_id(logical_table_id: str | None) -> str | None:
75
+ if logical_table_id is None:
76
+ return None
77
+ normalized = logical_table_id.strip()
78
+ return normalized if normalized else None
79
+
80
+
81
+ def _group_fields_by_logical_table_id(
82
+ fields: list[dict[str, Any]],
83
+ ) -> list[dict[str, Any]]:
84
+ grouped: dict[str | None, list[dict[str, Any]]] = {}
85
+ for field in fields:
86
+ key = field.get("logicalTableId")
87
+ grouped.setdefault(key, []).append(field)
88
+ return [{"logicalTableId": k, "fields": v} for k, v in grouped.items()]
89
+
90
+
91
+ def _populate_field_with_additional_properties(source_field: dict[str, Any], target_field: dict[str, Any]) -> None:
92
+ if source_field.get("description"):
93
+ target_field["description"] = source_field["description"]
94
+ desc_inherited = source_field.get("descriptionInherited")
95
+ if isinstance(desc_inherited, list) and desc_inherited:
96
+ target_field["descriptionInherited"] = desc_inherited
97
+ if source_field.get("dataCategory"):
98
+ target_field["dataCategory"] = source_field["dataCategory"]
99
+ if source_field.get("role"):
100
+ target_field["role"] = source_field["role"]
101
+ if source_field.get("defaultFormat"):
102
+ target_field["defaultFormat"] = source_field["defaultFormat"]
103
+ if source_field.get("formula"):
104
+ target_field["formula"] = source_field["formula"]
105
+ if source_field.get("isAutoGenerated") is not None:
106
+ target_field["isAutoGenerated"] = source_field["isAutoGenerated"]
107
+ if source_field.get("hasUserReference") is not None:
108
+ target_field["hasUserReference"] = source_field["hasUserReference"]
109
+ if source_field.get("binSize") is not None:
110
+ target_field["binSize"] = source_field["binSize"]
111
+
112
+
113
+ def _build_parameters(extra_data: dict[str, Any] | None) -> list[dict[str, Any]]:
114
+ if not extra_data or not extra_data.get("parameters"):
115
+ return []
116
+ parameters: list[dict[str, Any]] = []
117
+ for param in extra_data["parameters"]:
118
+ entry: dict[str, Any] = {
119
+ "name": param.get("parameterCaption"),
120
+ "parameterType": param.get("parameterType"),
121
+ "dataType": param.get("dataType"),
122
+ "value": param.get("value"),
123
+ }
124
+ param_type = param.get("parameterType")
125
+ if param_type == "LIST" and param.get("members"):
126
+ entry["members"] = param["members"]
127
+ elif param_type == "QUANTITATIVE_DATE":
128
+ entry["minDate"] = param.get("minDate")
129
+ entry["maxDate"] = param.get("maxDate")
130
+ entry["periodValue"] = param.get("periodValue")
131
+ entry["periodType"] = param.get("periodType")
132
+ elif param_type == "QUANTITATIVE_RANGE":
133
+ entry["min"] = param.get("min")
134
+ entry["max"] = param.get("max")
135
+ entry["step"] = param.get("step")
136
+ parameters.append(entry)
137
+ return parameters
138
+
139
+
140
+ def simplify_read_metadata_result(
141
+ read_metadata_result: dict[str, Any],
142
+ ) -> dict[str, Any]:
143
+ result: dict[str, Any] = {
144
+ "datasourceDescription": "",
145
+ "fieldGroups": [],
146
+ "parameters": [],
147
+ }
148
+
149
+ data = read_metadata_result.get("data")
150
+ if not data:
151
+ return result
152
+
153
+ fields: list[dict[str, Any]] = []
154
+ for field in data:
155
+ entry: dict[str, Any] = {
156
+ "name": field.get("fieldCaption"),
157
+ "dataType": field.get("dataType"),
158
+ "columnClass": field.get("columnClass"),
159
+ "logicalTableId": _normalize_logical_table_id(field.get("logicalTableId")),
160
+ }
161
+ if field.get("defaultAggregation"):
162
+ entry["defaultAggregation"] = field["defaultAggregation"]
163
+ if field.get("formula"):
164
+ entry["formula"] = field["formula"]
165
+ fields.append(entry)
166
+
167
+ result["parameters"] = _build_parameters(read_metadata_result.get("extraData"))
168
+ result["fieldGroups"] = _group_fields_by_logical_table_id(fields)
169
+ return result
170
+
171
+
172
+ def combine_fields(
173
+ read_metadata_result: dict[str, Any],
174
+ list_fields_result: dict[str, Any],
175
+ ) -> dict[str, Any]:
176
+ ds_list = list_fields_result.get("data", {}).get("publishedDatasources", [])
177
+ ds = ds_list[0] if ds_list else None
178
+
179
+ combined: dict[str, Any] = {
180
+ "datasourceDescription": (ds.get("description") or "") if ds else "",
181
+ "fieldGroups": [],
182
+ "parameters": [],
183
+ }
184
+
185
+ data = read_metadata_result.get("data")
186
+ fields: list[dict[str, Any]] = []
187
+
188
+ if not data:
189
+ # Fallback to GraphQL results
190
+ if ds and ds.get("fields"):
191
+ for field in ds["fields"]:
192
+ entry: dict[str, Any] = {
193
+ "name": field.get("name"),
194
+ "logicalTableId": None,
195
+ }
196
+ if field.get("dataType"):
197
+ entry["dataType"] = field["dataType"]
198
+ if field.get("aggregation"):
199
+ entry["defaultAggregation"] = field["aggregation"]
200
+ _populate_field_with_additional_properties(field, entry)
201
+ fields.append(entry)
202
+ combined["fieldGroups"] = _group_fields_by_logical_table_id(fields)
203
+ return combined
204
+
205
+ # Build fields from VizQL Data Service
206
+ for field in data:
207
+ entry = {
208
+ "name": field.get("fieldCaption"),
209
+ "dataType": field.get("dataType"),
210
+ "columnClass": field.get("columnClass"),
211
+ "logicalTableId": _normalize_logical_table_id(field.get("logicalTableId")),
212
+ }
213
+ if field.get("defaultAggregation"):
214
+ entry["defaultAggregation"] = field["defaultAggregation"]
215
+ if field.get("formula"):
216
+ entry["formula"] = field["formula"]
217
+ fields.append(entry)
218
+
219
+ combined["parameters"] = _build_parameters(read_metadata_result.get("extraData"))
220
+
221
+ # Enrich fields with GraphQL data
222
+ if ds and ds.get("fields"):
223
+ for field in fields:
224
+ match = next((f for f in ds["fields"] if f.get("name") == field.get("name")), None)
225
+ if match:
226
+ _populate_field_with_additional_properties(match, field)
227
+
228
+ combined["fieldGroups"] = _group_fields_by_logical_table_id(fields)
229
+ return combined
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+
7
+ def paginate(
8
+ *,
9
+ page_size: int | None = None,
10
+ limit: int | None = None,
11
+ get_data_fn: Callable[[int | None, int | None], dict[str, Any]],
12
+ ) -> list[Any]:
13
+ """Paginate through Tableau REST API results.
14
+
15
+ get_data_fn(page_size, page_number) should return {"pagination": {...}, "data": [...]}.
16
+ """
17
+ response = get_data_fn(page_size, None)
18
+ pagination = response["pagination"]
19
+ result = list(response["data"])
20
+
21
+ total_available: int = pagination["totalAvailable"]
22
+ page_number: int = pagination["pageNumber"]
23
+
24
+ while total_available > len(result) and (not limit or limit > len(result)):
25
+ response = get_data_fn(page_size, page_number + 1)
26
+ next_data = response["data"]
27
+
28
+ if len(next_data) == 0:
29
+ raise RuntimeError(
30
+ f"No more data available. Last fetched page number: {page_number}, "
31
+ f"Total available: {total_available}, Total fetched: {len(result)}"
32
+ )
33
+
34
+ pagination = response["pagination"]
35
+ total_available = pagination["totalAvailable"]
36
+ page_number = pagination["pageNumber"]
37
+ result.extend(next_data)
38
+
39
+ if limit and limit < len(result):
40
+ result = result[:limit]
41
+
42
+ return result
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def _get_reduced_search_item_content(content: dict[str, Any]) -> dict[str, Any]:
7
+ r: dict[str, Any] = {}
8
+ if content.get("modifiedTime"):
9
+ r["modifiedTime"] = content["modifiedTime"]
10
+ if content.get("sheetType"):
11
+ r["sheetType"] = content["sheetType"]
12
+ if content.get("caption"):
13
+ r["caption"] = content["caption"]
14
+ if content.get("workbookDescription"):
15
+ r["workbookDescription"] = content["workbookDescription"]
16
+ if content.get("type"):
17
+ r["type"] = content["type"]
18
+ if content.get("ownerId"):
19
+ r["ownerId"] = content["ownerId"]
20
+ if content.get("title"):
21
+ r["title"] = content["title"]
22
+ if content.get("ownerName"):
23
+ r["ownerName"] = content["ownerName"]
24
+ if content.get("containerName"):
25
+ if content.get("type") == "view":
26
+ r["parentWorkbookName"] = content["containerName"]
27
+ else:
28
+ r["containerName"] = content["containerName"]
29
+ if content.get("luid"):
30
+ r["luid"] = content["luid"]
31
+ if content.get("locationName"):
32
+ r["locationName"] = content["locationName"]
33
+ if isinstance(content.get("comments"), list) and content["comments"]:
34
+ r["comments"] = content["comments"]
35
+ if content.get("hitsTotal") is not None:
36
+ r["totalViewCount"] = content["hitsTotal"]
37
+ if content.get("favoritesTotal") is not None:
38
+ r["favoritesTotal"] = content["favoritesTotal"]
39
+ if isinstance(content.get("tags"), list) and content["tags"]:
40
+ r["tags"] = content["tags"]
41
+ if content.get("projectId"):
42
+ r["projectId"] = content["projectId"]
43
+ if content.get("projectName"):
44
+ r["projectName"] = content["projectName"]
45
+ if content.get("hitsSmallSpanTotal") is not None:
46
+ r["viewCountLastMonth"] = content["hitsSmallSpanTotal"]
47
+ if content.get("downstreamWorkbookCount") is not None:
48
+ r["downstreamWorkbookCount"] = content["downstreamWorkbookCount"]
49
+ if content.get("isConnectable") is not None:
50
+ r["isConnectable"] = content["isConnectable"]
51
+ if content.get("datasourceIsPublished") is not None:
52
+ r["datasourceIsPublished"] = content["datasourceIsPublished"]
53
+ if content.get("connectionType"):
54
+ r["connectionType"] = content["connectionType"]
55
+ if content.get("isCertified") is not None:
56
+ r["isCertified"] = content["isCertified"]
57
+ if content.get("hasExtracts") is not None:
58
+ r["hasExtracts"] = content["hasExtracts"]
59
+ if content.get("extractRefreshedAt"):
60
+ r["extractRefreshedAt"] = content["extractRefreshedAt"]
61
+ if content.get("extractUpdatedAt"):
62
+ r["extractUpdatedAt"] = content["extractUpdatedAt"]
63
+ if content.get("connectedWorkbooksCount") is not None:
64
+ r["connectedWorkbooksCount"] = content["connectedWorkbooksCount"]
65
+ if content.get("extractCreationPending") is not None:
66
+ r["extractCreationPending"] = content["extractCreationPending"]
67
+ if content.get("hasSevereDataQualityWarning") is not None:
68
+ r["hasSevereDataQualityWarning"] = content["hasSevereDataQualityWarning"]
69
+ if content.get("datasourceLuid"):
70
+ r["datasourceLuid"] = content["datasourceLuid"]
71
+ if content.get("hasActiveDataQualityWarning") is not None:
72
+ r["hasActiveDataQualityWarning"] = content["hasActiveDataQualityWarning"]
73
+ return r
74
+
75
+
76
+ def reduce_search_content_response(response: dict[str, Any]) -> list[dict[str, Any]]:
77
+ search_results: list[dict[str, Any]] = []
78
+ items = response.get("items") or []
79
+ for item in items:
80
+ search_results.append(_get_reduced_search_item_content(item.get("content", {})))
81
+
82
+ # Remove duplicate datasources with luid matching a unifieddatasource's datasourceLuid
83
+ unified_datasource_luids: set[str] = set()
84
+ for item in search_results:
85
+ if item.get("type") == "unifieddatasource" and isinstance(item.get("datasourceLuid"), str):
86
+ unified_datasource_luids.add(item["datasourceLuid"])
87
+
88
+ search_results = [
89
+ item
90
+ for item in search_results
91
+ if not (
92
+ item.get("type") == "datasource"
93
+ and isinstance(item.get("luid"), str)
94
+ and item["luid"] in unified_datasource_luids
95
+ )
96
+ ]
97
+
98
+ # Normalize unifieddatasource entries to datasource entries
99
+ for item in search_results:
100
+ if item.get("type") == "unifieddatasource":
101
+ item["type"] = "datasource"
102
+ item["luid"] = item.pop("datasourceLuid", None)
103
+
104
+ return search_results