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
|
@@ -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,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
|