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,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tempfile import TemporaryDirectory
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
from zipfile import ZipFile
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from ..errors.cli_error import CliError
|
|
13
|
+
from ..output.format import output
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import polars as pl
|
|
17
|
+
|
|
18
|
+
CONVERT_DEPS = ("pantab", "polars", "pyarrow")
|
|
19
|
+
SUPPORTED_FORMATS = ("parquet", "csv")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_convert_deps() -> None:
|
|
23
|
+
"""Check that optional convert dependencies are installed."""
|
|
24
|
+
missing = [pkg for pkg in CONVERT_DEPS if not _is_importable(pkg)]
|
|
25
|
+
if missing:
|
|
26
|
+
raise CliError(
|
|
27
|
+
error_type="missing-dependencies",
|
|
28
|
+
message=f"Convert requires packages not installed: {', '.join(missing)}",
|
|
29
|
+
hint="Run `pip install tableau-cli[convert]` to install conversion dependencies.",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_importable(name: str) -> bool:
|
|
34
|
+
try:
|
|
35
|
+
__import__(name)
|
|
36
|
+
return True
|
|
37
|
+
except ImportError:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extract_hyper_from_tdsx(tdsx_path: Path, target_dir: Path) -> Path:
|
|
42
|
+
"""Extract the .hyper file from a TDSX archive."""
|
|
43
|
+
with ZipFile(tdsx_path) as zf:
|
|
44
|
+
members = [m for m in zf.namelist() if m.endswith(".hyper")]
|
|
45
|
+
if len(members) == 0:
|
|
46
|
+
raise CliError(
|
|
47
|
+
error_type="convert-error",
|
|
48
|
+
message=f"No .hyper file found in {tdsx_path.name}",
|
|
49
|
+
hint="This datasource may be a live connection with no embedded data.",
|
|
50
|
+
)
|
|
51
|
+
if len(members) != 1:
|
|
52
|
+
raise CliError(
|
|
53
|
+
error_type="convert-error",
|
|
54
|
+
message=f"Found {len(members)} .hyper files in {tdsx_path.name}, expected 1",
|
|
55
|
+
)
|
|
56
|
+
member = members[0]
|
|
57
|
+
zf.extract(member, path=target_dir)
|
|
58
|
+
extracted = target_dir / member
|
|
59
|
+
if extracted.parent != target_dir:
|
|
60
|
+
final_path = target_dir / extracted.name
|
|
61
|
+
extracted.replace(final_path)
|
|
62
|
+
return final_path
|
|
63
|
+
return extracted
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _read_hyper(hyper_path: Path) -> pl.DataFrame:
|
|
67
|
+
"""Read .hyper file and return as Polars DataFrame."""
|
|
68
|
+
import pantab
|
|
69
|
+
import polars as pl
|
|
70
|
+
|
|
71
|
+
frames = pantab.frames_from_hyper(str(hyper_path))
|
|
72
|
+
if not frames:
|
|
73
|
+
raise CliError(
|
|
74
|
+
error_type="convert-error",
|
|
75
|
+
message=f"No tables found in {hyper_path.name}",
|
|
76
|
+
)
|
|
77
|
+
if len(frames) != 1:
|
|
78
|
+
raise CliError(
|
|
79
|
+
error_type="convert-error",
|
|
80
|
+
message=f"Found {len(frames)} tables in {hyper_path.name}, expected 1",
|
|
81
|
+
hint="Multi-table hyper files are not supported yet.",
|
|
82
|
+
)
|
|
83
|
+
return pl.from_pandas(next(iter(frames.values())))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _write_df(df: pl.DataFrame, output_path: Path, to: str) -> None:
|
|
87
|
+
"""Write DataFrame to the specified format."""
|
|
88
|
+
if to == "parquet":
|
|
89
|
+
df.write_parquet(output_path)
|
|
90
|
+
elif to == "csv":
|
|
91
|
+
df.write_csv(output_path)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@click.command("convert")
|
|
95
|
+
@click.argument("input_path", type=click.Path(exists=True))
|
|
96
|
+
@click.option(
|
|
97
|
+
"--to", "to_fmt", default="parquet", type=click.Choice(SUPPORTED_FORMATS), help="Output format (default: parquet)"
|
|
98
|
+
)
|
|
99
|
+
@click.option("-o", "--output", "output_path", default=None, help="Output file or directory (default: same as input)")
|
|
100
|
+
def convert_command(input_path, to_fmt, output_path):
|
|
101
|
+
"""Convert TDSX/HYPER files to Parquet or CSV format."""
|
|
102
|
+
_check_convert_deps()
|
|
103
|
+
|
|
104
|
+
input_p = Path(input_path)
|
|
105
|
+
suffix = input_p.suffix.lower()
|
|
106
|
+
|
|
107
|
+
if suffix not in (".tdsx", ".hyper"):
|
|
108
|
+
raise CliError(
|
|
109
|
+
error_type="invalid-input",
|
|
110
|
+
message=f"Unsupported file type: {suffix}",
|
|
111
|
+
hint="Supported formats: .tdsx, .hyper",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Resolve output path
|
|
115
|
+
ext = f".{to_fmt}"
|
|
116
|
+
if output_path is None:
|
|
117
|
+
out_p = input_p.with_suffix(ext)
|
|
118
|
+
elif os.path.isdir(output_path):
|
|
119
|
+
out_p = Path(output_path) / f"{input_p.stem}{ext}"
|
|
120
|
+
else:
|
|
121
|
+
out_p = Path(output_path)
|
|
122
|
+
|
|
123
|
+
# Read hyper data
|
|
124
|
+
if suffix == ".hyper":
|
|
125
|
+
df = _read_hyper(input_p)
|
|
126
|
+
else:
|
|
127
|
+
with TemporaryDirectory() as td:
|
|
128
|
+
hyper_path = _extract_hyper_from_tdsx(input_p, Path(td))
|
|
129
|
+
df = _read_hyper(hyper_path)
|
|
130
|
+
|
|
131
|
+
_write_df(df, out_p, to_fmt)
|
|
132
|
+
|
|
133
|
+
abs_path = os.path.abspath(out_p)
|
|
134
|
+
sys.stderr.write(f"Converted to {abs_path}\n")
|
|
135
|
+
output({"filePath": abs_path}, "json")
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..auth.with_auth import with_auth
|
|
10
|
+
from ..config.store import resolve_config
|
|
11
|
+
from ..output.format import output
|
|
12
|
+
from ..utils.paginate import paginate
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group("datasources")
|
|
16
|
+
def datasources_group():
|
|
17
|
+
"""Manage data sources."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Alias: allow 'ds' as well
|
|
21
|
+
ds_group = datasources_group
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@datasources_group.command("list")
|
|
25
|
+
@click.option("--filter", "filter_", default=None, help="Filter string (e.g., name:eq:Superstore)")
|
|
26
|
+
@click.option("--page-size", default=None, type=int, help="Page size for API requests")
|
|
27
|
+
@click.option("--limit", default=None, type=int, help="Max total results")
|
|
28
|
+
@click.option("--format", "fmt", default="json", help="Output format: json | table")
|
|
29
|
+
def datasources_list(filter_, page_size, limit, fmt):
|
|
30
|
+
"""List published data sources."""
|
|
31
|
+
config = resolve_config()
|
|
32
|
+
|
|
33
|
+
def fn(api):
|
|
34
|
+
def get_data_fn(ps, pn):
|
|
35
|
+
result = api.list_datasources(
|
|
36
|
+
site_id=api.site_id,
|
|
37
|
+
filter_=filter_ or "",
|
|
38
|
+
page_size=ps,
|
|
39
|
+
page_number=pn,
|
|
40
|
+
)
|
|
41
|
+
return {"pagination": result["pagination"], "data": result["datasources"]}
|
|
42
|
+
|
|
43
|
+
return paginate(page_size=page_size, limit=limit, get_data_fn=get_data_fn)
|
|
44
|
+
|
|
45
|
+
result = with_auth(config, fn)
|
|
46
|
+
output(result, fmt)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@datasources_group.command("download")
|
|
50
|
+
@click.argument("datasource_id")
|
|
51
|
+
@click.option(
|
|
52
|
+
"-o", "--output", "output_path", default=".", help="Output file path or directory (default: current directory)"
|
|
53
|
+
)
|
|
54
|
+
def datasources_download(datasource_id, output_path):
|
|
55
|
+
"""Download a datasource file (.tdsx)."""
|
|
56
|
+
config = resolve_config()
|
|
57
|
+
|
|
58
|
+
data, filename = with_auth(
|
|
59
|
+
config,
|
|
60
|
+
lambda api: api.download_datasource(datasource_id=datasource_id, site_id=api.site_id),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
file_path = os.path.join(output_path, filename) if os.path.isdir(output_path) else output_path
|
|
64
|
+
|
|
65
|
+
with open(file_path, "wb") as f:
|
|
66
|
+
f.write(data)
|
|
67
|
+
|
|
68
|
+
abs_path = os.path.abspath(file_path)
|
|
69
|
+
sys.stderr.write(f"Downloaded to {abs_path}\n")
|
|
70
|
+
output({"filePath": abs_path}, "json")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@datasources_group.command("metadata")
|
|
74
|
+
@click.argument("luid")
|
|
75
|
+
@click.option("--format", "fmt", default="json", help="Output format: json | table")
|
|
76
|
+
def datasources_metadata(luid, fmt):
|
|
77
|
+
"""Get data source field metadata."""
|
|
78
|
+
config = resolve_config()
|
|
79
|
+
|
|
80
|
+
def fn(api):
|
|
81
|
+
from ..utils.datasource_metadata_utils import (
|
|
82
|
+
combine_fields,
|
|
83
|
+
get_graphql_query,
|
|
84
|
+
simplify_read_metadata_result,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
read_metadata_result = api.read_metadata(datasource_luid=luid)
|
|
88
|
+
|
|
89
|
+
# Try to enrich with Metadata API (GraphQL)
|
|
90
|
+
try:
|
|
91
|
+
graphql_result = api.graphql(get_graphql_query(luid))
|
|
92
|
+
except Exception:
|
|
93
|
+
# Metadata API may not be available
|
|
94
|
+
return simplify_read_metadata_result(read_metadata_result)
|
|
95
|
+
|
|
96
|
+
return combine_fields(read_metadata_result, graphql_result)
|
|
97
|
+
|
|
98
|
+
result = with_auth(config, fn)
|
|
99
|
+
output(result, fmt)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@datasources_group.command("query")
|
|
103
|
+
@click.argument("luid")
|
|
104
|
+
@click.option("--query", "query_json", required=True, help="Query JSON (fields, filters, etc.)")
|
|
105
|
+
@click.option("--limit", default=None, type=int, help="Row limit")
|
|
106
|
+
@click.option("--format", "fmt", default="json", help="Output format: json | table")
|
|
107
|
+
def datasources_query(luid, query_json, limit, fmt):
|
|
108
|
+
"""Query a data source using VizQL Data Service."""
|
|
109
|
+
config = resolve_config()
|
|
110
|
+
query = json.loads(query_json)
|
|
111
|
+
|
|
112
|
+
def fn(api):
|
|
113
|
+
# Note: rowLimit is NOT sent to the API (consistent with MCP default behavior
|
|
114
|
+
# for Tableau versions < 2026.1.0 where server-side row limits are unsupported).
|
|
115
|
+
# Instead, truncation is done client-side after the response.
|
|
116
|
+
query_result = api.query_datasource(
|
|
117
|
+
datasource_luid=luid,
|
|
118
|
+
query=query,
|
|
119
|
+
options={
|
|
120
|
+
"returnFormat": "OBJECTS",
|
|
121
|
+
"debug": True,
|
|
122
|
+
"disaggregate": False,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if limit and query_result.get("data") and len(query_result["data"]) > limit:
|
|
127
|
+
query_result["data"] = query_result["data"][:limit]
|
|
128
|
+
|
|
129
|
+
return query_result
|
|
130
|
+
|
|
131
|
+
result = with_auth(config, fn)
|
|
132
|
+
output(result, fmt)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ..auth.with_auth import with_auth
|
|
6
|
+
from ..config.store import resolve_config
|
|
7
|
+
from ..output.format import output
|
|
8
|
+
from ..utils.search_content_utils import reduce_search_content_response
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command("search")
|
|
12
|
+
@click.argument("terms", required=False, default=None)
|
|
13
|
+
@click.option("--type", "type_", default=None, help="Comma-separated content types (e.g., workbook,view,datasource)")
|
|
14
|
+
@click.option("--limit", default=100, type=int, help="Max results")
|
|
15
|
+
@click.option("--order-by", default=None, help="Sort method (e.g., hitsTotal:desc)")
|
|
16
|
+
@click.option("--format", "fmt", default="json", help="Output format: json | table")
|
|
17
|
+
def search_command(terms, type_, limit, order_by, fmt):
|
|
18
|
+
"""Search across all content types."""
|
|
19
|
+
config = resolve_config()
|
|
20
|
+
filter_parts: list[str] = []
|
|
21
|
+
if type_:
|
|
22
|
+
types = type_.split(",")
|
|
23
|
+
if len(types) == 1:
|
|
24
|
+
filter_parts.append(f"type:eq:{types[0]}")
|
|
25
|
+
else:
|
|
26
|
+
filter_parts.append(f"type:in:[{','.join(types)}]")
|
|
27
|
+
|
|
28
|
+
def fn(api):
|
|
29
|
+
response = api.search_content(
|
|
30
|
+
terms=terms,
|
|
31
|
+
page=0,
|
|
32
|
+
limit=limit,
|
|
33
|
+
order_by=order_by,
|
|
34
|
+
filter_=",".join(filter_parts) if filter_parts else None,
|
|
35
|
+
)
|
|
36
|
+
return reduce_search_content_response(response)
|
|
37
|
+
|
|
38
|
+
result = with_auth(config, fn)
|
|
39
|
+
output(result, fmt)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ..auth.with_auth import with_auth
|
|
9
|
+
from ..config.store import resolve_config
|
|
10
|
+
from ..output.format import output
|
|
11
|
+
from ..utils.paginate import paginate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group("views")
|
|
15
|
+
def views_group():
|
|
16
|
+
"""Manage views."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@views_group.command("list")
|
|
20
|
+
@click.option("--filter", "filter_", default=None, help="Filter string (e.g., name:has:Sales)")
|
|
21
|
+
@click.option("--page-size", default=None, type=int, help="Page size for API requests")
|
|
22
|
+
@click.option("--limit", default=None, type=int, help="Max total results")
|
|
23
|
+
@click.option("--format", "fmt", default="json", help="Output format: json | table")
|
|
24
|
+
def views_list(filter_, page_size, limit, fmt):
|
|
25
|
+
"""List views on the site."""
|
|
26
|
+
config = resolve_config()
|
|
27
|
+
|
|
28
|
+
def fn(api):
|
|
29
|
+
def get_data_fn(ps, pn):
|
|
30
|
+
result = api.query_views_for_site(
|
|
31
|
+
site_id=api.site_id,
|
|
32
|
+
filter_=filter_ or "",
|
|
33
|
+
page_size=ps,
|
|
34
|
+
page_number=pn,
|
|
35
|
+
)
|
|
36
|
+
return {"pagination": result["pagination"], "data": result["views"]}
|
|
37
|
+
|
|
38
|
+
return paginate(page_size=page_size, limit=limit, get_data_fn=get_data_fn)
|
|
39
|
+
|
|
40
|
+
result = with_auth(config, fn)
|
|
41
|
+
output(result, fmt)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@views_group.command("data")
|
|
45
|
+
@click.argument("view_id")
|
|
46
|
+
def views_data(view_id):
|
|
47
|
+
"""Get view data as CSV."""
|
|
48
|
+
config = resolve_config()
|
|
49
|
+
csv = with_auth(config, lambda api: api.query_view_data(view_id=view_id, site_id=api.site_id))
|
|
50
|
+
sys.stdout.write(csv)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@views_group.command("image")
|
|
54
|
+
@click.argument("view_id")
|
|
55
|
+
@click.option("--width", default=None, type=int, help="Image width in pixels")
|
|
56
|
+
@click.option("--height", default=None, type=int, help="Image height in pixels")
|
|
57
|
+
@click.option("--img-format", default="PNG", help="Image format: PNG | SVG")
|
|
58
|
+
@click.option("-o", "--output", "output_path", default=None, help="Output file path")
|
|
59
|
+
def views_image(view_id, width, height, img_format, output_path):
|
|
60
|
+
"""Download view image."""
|
|
61
|
+
config = resolve_config()
|
|
62
|
+
|
|
63
|
+
image_data = with_auth(
|
|
64
|
+
config,
|
|
65
|
+
lambda api: api.query_view_image(
|
|
66
|
+
view_id=view_id,
|
|
67
|
+
site_id=api.site_id,
|
|
68
|
+
width=width,
|
|
69
|
+
height=height,
|
|
70
|
+
format_=img_format,
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if output_path:
|
|
75
|
+
with open(output_path, "wb") as f:
|
|
76
|
+
f.write(image_data)
|
|
77
|
+
abs_path = os.path.abspath(output_path)
|
|
78
|
+
sys.stderr.write(f"Image saved to {abs_path}\n")
|
|
79
|
+
output({"filePath": abs_path}, "json")
|
|
80
|
+
else:
|
|
81
|
+
import base64
|
|
82
|
+
|
|
83
|
+
sys.stdout.write(base64.b64encode(image_data).decode("ascii"))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ..auth.with_auth import with_auth
|
|
6
|
+
from ..config.store import resolve_config
|
|
7
|
+
from ..output.format import output
|
|
8
|
+
from ..utils.paginate import paginate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group("workbooks")
|
|
12
|
+
def workbooks_group():
|
|
13
|
+
"""Manage workbooks."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@workbooks_group.command("list")
|
|
17
|
+
@click.option("--filter", "filter_", default=None, help="Filter string (e.g., name:eq:Finance)")
|
|
18
|
+
@click.option("--page-size", default=None, type=int, help="Page size for API requests")
|
|
19
|
+
@click.option("--limit", default=None, type=int, help="Max total results")
|
|
20
|
+
@click.option("--format", "fmt", default="json", help="Output format: json | table")
|
|
21
|
+
def workbooks_list(filter_, page_size, limit, fmt):
|
|
22
|
+
"""List workbooks on the site."""
|
|
23
|
+
config = resolve_config()
|
|
24
|
+
|
|
25
|
+
def fn(api):
|
|
26
|
+
def get_data_fn(ps, pn):
|
|
27
|
+
result = api.query_workbooks_for_site(
|
|
28
|
+
site_id=api.site_id,
|
|
29
|
+
filter_=filter_ or "",
|
|
30
|
+
page_size=ps,
|
|
31
|
+
page_number=pn,
|
|
32
|
+
)
|
|
33
|
+
return {"pagination": result["pagination"], "data": result["workbooks"]}
|
|
34
|
+
|
|
35
|
+
return paginate(page_size=page_size, limit=limit, get_data_fn=get_data_fn)
|
|
36
|
+
|
|
37
|
+
result = with_auth(config, fn)
|
|
38
|
+
output(result, fmt)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@workbooks_group.command("get")
|
|
42
|
+
@click.argument("workbook_id")
|
|
43
|
+
@click.option("--format", "fmt", default="json", help="Output format: json | table")
|
|
44
|
+
def workbooks_get(workbook_id, fmt):
|
|
45
|
+
"""Get workbook details."""
|
|
46
|
+
config = resolve_config()
|
|
47
|
+
|
|
48
|
+
def fn(api):
|
|
49
|
+
workbook = api.get_workbook(workbook_id=workbook_id, site_id=api.site_id)
|
|
50
|
+
|
|
51
|
+
# Enrich views with usage statistics (consistent with MCP behavior)
|
|
52
|
+
if workbook.get("views"):
|
|
53
|
+
views = api.query_views_for_workbook(workbook_id=workbook_id, site_id=api.site_id)
|
|
54
|
+
workbook["views"]["view"] = views
|
|
55
|
+
|
|
56
|
+
return workbook
|
|
57
|
+
|
|
58
|
+
result = with_auth(config, fn)
|
|
59
|
+
output(result, fmt)
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .types import Config, PartialConfig
|
|
8
|
+
|
|
9
|
+
CONFIG_PATH = Path.home() / ".tableau-cli.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_file_config() -> PartialConfig:
|
|
13
|
+
try:
|
|
14
|
+
raw = CONFIG_PATH.read_text(encoding="utf-8")
|
|
15
|
+
data = json.loads(raw)
|
|
16
|
+
return PartialConfig(
|
|
17
|
+
server=data.get("server"),
|
|
18
|
+
site_name=data.get("siteName"),
|
|
19
|
+
pat_name=data.get("patName"),
|
|
20
|
+
pat_value=data.get("patValue"),
|
|
21
|
+
)
|
|
22
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
23
|
+
return PartialConfig()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save_file_config(config: PartialConfig) -> None:
|
|
27
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
data: dict = {}
|
|
29
|
+
if config.server is not None:
|
|
30
|
+
data["server"] = config.server
|
|
31
|
+
if config.site_name is not None:
|
|
32
|
+
data["siteName"] = config.site_name
|
|
33
|
+
if config.pat_name is not None:
|
|
34
|
+
data["patName"] = config.pat_name
|
|
35
|
+
if config.pat_value is not None:
|
|
36
|
+
data["patValue"] = config.pat_value
|
|
37
|
+
CONFIG_PATH.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_config() -> Config:
|
|
41
|
+
file = load_file_config()
|
|
42
|
+
|
|
43
|
+
server = os.environ.get("SERVER") or file.server
|
|
44
|
+
site_name = os.environ.get("SITE_NAME") or file.site_name or ""
|
|
45
|
+
pat_name = os.environ.get("PAT_NAME") or file.pat_name
|
|
46
|
+
pat_value = os.environ.get("PAT_VALUE") or file.pat_value
|
|
47
|
+
|
|
48
|
+
if not server:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
"Missing required config: server. Set via `tableau-cli config set --server <url>` or SERVER env var."
|
|
51
|
+
)
|
|
52
|
+
if not pat_name or not pat_value:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"Missing required config: patName/patValue. "
|
|
55
|
+
"Set via `tableau-cli config set --pat-name <name> --pat-value <value>` "
|
|
56
|
+
"or PAT_NAME/PAT_VALUE env vars."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return Config(server=server, site_name=site_name, pat_name=pat_name, pat_value=pat_value)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Config:
|
|
8
|
+
server: str
|
|
9
|
+
site_name: str
|
|
10
|
+
pat_name: str
|
|
11
|
+
pat_value: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class PartialConfig:
|
|
16
|
+
server: str | None = None
|
|
17
|
+
site_name: str | None = None
|
|
18
|
+
pat_name: str | None = None
|
|
19
|
+
pat_value: str | None = None
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CliError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
*,
|
|
10
|
+
error_type: str,
|
|
11
|
+
message: str,
|
|
12
|
+
details: str | None = None,
|
|
13
|
+
hint: str | None = None,
|
|
14
|
+
):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.error_type = error_type
|
|
17
|
+
self.details = details
|
|
18
|
+
self.hint = hint
|
|
19
|
+
|
|
20
|
+
def to_output(self) -> dict[str, Any]:
|
|
21
|
+
output: dict[str, Any] = {
|
|
22
|
+
"isError": True,
|
|
23
|
+
"errorType": self.error_type,
|
|
24
|
+
"message": str(self),
|
|
25
|
+
}
|
|
26
|
+
if self.details:
|
|
27
|
+
output["details"] = self.details
|
|
28
|
+
if self.hint:
|
|
29
|
+
output["hint"] = self.hint
|
|
30
|
+
return output
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FeatureDisabledError(CliError):
|
|
34
|
+
def __init__(self, message: str, hint: str | None = None):
|
|
35
|
+
super().__init__(error_type="feature-disabled", message=message, hint=hint)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthenticationError(CliError):
|
|
39
|
+
def __init__(self, message: str):
|
|
40
|
+
super().__init__(
|
|
41
|
+
error_type="authentication-error",
|
|
42
|
+
message=message,
|
|
43
|
+
hint=("Check your PAT credentials with `tableau-cli config show`. Ensure the token has not expired."),
|
|
44
|
+
)
|