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,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
+ )