tableau-cli 0.1.1__tar.gz → 0.1.3__tar.gz
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-0.1.1 → tableau_cli-0.1.3}/PKG-INFO +28 -9
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/README.md +27 -8
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/pyproject.toml +1 -1
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/skills/SKILL.md +8 -5
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/skills/references/cli.md +55 -3
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/skills/references/installation.md +7 -1
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/api/client.py +93 -3
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/cli.py +31 -1
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/datasources_cmd.py +10 -2
- tableau_cli-0.1.3/src/tableau_cli/commands/projects_cmd.py +38 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/search_cmd.py +2 -1
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/views_cmd.py +54 -4
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/workbooks_cmd.py +10 -1
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/utils/datasource_metadata_utils.py +16 -0
- tableau_cli-0.1.3/src/tableau_cli/utils/lineage_utils.py +216 -0
- tableau_cli-0.1.3/src/tableau_cli/utils/tableau_version.py +21 -0
- tableau_cli-0.1.3/src/tableau_cli/utils/web_url.py +33 -0
- tableau_cli-0.1.1/.claude/settings.local.json +0 -18
- tableau_cli-0.1.1/.claude/skills/tableau-cli +0 -1
- tableau_cli-0.1.1/skills-lock.json +0 -10
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/.gitignore +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/LICENSE +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/NOTICE +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/__init__.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/api/__init__.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/auth/__init__.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/auth/with_auth.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/__init__.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/config_cmd.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/convert_cmd.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/config/__init__.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/config/store.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/config/types.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/errors/__init__.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/errors/cli_error.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/errors/vds_error_handler.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/output/__init__.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/output/format.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/output/json_output.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/output/table_output.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/utils/__init__.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/utils/convert.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/utils/paginate.py +0 -0
- {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/utils/search_content_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tableau-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: CLI for Tableau Server/Cloud, designed for AI agent integration
|
|
5
5
|
Project-URL: Repository, https://github.com/i-richardwang/tableau-cli
|
|
6
6
|
Author: Richard Wang
|
|
@@ -132,7 +132,9 @@ tableau-cli datasources download <datasourceId> -o ./data/
|
|
|
132
132
|
tableau-cli ds download <datasourceId> -o ./data/ --to parquet
|
|
133
133
|
tableau-cli ds download <datasourceId> -o ./data/ --to csv
|
|
134
134
|
|
|
135
|
-
# Get field metadata (VizQL Data Service + Metadata API enrichment)
|
|
135
|
+
# Get field metadata (VizQL Data Service + Metadata API enrichment).
|
|
136
|
+
# On Tableau >= 2025.3 the result also includes the datasource model:
|
|
137
|
+
# logical tables and their relationships.
|
|
136
138
|
tableau-cli datasources metadata <luid>
|
|
137
139
|
|
|
138
140
|
# Query datasource data (VizQL Data Service)
|
|
@@ -143,16 +145,23 @@ tableau-cli ds query <luid> --query '{"fields": [...]}' --limit 100
|
|
|
143
145
|
### Views
|
|
144
146
|
|
|
145
147
|
```bash
|
|
146
|
-
# List views
|
|
148
|
+
# List views (includes upstream datasources via Metadata API enrichment)
|
|
147
149
|
tableau-cli views list
|
|
148
150
|
tableau-cli views list --filter "name:has:Dashboard" --format table
|
|
149
151
|
|
|
152
|
+
# Get view details: upstream datasources, workbook/project/owner info, and web URL
|
|
153
|
+
tableau-cli views get <viewId>
|
|
154
|
+
|
|
150
155
|
# Get view data as CSV
|
|
151
156
|
tableau-cli views data <viewId>
|
|
152
157
|
|
|
158
|
+
# Get view data with view filters applied
|
|
159
|
+
tableau-cli views data <viewId> --vf "Region=West" --vf "Category=Technology"
|
|
160
|
+
|
|
153
161
|
# Download view image
|
|
154
162
|
tableau-cli views image <viewId> -o dashboard.png
|
|
155
163
|
tableau-cli views image <viewId> --width 1200 --height 800 --img-format SVG -o dashboard.svg
|
|
164
|
+
tableau-cli views image <viewId> --vf "Region=West" -o west.png
|
|
156
165
|
```
|
|
157
166
|
|
|
158
167
|
### Convert
|
|
@@ -177,14 +186,23 @@ tableau-cli convert extract.hyper --to csv -o ./output/result.csv
|
|
|
177
186
|
### Workbooks
|
|
178
187
|
|
|
179
188
|
```bash
|
|
180
|
-
# List workbooks
|
|
189
|
+
# List workbooks (includes upstream datasources via Metadata API enrichment)
|
|
181
190
|
tableau-cli workbooks list
|
|
182
191
|
tableau-cli wb list --filter "name:eq:Finance" --format table
|
|
183
192
|
|
|
184
|
-
# Get workbook details (includes views with usage statistics
|
|
193
|
+
# Get workbook details (includes views with usage statistics,
|
|
194
|
+
# upstream datasources, and the default view's web URL)
|
|
185
195
|
tableau-cli workbooks get <workbookId>
|
|
186
196
|
```
|
|
187
197
|
|
|
198
|
+
### Projects
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# List projects
|
|
202
|
+
tableau-cli projects list
|
|
203
|
+
tableau-cli projects list --filter "topLevelProject:eq:true" --format table
|
|
204
|
+
```
|
|
205
|
+
|
|
188
206
|
### Config
|
|
189
207
|
|
|
190
208
|
```bash
|
|
@@ -237,10 +255,11 @@ Error types include: `authentication-error`, `feature-disabled`, `tableau-api-er
|
|
|
237
255
|
| Area | APIs Used |
|
|
238
256
|
|------|-----------|
|
|
239
257
|
| Authentication | REST API v3.24 — PAT sign-in / sign-out |
|
|
240
|
-
| Datasources | REST API (list, download) + VizQL Data Service (metadata, query) + Metadata API (GraphQL enrichment) |
|
|
241
|
-
| Views | REST API (list, data, image) |
|
|
242
|
-
| Workbooks | REST API (list, get with view enrichment) |
|
|
243
|
-
|
|
|
258
|
+
| Datasources | REST API (list, download) + VizQL Data Service (metadata, model, query) + Metadata API (GraphQL enrichment) |
|
|
259
|
+
| Views | REST API (list, get, data, image with view filters) + Metadata API (lineage enrichment) |
|
|
260
|
+
| Workbooks | REST API (list, get with view enrichment) + Metadata API (lineage enrichment) |
|
|
261
|
+
| Projects | REST API (list) |
|
|
262
|
+
| Search | Content Exploration API + Metadata API (lineage enrichment) |
|
|
244
263
|
| Convert | Local file conversion: TDSX/HYPER → Parquet/CSV (optional dependencies) |
|
|
245
264
|
|
|
246
265
|
## Development
|
|
@@ -102,7 +102,9 @@ tableau-cli datasources download <datasourceId> -o ./data/
|
|
|
102
102
|
tableau-cli ds download <datasourceId> -o ./data/ --to parquet
|
|
103
103
|
tableau-cli ds download <datasourceId> -o ./data/ --to csv
|
|
104
104
|
|
|
105
|
-
# Get field metadata (VizQL Data Service + Metadata API enrichment)
|
|
105
|
+
# Get field metadata (VizQL Data Service + Metadata API enrichment).
|
|
106
|
+
# On Tableau >= 2025.3 the result also includes the datasource model:
|
|
107
|
+
# logical tables and their relationships.
|
|
106
108
|
tableau-cli datasources metadata <luid>
|
|
107
109
|
|
|
108
110
|
# Query datasource data (VizQL Data Service)
|
|
@@ -113,16 +115,23 @@ tableau-cli ds query <luid> --query '{"fields": [...]}' --limit 100
|
|
|
113
115
|
### Views
|
|
114
116
|
|
|
115
117
|
```bash
|
|
116
|
-
# List views
|
|
118
|
+
# List views (includes upstream datasources via Metadata API enrichment)
|
|
117
119
|
tableau-cli views list
|
|
118
120
|
tableau-cli views list --filter "name:has:Dashboard" --format table
|
|
119
121
|
|
|
122
|
+
# Get view details: upstream datasources, workbook/project/owner info, and web URL
|
|
123
|
+
tableau-cli views get <viewId>
|
|
124
|
+
|
|
120
125
|
# Get view data as CSV
|
|
121
126
|
tableau-cli views data <viewId>
|
|
122
127
|
|
|
128
|
+
# Get view data with view filters applied
|
|
129
|
+
tableau-cli views data <viewId> --vf "Region=West" --vf "Category=Technology"
|
|
130
|
+
|
|
123
131
|
# Download view image
|
|
124
132
|
tableau-cli views image <viewId> -o dashboard.png
|
|
125
133
|
tableau-cli views image <viewId> --width 1200 --height 800 --img-format SVG -o dashboard.svg
|
|
134
|
+
tableau-cli views image <viewId> --vf "Region=West" -o west.png
|
|
126
135
|
```
|
|
127
136
|
|
|
128
137
|
### Convert
|
|
@@ -147,14 +156,23 @@ tableau-cli convert extract.hyper --to csv -o ./output/result.csv
|
|
|
147
156
|
### Workbooks
|
|
148
157
|
|
|
149
158
|
```bash
|
|
150
|
-
# List workbooks
|
|
159
|
+
# List workbooks (includes upstream datasources via Metadata API enrichment)
|
|
151
160
|
tableau-cli workbooks list
|
|
152
161
|
tableau-cli wb list --filter "name:eq:Finance" --format table
|
|
153
162
|
|
|
154
|
-
# Get workbook details (includes views with usage statistics
|
|
163
|
+
# Get workbook details (includes views with usage statistics,
|
|
164
|
+
# upstream datasources, and the default view's web URL)
|
|
155
165
|
tableau-cli workbooks get <workbookId>
|
|
156
166
|
```
|
|
157
167
|
|
|
168
|
+
### Projects
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# List projects
|
|
172
|
+
tableau-cli projects list
|
|
173
|
+
tableau-cli projects list --filter "topLevelProject:eq:true" --format table
|
|
174
|
+
```
|
|
175
|
+
|
|
158
176
|
### Config
|
|
159
177
|
|
|
160
178
|
```bash
|
|
@@ -207,10 +225,11 @@ Error types include: `authentication-error`, `feature-disabled`, `tableau-api-er
|
|
|
207
225
|
| Area | APIs Used |
|
|
208
226
|
|------|-----------|
|
|
209
227
|
| Authentication | REST API v3.24 — PAT sign-in / sign-out |
|
|
210
|
-
| Datasources | REST API (list, download) + VizQL Data Service (metadata, query) + Metadata API (GraphQL enrichment) |
|
|
211
|
-
| Views | REST API (list, data, image) |
|
|
212
|
-
| Workbooks | REST API (list, get with view enrichment) |
|
|
213
|
-
|
|
|
228
|
+
| Datasources | REST API (list, download) + VizQL Data Service (metadata, model, query) + Metadata API (GraphQL enrichment) |
|
|
229
|
+
| Views | REST API (list, get, data, image with view filters) + Metadata API (lineage enrichment) |
|
|
230
|
+
| Workbooks | REST API (list, get with view enrichment) + Metadata API (lineage enrichment) |
|
|
231
|
+
| Projects | REST API (list) |
|
|
232
|
+
| Search | Content Exploration API + Metadata API (lineage enrichment) |
|
|
214
233
|
| Convert | Local file conversion: TDSX/HYPER → Parquet/CSV (optional dependencies) |
|
|
215
234
|
|
|
216
235
|
## Development
|
|
@@ -33,13 +33,14 @@ Before running any `tableau-cli` command, load [references/cli.md](references/cl
|
|
|
33
33
|
The following requests can be handled directly with CLI commands. Load [references/cli.md](references/cli.md) and execute:
|
|
34
34
|
|
|
35
35
|
- Search content across types — `search`
|
|
36
|
-
- List/filter datasources, views, or
|
|
36
|
+
- List/filter datasources, views, workbooks, or projects — `ds list` / `views list` / `wb list` / `projects list`
|
|
37
37
|
- Download a datasource file — `ds download` (supports `--to parquet` / `--to csv` for direct conversion)
|
|
38
|
-
- Inspect datasource field metadata — `ds metadata`
|
|
38
|
+
- Inspect datasource field metadata and table relationships — `ds metadata`
|
|
39
39
|
- Query data from a datasource — `ds query`
|
|
40
|
-
-
|
|
41
|
-
- Export view as
|
|
42
|
-
-
|
|
40
|
+
- View details of a single view, incl. its web URL and upstream datasources — `views get`
|
|
41
|
+
- Export view data as CSV — `views data` (supports `--vf "Field=Value"` view filters)
|
|
42
|
+
- Export view as image — `views image` (supports `--vf "Field=Value"` view filters)
|
|
43
|
+
- View workbook details, its views, and its web URL — `wb get`
|
|
43
44
|
- Convert TDSX/HYPER to Parquet/CSV — `convert`
|
|
44
45
|
|
|
45
46
|
Common combined workflows:
|
|
@@ -49,3 +50,5 @@ Common combined workflows:
|
|
|
49
50
|
**Quick data extraction (no file download)**: `ds metadata` → `ds query` (requires VizQL Data Service; fall back to download + convert if unavailable)
|
|
50
51
|
|
|
51
52
|
**Export dashboard screenshot**: `views list` → `views image`
|
|
53
|
+
|
|
54
|
+
**From dashboard to data**: `search` or `views get` → `upstreamDatasources[].luid` → `ds metadata` → `ds query`
|
|
@@ -40,6 +40,8 @@ tableau-cli search --format table
|
|
|
40
40
|
|
|
41
41
|
View results include `parentWorkbookName` (parent workbook) and `totalViewCount` (total views).
|
|
42
42
|
|
|
43
|
+
Workbook and view results include `upstreamDatasources[]` (`{luid, name}`) — the published datasources they are built on. Use this to jump from a dashboard to `ds metadata` / `ds query` without a separate lookup.
|
|
44
|
+
|
|
43
45
|
---
|
|
44
46
|
|
|
45
47
|
## datasources (alias: ds)
|
|
@@ -94,10 +96,11 @@ tableau-cli ds metadata <luid> --format table
|
|
|
94
96
|
|
|
95
97
|
Returns:
|
|
96
98
|
- `datasourceDescription` — datasource description
|
|
99
|
+
- `datasourceModel` — logical tables and their relationships (join fields and operators); only present on Tableau >= 2025.3
|
|
97
100
|
- `fieldGroups[].fields[]` — field list (name, dataType, role, formula, description, etc.)
|
|
98
101
|
- `parameters[]` — parameter list (name, parameterType, dataType, value, etc.)
|
|
99
102
|
|
|
100
|
-
Attempts to enrich via GraphQL Metadata API; falls back to base VizQL metadata if unavailable.
|
|
103
|
+
Attempts to enrich via GraphQL Metadata API; falls back to base VizQL metadata if unavailable. Use `datasourceModel` to understand multi-table datasources before constructing `ds query` requests.
|
|
101
104
|
|
|
102
105
|
### ds query
|
|
103
106
|
|
|
@@ -137,7 +140,22 @@ tableau-cli views list --filter "name:has:keyword" --format table
|
|
|
137
140
|
| `--limit` | int | none | Max total results |
|
|
138
141
|
| `--format` | json / table | json | Output format |
|
|
139
142
|
|
|
140
|
-
Results include `
|
|
143
|
+
Results include the parent `workbook` (with name), `project`, `owner`, `upstreamDatasources[]` (published datasources the view is built on), and `totalViewCount`.
|
|
144
|
+
|
|
145
|
+
### views get
|
|
146
|
+
|
|
147
|
+
View details for a single view, including its web URL.
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
tableau-cli views get <view-id>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
| Option | Type | Default | Description |
|
|
154
|
+
|--------|------|---------|-------------|
|
|
155
|
+
| `view_id` | string (positional) | required | View LUID |
|
|
156
|
+
| `--format` | json / table | json | Output format |
|
|
157
|
+
|
|
158
|
+
Returns the view with `upstreamDatasources[]`, parent `workbook` / `project` / `owner` info, usage statistics, and `url` — the web URL to open the view in a browser.
|
|
141
159
|
|
|
142
160
|
### views data
|
|
143
161
|
|
|
@@ -145,11 +163,13 @@ Export view data as CSV.
|
|
|
145
163
|
|
|
146
164
|
```bash
|
|
147
165
|
tableau-cli views data <view-id>
|
|
166
|
+
tableau-cli views data <view-id> --vf "Region=West" --vf "Category=Technology"
|
|
148
167
|
```
|
|
149
168
|
|
|
150
169
|
| Option | Type | Description |
|
|
151
170
|
|--------|------|-------------|
|
|
152
171
|
| `view_id` | string (positional) | View LUID |
|
|
172
|
+
| `--vf` | `Field=Value` (repeatable) | View filter: filter the view by field values before export |
|
|
153
173
|
|
|
154
174
|
Output: CSV text written directly to stdout (not JSON).
|
|
155
175
|
|
|
@@ -161,6 +181,7 @@ Export view as image.
|
|
|
161
181
|
tableau-cli views image <view-id> -o output.png
|
|
162
182
|
tableau-cli views image <view-id> --width 1200 --height 800 -o output.png
|
|
163
183
|
tableau-cli views image <view-id> --img-format SVG -o output.svg
|
|
184
|
+
tableau-cli views image <view-id> --vf "Region=West" -o west.png
|
|
164
185
|
```
|
|
165
186
|
|
|
166
187
|
| Option | Type | Default | Description |
|
|
@@ -169,6 +190,7 @@ tableau-cli views image <view-id> --img-format SVG -o output.svg
|
|
|
169
190
|
| `--width` | int | none | Image width in pixels |
|
|
170
191
|
| `--height` | int | none | Image height in pixels |
|
|
171
192
|
| `--img-format` | PNG / SVG | PNG | Image format |
|
|
193
|
+
| `--vf` | `Field=Value` (repeatable) | none | View filter: filter the view by field values before rendering |
|
|
172
194
|
| `-o, --output` | path | none | Output file path |
|
|
173
195
|
|
|
174
196
|
With `-o`: outputs `{"filePath": "<absolute path>"}`. Without `-o`: base64-encoded image to stdout.
|
|
@@ -191,6 +213,8 @@ tableau-cli wb list --filter "name:has:keyword" --format table
|
|
|
191
213
|
| `--limit` | int | none | Max total results |
|
|
192
214
|
| `--format` | json / table | json | Output format |
|
|
193
215
|
|
|
216
|
+
Results include `upstreamDatasources[]` (`{luid, name}`) — the published datasources each workbook is built on.
|
|
217
|
+
|
|
194
218
|
### wb get
|
|
195
219
|
|
|
196
220
|
View workbook details, including all views with usage statistics.
|
|
@@ -205,6 +229,29 @@ tableau-cli wb get <workbook-id> --format table
|
|
|
205
229
|
| `workbook_id` | string (positional) | required | Workbook LUID |
|
|
206
230
|
| `--format` | json / table | json | Output format |
|
|
207
231
|
|
|
232
|
+
Returns the workbook with `upstreamDatasources[]` and `url` — the web URL to open the workbook's default view in a browser.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## projects
|
|
237
|
+
|
|
238
|
+
### projects list
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
tableau-cli projects list
|
|
242
|
+
tableau-cli projects list --filter "topLevelProject:eq:true" --format table
|
|
243
|
+
tableau-cli projects list --filter "parentProjectId:eq:<project-id>"
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
| Option | Type | Default | Description |
|
|
247
|
+
|--------|------|---------|-------------|
|
|
248
|
+
| `--filter` | string | none | Server-side filter (fields: name, parentProjectId, topLevelProject, ownerName, createdAt, updatedAt) |
|
|
249
|
+
| `--page-size` | int | none | Items per page |
|
|
250
|
+
| `--limit` | int | none | Max total results |
|
|
251
|
+
| `--format` | json / table | json | Output format |
|
|
252
|
+
|
|
253
|
+
Results include name, description, parent project, content permissions, owner, and timestamps.
|
|
254
|
+
|
|
208
255
|
---
|
|
209
256
|
|
|
210
257
|
## convert
|
|
@@ -274,9 +321,14 @@ The `--filter` option uses `field:operator:value` format:
|
|
|
274
321
|
| Inspect datasource fields | `ds metadata <luid>` |
|
|
275
322
|
| Query data from a datasource | `ds query <luid> --query '{...}'` |
|
|
276
323
|
| Find a dashboard/view | `views list --filter "name:has:..."` |
|
|
324
|
+
| See view details / get view link | `views get <view-id>` |
|
|
325
|
+
| Find the datasource behind a dashboard | `views get <view-id>` → `upstreamDatasources` |
|
|
277
326
|
| Export view data | `views data <view-id>` |
|
|
327
|
+
| Export filtered view data | `views data <view-id> --vf "Field=Value"` |
|
|
278
328
|
| Export view screenshot | `views image <view-id> -o file.png` |
|
|
329
|
+
| Export filtered view screenshot | `views image <view-id> --vf "Field=Value" -o file.png` |
|
|
279
330
|
| Find workbooks | `wb list --filter "name:has:..."` |
|
|
280
|
-
| See views in a workbook | `wb get <workbook-id>` |
|
|
331
|
+
| See views in a workbook / get workbook link | `wb get <workbook-id>` |
|
|
332
|
+
| Browse projects / site structure | `projects list` |
|
|
281
333
|
| Convert local TDSX to Parquet | `convert file.tdsx -o dir/` |
|
|
282
334
|
| Download and convert for analysis | `ds download <id> --to parquet` |
|
|
@@ -9,7 +9,13 @@ description: Installation and authentication setup for tableau-cli. Load when th
|
|
|
9
9
|
pip install "tableau-cli[convert]"
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
Without `[convert]`, only core features are available (search, datasources, views, workbooks) — the `convert` command will not work.
|
|
12
|
+
Without `[convert]`, only core features are available (search, datasources, views, workbooks) — the `convert` command and `ds download --to parquet/csv` will not work.
|
|
13
|
+
|
|
14
|
+
## Upgrade
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install --upgrade "tableau-cli[convert]"
|
|
18
|
+
```
|
|
13
19
|
|
|
14
20
|
## Authentication
|
|
15
21
|
|
|
@@ -89,6 +89,14 @@ class RestApi:
|
|
|
89
89
|
self._client.post(url, headers=self._auth_headers())
|
|
90
90
|
self._creds = None
|
|
91
91
|
|
|
92
|
+
# ── Server (REST API) ───────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def get_server_info(self) -> dict[str, Any]:
|
|
95
|
+
url = f"{self._base_url}/serverinfo"
|
|
96
|
+
resp = self._client.get(url, headers=self._auth_headers())
|
|
97
|
+
resp.raise_for_status()
|
|
98
|
+
return resp.json()["serverInfo"]
|
|
99
|
+
|
|
92
100
|
# ── Datasources (REST API) ──────────────────────────────────────
|
|
93
101
|
|
|
94
102
|
def list_datasources(
|
|
@@ -167,9 +175,17 @@ class RestApi:
|
|
|
167
175
|
"views": [_shape_view(v) for v in raw_list],
|
|
168
176
|
}
|
|
169
177
|
|
|
170
|
-
def
|
|
178
|
+
def get_view(self, *, view_id: str, site_id: str) -> dict[str, Any]:
|
|
179
|
+
url = f"{self._base_url}/sites/{site_id}/views/{view_id}"
|
|
180
|
+
params = {"includeUsageStatistics": "true"}
|
|
181
|
+
resp = self._client.get(url, params=params, headers=self._auth_headers())
|
|
182
|
+
resp.raise_for_status()
|
|
183
|
+
return _shape_view(resp.json()["view"])
|
|
184
|
+
|
|
185
|
+
def query_view_data(self, *, view_id: str, site_id: str, view_filters: dict[str, str] | None = None) -> str:
|
|
171
186
|
url = f"{self._base_url}/sites/{site_id}/views/{view_id}/data"
|
|
172
|
-
|
|
187
|
+
params = _view_filter_params(view_filters)
|
|
188
|
+
resp = self._client.get(url, params=params, headers=self._auth_headers())
|
|
173
189
|
resp.raise_for_status()
|
|
174
190
|
return resp.text
|
|
175
191
|
|
|
@@ -181,6 +197,7 @@ class RestApi:
|
|
|
181
197
|
width: int | None = None,
|
|
182
198
|
height: int | None = None,
|
|
183
199
|
format_: str = "PNG",
|
|
200
|
+
view_filters: dict[str, str] | None = None,
|
|
184
201
|
) -> bytes:
|
|
185
202
|
url = f"{self._base_url}/sites/{site_id}/views/{view_id}/image"
|
|
186
203
|
params: dict[str, Any] = {"resolution": "high"}
|
|
@@ -190,6 +207,7 @@ class RestApi:
|
|
|
190
207
|
params["vizHeight"] = height
|
|
191
208
|
if format_:
|
|
192
209
|
params["format"] = format_
|
|
210
|
+
params.update(_view_filter_params(view_filters))
|
|
193
211
|
resp = self._client.get(url, params=params, headers=self._auth_headers())
|
|
194
212
|
if resp.status_code >= 400:
|
|
195
213
|
error_data: dict[str, Any] = {}
|
|
@@ -264,6 +282,33 @@ class RestApi:
|
|
|
264
282
|
"workbooks": [_shape_workbook(w) for w in raw_list],
|
|
265
283
|
}
|
|
266
284
|
|
|
285
|
+
# ── Projects (REST API) ─────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
def query_projects(
|
|
288
|
+
self,
|
|
289
|
+
*,
|
|
290
|
+
site_id: str,
|
|
291
|
+
filter_: str = "",
|
|
292
|
+
page_size: int | None = None,
|
|
293
|
+
page_number: int | None = None,
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
url = f"{self._base_url}/sites/{site_id}/projects"
|
|
296
|
+
params: dict[str, Any] = {}
|
|
297
|
+
if filter_:
|
|
298
|
+
params["filter"] = filter_
|
|
299
|
+
if page_size is not None:
|
|
300
|
+
params["pageSize"] = page_size
|
|
301
|
+
if page_number is not None:
|
|
302
|
+
params["pageNumber"] = page_number
|
|
303
|
+
resp = self._client.get(url, params=params, headers=self._auth_headers())
|
|
304
|
+
resp.raise_for_status()
|
|
305
|
+
data = resp.json()
|
|
306
|
+
raw_list = data.get("projects", {}).get("project") or []
|
|
307
|
+
return {
|
|
308
|
+
"pagination": _parse_pagination(data["pagination"]),
|
|
309
|
+
"projects": [_shape_project_detail(p) for p in raw_list],
|
|
310
|
+
}
|
|
311
|
+
|
|
267
312
|
# ── Content Exploration (Search) ────────────────────────────────
|
|
268
313
|
|
|
269
314
|
def search_content(
|
|
@@ -305,6 +350,18 @@ class RestApi:
|
|
|
305
350
|
resp.raise_for_status()
|
|
306
351
|
return resp.json()
|
|
307
352
|
|
|
353
|
+
def get_datasource_model(self, *, datasource_luid: str) -> dict[str, Any]:
|
|
354
|
+
url = f"{self._host}/api/v1/vizql-data-service/get-datasource-model"
|
|
355
|
+
body = {"datasource": {"datasourceLuid": datasource_luid}}
|
|
356
|
+
try:
|
|
357
|
+
resp = self._client.post(url, json=body, headers=self._auth_headers())
|
|
358
|
+
except httpx.TransportError as exc:
|
|
359
|
+
raise FeatureDisabledError(VDS_DISABLED_MESSAGE) from exc
|
|
360
|
+
if resp.status_code == 404:
|
|
361
|
+
raise FeatureDisabledError(VDS_DISABLED_MESSAGE)
|
|
362
|
+
resp.raise_for_status()
|
|
363
|
+
return resp.json()
|
|
364
|
+
|
|
308
365
|
def query_datasource(self, *, datasource_luid: str, query: dict, options: dict | None = None) -> dict[str, Any]:
|
|
309
366
|
url = f"{self._host}/api/v1/vizql-data-service/query-datasource"
|
|
310
367
|
body: dict[str, Any] = {
|
|
@@ -350,6 +407,13 @@ def _parse_pagination(p: dict[str, Any]) -> dict[str, int]:
|
|
|
350
407
|
}
|
|
351
408
|
|
|
352
409
|
|
|
410
|
+
def _view_filter_params(view_filters: dict[str, str] | None) -> dict[str, str]:
|
|
411
|
+
"""Map view filter field names to `vf_`-prefixed query parameters."""
|
|
412
|
+
if not view_filters:
|
|
413
|
+
return {}
|
|
414
|
+
return {(key if key.startswith("vf_") else f"vf_{key}"): value for key, value in view_filters.items()}
|
|
415
|
+
|
|
416
|
+
|
|
353
417
|
# ── Response shaping (equivalent to Zod schemas in TS version) ──────────
|
|
354
418
|
|
|
355
419
|
|
|
@@ -359,6 +423,31 @@ def _shape_project(raw: Any) -> dict[str, str]:
|
|
|
359
423
|
return {"name": raw.get("name", ""), "id": raw.get("id", "")}
|
|
360
424
|
|
|
361
425
|
|
|
426
|
+
def _shape_project_detail(raw: dict[str, Any]) -> dict[str, Any]:
|
|
427
|
+
"""Match TS projectSchema: {id, name, description?, parentProjectId?, contentPermissions?, ...}"""
|
|
428
|
+
result: dict[str, Any] = {
|
|
429
|
+
"id": raw.get("id", ""),
|
|
430
|
+
"name": raw.get("name", ""),
|
|
431
|
+
}
|
|
432
|
+
for key in (
|
|
433
|
+
"description",
|
|
434
|
+
"parentProjectId",
|
|
435
|
+
"contentPermissions",
|
|
436
|
+
"controllingPermissionsProjectId",
|
|
437
|
+
"createdAt",
|
|
438
|
+
"updatedAt",
|
|
439
|
+
):
|
|
440
|
+
if key in raw:
|
|
441
|
+
result[key] = raw[key]
|
|
442
|
+
# z.coerce.boolean() — convert string "true"/"false" to bool
|
|
443
|
+
if "topLevelProject" in raw:
|
|
444
|
+
top_level = raw["topLevelProject"]
|
|
445
|
+
result["topLevelProject"] = top_level.lower() == "true" if isinstance(top_level, str) else bool(top_level)
|
|
446
|
+
if isinstance(raw.get("owner"), dict) and raw["owner"].get("id"):
|
|
447
|
+
result["owner"] = {"id": raw["owner"]["id"]}
|
|
448
|
+
return result
|
|
449
|
+
|
|
450
|
+
|
|
362
451
|
def _shape_tags(raw: Any) -> dict[str, Any]:
|
|
363
452
|
if not isinstance(raw, dict):
|
|
364
453
|
return {}
|
|
@@ -382,10 +471,11 @@ def _shape_datasource(raw: dict[str, Any]) -> dict[str, Any]:
|
|
|
382
471
|
|
|
383
472
|
|
|
384
473
|
def _shape_view(raw: dict[str, Any]) -> dict[str, Any]:
|
|
385
|
-
"""Match TS viewSchema: {id, name, createdAt, updatedAt, workbook?, owner?, project?, tags, usage?}"""
|
|
474
|
+
"""Match TS viewSchema: {id, name, contentUrl, createdAt, updatedAt, workbook?, owner?, project?, tags, usage?}"""
|
|
386
475
|
result: dict[str, Any] = {
|
|
387
476
|
"id": raw.get("id", ""),
|
|
388
477
|
"name": raw.get("name", ""),
|
|
478
|
+
"contentUrl": raw.get("contentUrl", ""),
|
|
389
479
|
"createdAt": raw.get("createdAt", ""),
|
|
390
480
|
"updatedAt": raw.get("updatedAt", ""),
|
|
391
481
|
}
|
|
@@ -78,7 +78,35 @@ def to_error_output(err: Exception) -> dict[str, Any]:
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
def _check_for_update() -> str | None:
|
|
82
|
+
"""Check PyPI for a newer version. Returns a notice string, or None if up to date."""
|
|
83
|
+
try:
|
|
84
|
+
from importlib.metadata import version as installed_version
|
|
85
|
+
|
|
86
|
+
current = installed_version("tableau-cli")
|
|
87
|
+
resp = httpx.get("https://pypi.org/pypi/tableau-cli/json", timeout=3)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
latest = resp.json()["info"]["version"]
|
|
90
|
+
|
|
91
|
+
def version_key(version: str) -> tuple[int, ...]:
|
|
92
|
+
return tuple(int(part) for part in version.split("."))
|
|
93
|
+
|
|
94
|
+
if version_key(latest) > version_key(current):
|
|
95
|
+
return (
|
|
96
|
+
f'Update available: {current} → {latest}. Run `pip install --upgrade "tableau-cli[convert]"` to update.'
|
|
97
|
+
)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
81
103
|
class TableauCli(click.Group):
|
|
104
|
+
def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
|
|
105
|
+
super().format_help(ctx, formatter)
|
|
106
|
+
notice = _check_for_update()
|
|
107
|
+
if notice:
|
|
108
|
+
formatter.write(f"\n{notice}\n")
|
|
109
|
+
|
|
82
110
|
def invoke(self, ctx: click.Context) -> Any:
|
|
83
111
|
try:
|
|
84
112
|
return super().invoke(ctx)
|
|
@@ -95,7 +123,7 @@ class TableauCli(click.Group):
|
|
|
95
123
|
|
|
96
124
|
|
|
97
125
|
@click.group(cls=TableauCli)
|
|
98
|
-
@click.version_option("0.1.
|
|
126
|
+
@click.version_option("0.1.3")
|
|
99
127
|
def cli():
|
|
100
128
|
"""CLI tool for interacting with Tableau Server/Cloud."""
|
|
101
129
|
|
|
@@ -104,6 +132,7 @@ def main() -> None:
|
|
|
104
132
|
from .commands.config_cmd import config_group
|
|
105
133
|
from .commands.convert_cmd import convert_command
|
|
106
134
|
from .commands.datasources_cmd import datasources_group
|
|
135
|
+
from .commands.projects_cmd import projects_group
|
|
107
136
|
from .commands.search_cmd import search_command
|
|
108
137
|
from .commands.views_cmd import views_group
|
|
109
138
|
from .commands.workbooks_cmd import workbooks_group
|
|
@@ -112,6 +141,7 @@ def main() -> None:
|
|
|
112
141
|
cli.add_command(convert_command)
|
|
113
142
|
cli.add_command(datasources_group)
|
|
114
143
|
cli.add_command(datasources_group, name="ds") # alias
|
|
144
|
+
cli.add_command(projects_group)
|
|
115
145
|
cli.add_command(views_group)
|
|
116
146
|
cli.add_command(workbooks_group)
|
|
117
147
|
cli.add_command(workbooks_group, name="wb") # alias
|
|
@@ -104,17 +104,25 @@ def datasources_metadata(luid, fmt):
|
|
|
104
104
|
get_graphql_query,
|
|
105
105
|
simplify_read_metadata_result,
|
|
106
106
|
)
|
|
107
|
+
from ..utils.tableau_version import is_tableau_version_at_least
|
|
107
108
|
|
|
108
109
|
read_metadata_result = api.read_metadata(datasource_luid=luid)
|
|
109
110
|
|
|
111
|
+
# The datasource model (logical tables and their relationships) comes from
|
|
112
|
+
# a dedicated VizQL Data Service endpoint that requires Tableau >= 2025.3.
|
|
113
|
+
datasource_model_result = None
|
|
114
|
+
product_version = api.get_server_info().get("productVersion", {}).get("value", "")
|
|
115
|
+
if is_tableau_version_at_least(product_version, "2025.3.0"):
|
|
116
|
+
datasource_model_result = api.get_datasource_model(datasource_luid=luid)
|
|
117
|
+
|
|
110
118
|
# Try to enrich with Metadata API (GraphQL)
|
|
111
119
|
try:
|
|
112
120
|
graphql_result = api.graphql(get_graphql_query(luid))
|
|
113
121
|
except Exception:
|
|
114
122
|
# Metadata API may not be available
|
|
115
|
-
return simplify_read_metadata_result(read_metadata_result)
|
|
123
|
+
return simplify_read_metadata_result(read_metadata_result, datasource_model_result)
|
|
116
124
|
|
|
117
|
-
return combine_fields(read_metadata_result, graphql_result)
|
|
125
|
+
return combine_fields(read_metadata_result, graphql_result, datasource_model_result)
|
|
118
126
|
|
|
119
127
|
result = with_auth(config, fn)
|
|
120
128
|
output(result, fmt)
|
|
@@ -0,0 +1,38 @@
|
|
|
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("projects")
|
|
12
|
+
def projects_group():
|
|
13
|
+
"""Manage projects."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@projects_group.command("list")
|
|
17
|
+
@click.option("--filter", "filter_", default=None, help="Filter string (e.g., name:eq:Default)")
|
|
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 projects_list(filter_, page_size, limit, fmt):
|
|
22
|
+
"""List projects on the site."""
|
|
23
|
+
config = resolve_config()
|
|
24
|
+
|
|
25
|
+
def fn(api):
|
|
26
|
+
def get_data_fn(ps, pn):
|
|
27
|
+
result = api.query_projects(
|
|
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["projects"]}
|
|
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)
|
|
@@ -5,6 +5,7 @@ import click
|
|
|
5
5
|
from ..auth.with_auth import with_auth
|
|
6
6
|
from ..config.store import resolve_config
|
|
7
7
|
from ..output.format import output
|
|
8
|
+
from ..utils.lineage_utils import enrich_search_results_with_lineage
|
|
8
9
|
from ..utils.search_content_utils import reduce_search_content_response
|
|
9
10
|
|
|
10
11
|
|
|
@@ -33,7 +34,7 @@ def search_command(terms, type_, limit, order_by, fmt):
|
|
|
33
34
|
order_by=order_by,
|
|
34
35
|
filter_=",".join(filter_parts) if filter_parts else None,
|
|
35
36
|
)
|
|
36
|
-
return reduce_search_content_response(response)
|
|
37
|
+
return enrich_search_results_with_lineage(api, reduce_search_content_response(response))
|
|
37
38
|
|
|
38
39
|
result = with_auth(config, fn)
|
|
39
40
|
output(result, fmt)
|
|
@@ -2,13 +2,37 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
5
|
+
from typing import Any
|
|
5
6
|
|
|
6
7
|
import click
|
|
7
8
|
|
|
8
9
|
from ..auth.with_auth import with_auth
|
|
9
10
|
from ..config.store import resolve_config
|
|
10
11
|
from ..output.format import output
|
|
12
|
+
from ..utils.lineage_utils import enrich_views_with_lineage
|
|
11
13
|
from ..utils.paginate import paginate
|
|
14
|
+
from ..utils.web_url import construct_view_web_url
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_view_filters(vf: tuple[str, ...]) -> dict[str, str] | None:
|
|
18
|
+
"""Parse repeated --vf 'Field=Value' options into a view filter map."""
|
|
19
|
+
if not vf:
|
|
20
|
+
return None
|
|
21
|
+
view_filters: dict[str, str] = {}
|
|
22
|
+
for entry in vf:
|
|
23
|
+
field, sep, value = entry.partition("=")
|
|
24
|
+
if not sep or not field:
|
|
25
|
+
raise click.BadParameter(f"Expected 'Field=Value', got '{entry}'.", param_hint="--vf")
|
|
26
|
+
view_filters[field] = value
|
|
27
|
+
return view_filters
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _flatten_view_usage(views: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
31
|
+
"""Flatten usage statistics into a top-level totalViewCount (consistent with MCP behavior)."""
|
|
32
|
+
for view in views:
|
|
33
|
+
usage = view.pop("usage", None)
|
|
34
|
+
view["totalViewCount"] = usage.get("totalViewCount", 0) if isinstance(usage, dict) else 0
|
|
35
|
+
return views
|
|
12
36
|
|
|
13
37
|
|
|
14
38
|
@click.group("views")
|
|
@@ -35,7 +59,25 @@ def views_list(filter_, page_size, limit, fmt):
|
|
|
35
59
|
)
|
|
36
60
|
return {"pagination": result["pagination"], "data": result["views"]}
|
|
37
61
|
|
|
38
|
-
|
|
62
|
+
views = paginate(page_size=page_size, limit=limit, get_data_fn=get_data_fn)
|
|
63
|
+
return _flatten_view_usage(enrich_views_with_lineage(api, views))
|
|
64
|
+
|
|
65
|
+
result = with_auth(config, fn)
|
|
66
|
+
output(result, fmt)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@views_group.command("get")
|
|
70
|
+
@click.argument("view_id")
|
|
71
|
+
@click.option("--format", "fmt", default="json", help="Output format: json | table")
|
|
72
|
+
def views_get(view_id, fmt):
|
|
73
|
+
"""Get view details, including upstream datasources and web URL."""
|
|
74
|
+
config = resolve_config()
|
|
75
|
+
|
|
76
|
+
def fn(api):
|
|
77
|
+
view = api.get_view(view_id=view_id, site_id=api.site_id)
|
|
78
|
+
view = enrich_views_with_lineage(api, [view])[0]
|
|
79
|
+
view["url"] = construct_view_web_url(config.server, config.site_name, view["contentUrl"])
|
|
80
|
+
return view
|
|
39
81
|
|
|
40
82
|
result = with_auth(config, fn)
|
|
41
83
|
output(result, fmt)
|
|
@@ -43,10 +85,15 @@ def views_list(filter_, page_size, limit, fmt):
|
|
|
43
85
|
|
|
44
86
|
@views_group.command("data")
|
|
45
87
|
@click.argument("view_id")
|
|
46
|
-
|
|
88
|
+
@click.option("--vf", multiple=True, help="View filter as 'Field=Value' (repeatable)")
|
|
89
|
+
def views_data(view_id, vf):
|
|
47
90
|
"""Get view data as CSV."""
|
|
48
91
|
config = resolve_config()
|
|
49
|
-
|
|
92
|
+
view_filters = _parse_view_filters(vf)
|
|
93
|
+
csv = with_auth(
|
|
94
|
+
config,
|
|
95
|
+
lambda api: api.query_view_data(view_id=view_id, site_id=api.site_id, view_filters=view_filters),
|
|
96
|
+
)
|
|
50
97
|
sys.stdout.write(csv)
|
|
51
98
|
|
|
52
99
|
|
|
@@ -55,10 +102,12 @@ def views_data(view_id):
|
|
|
55
102
|
@click.option("--width", default=None, type=int, help="Image width in pixels")
|
|
56
103
|
@click.option("--height", default=None, type=int, help="Image height in pixels")
|
|
57
104
|
@click.option("--img-format", default="PNG", help="Image format: PNG | SVG")
|
|
105
|
+
@click.option("--vf", multiple=True, help="View filter as 'Field=Value' (repeatable)")
|
|
58
106
|
@click.option("-o", "--output", "output_path", default=None, help="Output file path")
|
|
59
|
-
def views_image(view_id, width, height, img_format, output_path):
|
|
107
|
+
def views_image(view_id, width, height, img_format, vf, output_path):
|
|
60
108
|
"""Download view image."""
|
|
61
109
|
config = resolve_config()
|
|
110
|
+
view_filters = _parse_view_filters(vf)
|
|
62
111
|
|
|
63
112
|
image_data = with_auth(
|
|
64
113
|
config,
|
|
@@ -68,6 +117,7 @@ def views_image(view_id, width, height, img_format, output_path):
|
|
|
68
117
|
width=width,
|
|
69
118
|
height=height,
|
|
70
119
|
format_=img_format,
|
|
120
|
+
view_filters=view_filters,
|
|
71
121
|
),
|
|
72
122
|
)
|
|
73
123
|
|
|
@@ -5,7 +5,9 @@ import click
|
|
|
5
5
|
from ..auth.with_auth import with_auth
|
|
6
6
|
from ..config.store import resolve_config
|
|
7
7
|
from ..output.format import output
|
|
8
|
+
from ..utils.lineage_utils import enrich_workbooks_with_lineage
|
|
8
9
|
from ..utils.paginate import paginate
|
|
10
|
+
from ..utils.web_url import get_default_view_web_url
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
@click.group("workbooks")
|
|
@@ -32,7 +34,8 @@ def workbooks_list(filter_, page_size, limit, fmt):
|
|
|
32
34
|
)
|
|
33
35
|
return {"pagination": result["pagination"], "data": result["workbooks"]}
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
workbooks = paginate(page_size=page_size, limit=limit, get_data_fn=get_data_fn)
|
|
38
|
+
return enrich_workbooks_with_lineage(api, workbooks)
|
|
36
39
|
|
|
37
40
|
result = with_auth(config, fn)
|
|
38
41
|
output(result, fmt)
|
|
@@ -53,6 +56,12 @@ def workbooks_get(workbook_id, fmt):
|
|
|
53
56
|
views = api.query_views_for_workbook(workbook_id=workbook_id, site_id=api.site_id)
|
|
54
57
|
workbook["views"]["view"] = views
|
|
55
58
|
|
|
59
|
+
workbook = enrich_workbooks_with_lineage(api, [workbook])[0]
|
|
60
|
+
|
|
61
|
+
url = get_default_view_web_url(workbook, config.server, config.site_name)
|
|
62
|
+
if url:
|
|
63
|
+
workbook["url"] = url
|
|
64
|
+
|
|
56
65
|
return workbook
|
|
57
66
|
|
|
58
67
|
result = with_auth(config, fn)
|
|
@@ -137,11 +137,25 @@ def _build_parameters(extra_data: dict[str, Any] | None) -> list[dict[str, Any]]
|
|
|
137
137
|
return parameters
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def _datasource_model_entry(datasource_model_result: dict[str, Any] | None) -> dict[str, Any]:
|
|
141
|
+
"""Optional datasourceModel entry, placed after datasourceDescription to match the MCP response shape."""
|
|
142
|
+
if datasource_model_result is None:
|
|
143
|
+
return {}
|
|
144
|
+
return {
|
|
145
|
+
"datasourceModel": {
|
|
146
|
+
"logicalTables": datasource_model_result.get("logicalTables", []),
|
|
147
|
+
"logicalTableRelationships": datasource_model_result.get("logicalTableRelationships", []),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
140
152
|
def simplify_read_metadata_result(
|
|
141
153
|
read_metadata_result: dict[str, Any],
|
|
154
|
+
datasource_model_result: dict[str, Any] | None = None,
|
|
142
155
|
) -> dict[str, Any]:
|
|
143
156
|
result: dict[str, Any] = {
|
|
144
157
|
"datasourceDescription": "",
|
|
158
|
+
**_datasource_model_entry(datasource_model_result),
|
|
145
159
|
"fieldGroups": [],
|
|
146
160
|
"parameters": [],
|
|
147
161
|
}
|
|
@@ -172,12 +186,14 @@ def simplify_read_metadata_result(
|
|
|
172
186
|
def combine_fields(
|
|
173
187
|
read_metadata_result: dict[str, Any],
|
|
174
188
|
list_fields_result: dict[str, Any],
|
|
189
|
+
datasource_model_result: dict[str, Any] | None = None,
|
|
175
190
|
) -> dict[str, Any]:
|
|
176
191
|
ds_list = list_fields_result.get("data", {}).get("publishedDatasources", [])
|
|
177
192
|
ds = ds_list[0] if ds_list else None
|
|
178
193
|
|
|
179
194
|
combined: dict[str, Any] = {
|
|
180
195
|
"datasourceDescription": (ds.get("description") or "") if ds else "",
|
|
196
|
+
**_datasource_model_entry(datasource_model_result),
|
|
181
197
|
"fieldGroups": [],
|
|
182
198
|
"parameters": [],
|
|
183
199
|
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ..api.client import RestApi
|
|
9
|
+
|
|
10
|
+
# Lineage enrichment (equivalent to lineageUtils.ts in the TS version).
|
|
11
|
+
#
|
|
12
|
+
# Workbooks, views, and search results are enriched with their upstream published
|
|
13
|
+
# datasources via a single batched Metadata API (GraphQL) query. Enrichment is
|
|
14
|
+
# best-effort: if the Metadata API is unavailable, the original results are
|
|
15
|
+
# returned unchanged and a warning is written to stderr.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _to_graphql_string_array(values: list[str]) -> str:
|
|
19
|
+
return f"[{', '.join(json.dumps(v) for v in values)}]"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _workbooks_connection_query(workbook_luids: list[str]) -> str:
|
|
23
|
+
return f"""workbooksConnection(filter: {{ luidWithin: {_to_graphql_string_array(workbook_luids)} }}) {{
|
|
24
|
+
nodes {{
|
|
25
|
+
luid
|
|
26
|
+
upstreamDatasources {{
|
|
27
|
+
luid
|
|
28
|
+
name
|
|
29
|
+
}}
|
|
30
|
+
}}
|
|
31
|
+
}}"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sheets_connection_query(view_luids: list[str]) -> str:
|
|
35
|
+
return f"""sheetsConnection(filter: {{ luidWithin: {_to_graphql_string_array(view_luids)} }}) {{
|
|
36
|
+
nodes {{
|
|
37
|
+
luid
|
|
38
|
+
upstreamDatasources {{
|
|
39
|
+
name
|
|
40
|
+
... on PublishedDatasource {{
|
|
41
|
+
luid
|
|
42
|
+
}}
|
|
43
|
+
}}
|
|
44
|
+
workbook {{
|
|
45
|
+
luid
|
|
46
|
+
name
|
|
47
|
+
projectLuid
|
|
48
|
+
projectName
|
|
49
|
+
owner {{
|
|
50
|
+
luid
|
|
51
|
+
name
|
|
52
|
+
username
|
|
53
|
+
}}
|
|
54
|
+
}}
|
|
55
|
+
}}
|
|
56
|
+
}}"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_workbook_lineage_query(workbook_luids: list[str]) -> str:
|
|
60
|
+
return f"""
|
|
61
|
+
query workbookLineage {{
|
|
62
|
+
{_workbooks_connection_query(workbook_luids)}
|
|
63
|
+
}}"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_view_lineage_query(view_luids: list[str]) -> str:
|
|
67
|
+
return f"""
|
|
68
|
+
query viewLineage {{
|
|
69
|
+
{_sheets_connection_query(view_luids)}
|
|
70
|
+
}}"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_search_content_lineage_query(workbook_luids: list[str], view_luids: list[str]) -> str:
|
|
74
|
+
return f"""
|
|
75
|
+
query searchContentLineage {{
|
|
76
|
+
{_workbooks_connection_query(workbook_luids) if workbook_luids else ""}
|
|
77
|
+
{_sheets_connection_query(view_luids) if view_luids else ""}
|
|
78
|
+
}}"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _normalize_lineage_contents(contents: Any) -> list[dict[str, str]]:
|
|
82
|
+
if not isinstance(contents, list):
|
|
83
|
+
return []
|
|
84
|
+
return [
|
|
85
|
+
{"luid": c["luid"], "name": c.get("name") or c["luid"]}
|
|
86
|
+
for c in contents
|
|
87
|
+
if isinstance(c, dict) and c.get("luid")
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_workbook_lineage_by_luid(response: dict[str, Any]) -> dict[str, list[dict[str, str]]]:
|
|
92
|
+
nodes = response.get("data", {}).get("workbooksConnection", {}).get("nodes") or []
|
|
93
|
+
return {
|
|
94
|
+
node["luid"]: _normalize_lineage_contents(node.get("upstreamDatasources"))
|
|
95
|
+
for node in nodes
|
|
96
|
+
if isinstance(node, dict) and node.get("luid")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_view_lineage_by_luid(response: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
101
|
+
nodes = response.get("data", {}).get("sheetsConnection", {}).get("nodes") or []
|
|
102
|
+
lineage_by_luid: dict[str, dict[str, Any]] = {}
|
|
103
|
+
for node in nodes:
|
|
104
|
+
if not isinstance(node, dict) or not node.get("luid"):
|
|
105
|
+
continue
|
|
106
|
+
workbook = node.get("workbook") if isinstance(node.get("workbook"), dict) else None
|
|
107
|
+
owner = workbook.get("owner") if workbook and isinstance(workbook.get("owner"), dict) else None
|
|
108
|
+
lineage_by_luid[node["luid"]] = {
|
|
109
|
+
"upstreamDatasources": _normalize_lineage_contents(node.get("upstreamDatasources")),
|
|
110
|
+
"workbook": (
|
|
111
|
+
{"luid": workbook["luid"], "name": workbook["name"]} if workbook and workbook.get("name") else None
|
|
112
|
+
),
|
|
113
|
+
"ownerLuid": owner.get("luid") if owner else None,
|
|
114
|
+
"ownerName": (owner.get("name") or owner.get("username")) if owner else None,
|
|
115
|
+
"projectLuid": workbook.get("projectLuid") if workbook else None,
|
|
116
|
+
"projectName": workbook.get("projectName") if workbook else None,
|
|
117
|
+
}
|
|
118
|
+
return lineage_by_luid
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def merge_workbook_lineage(
|
|
122
|
+
workbooks: list[dict[str, Any]],
|
|
123
|
+
lineage_by_luid: dict[str, list[dict[str, str]]],
|
|
124
|
+
) -> list[dict[str, Any]]:
|
|
125
|
+
for workbook in workbooks:
|
|
126
|
+
upstream_datasources = lineage_by_luid.get(workbook.get("id", ""))
|
|
127
|
+
if upstream_datasources:
|
|
128
|
+
workbook["upstreamDatasources"] = upstream_datasources
|
|
129
|
+
return workbooks
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def merge_view_lineage(
|
|
133
|
+
views: list[dict[str, Any]],
|
|
134
|
+
lineage_by_luid: dict[str, dict[str, Any]],
|
|
135
|
+
) -> list[dict[str, Any]]:
|
|
136
|
+
for view in views:
|
|
137
|
+
lineage = lineage_by_luid.get(view.get("id", ""))
|
|
138
|
+
if not lineage:
|
|
139
|
+
continue
|
|
140
|
+
if lineage["upstreamDatasources"]:
|
|
141
|
+
view["upstreamDatasources"] = lineage["upstreamDatasources"]
|
|
142
|
+
if lineage["workbook"]:
|
|
143
|
+
workbook = view.setdefault("workbook", {})
|
|
144
|
+
workbook.setdefault("id", lineage["workbook"]["luid"])
|
|
145
|
+
workbook["name"] = lineage["workbook"]["name"]
|
|
146
|
+
if lineage["ownerLuid"] or lineage["ownerName"]:
|
|
147
|
+
owner = view.setdefault("owner", {})
|
|
148
|
+
if lineage["ownerLuid"]:
|
|
149
|
+
owner.setdefault("id", lineage["ownerLuid"])
|
|
150
|
+
if lineage["ownerName"]:
|
|
151
|
+
owner["name"] = lineage["ownerName"]
|
|
152
|
+
if lineage["projectLuid"] or lineage["projectName"]:
|
|
153
|
+
project = view.setdefault("project", {})
|
|
154
|
+
if lineage["projectLuid"]:
|
|
155
|
+
project.setdefault("id", lineage["projectLuid"])
|
|
156
|
+
if lineage["projectName"]:
|
|
157
|
+
project["name"] = lineage["projectName"]
|
|
158
|
+
return views
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _warn_enrichment_failed(subject: str, exc: Exception) -> None:
|
|
162
|
+
sys.stderr.write(f"Warning: failed to enrich {subject} with lineage metadata: {exc}\n")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def enrich_workbooks_with_lineage(api: RestApi, workbooks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
166
|
+
if not workbooks:
|
|
167
|
+
return workbooks
|
|
168
|
+
try:
|
|
169
|
+
response = api.graphql(get_workbook_lineage_query([w["id"] for w in workbooks]))
|
|
170
|
+
return merge_workbook_lineage(workbooks, get_workbook_lineage_by_luid(response))
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
_warn_enrichment_failed("workbooks", exc)
|
|
173
|
+
return workbooks
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def enrich_views_with_lineage(api: RestApi, views: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
177
|
+
if not views:
|
|
178
|
+
return views
|
|
179
|
+
try:
|
|
180
|
+
response = api.graphql(get_view_lineage_query([v["id"] for v in views]))
|
|
181
|
+
return merge_view_lineage(views, get_view_lineage_by_luid(response))
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
_warn_enrichment_failed("views", exc)
|
|
184
|
+
return views
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def enrich_search_results_with_lineage(api: RestApi, search_results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
188
|
+
"""Attach upstreamDatasources to workbook and view search results."""
|
|
189
|
+
|
|
190
|
+
def luids_of_type(content_type: str) -> list[str]:
|
|
191
|
+
return [r["luid"] for r in search_results if r.get("type") == content_type and isinstance(r.get("luid"), str)]
|
|
192
|
+
|
|
193
|
+
workbook_luids = luids_of_type("workbook")
|
|
194
|
+
view_luids = luids_of_type("view")
|
|
195
|
+
if not workbook_luids and not view_luids:
|
|
196
|
+
return search_results
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
response = api.graphql(get_search_content_lineage_query(workbook_luids, view_luids))
|
|
200
|
+
workbook_lineage_by_luid = get_workbook_lineage_by_luid(response)
|
|
201
|
+
view_lineage_by_luid = get_view_lineage_by_luid(response)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
_warn_enrichment_failed("search results", exc)
|
|
204
|
+
return search_results
|
|
205
|
+
|
|
206
|
+
for item in search_results:
|
|
207
|
+
if item.get("type") == "workbook":
|
|
208
|
+
upstream_datasources = workbook_lineage_by_luid.get(item.get("luid", ""))
|
|
209
|
+
elif item.get("type") == "view":
|
|
210
|
+
lineage = view_lineage_by_luid.get(item.get("luid", ""))
|
|
211
|
+
upstream_datasources = lineage["upstreamDatasources"] if lineage else None
|
|
212
|
+
else:
|
|
213
|
+
continue
|
|
214
|
+
if upstream_datasources:
|
|
215
|
+
item["upstreamDatasources"] = upstream_datasources
|
|
216
|
+
return search_results
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def is_tableau_version_at_least(product_version: str, min_version: str) -> bool:
|
|
5
|
+
"""Check whether a Tableau product version meets a minimum release version.
|
|
6
|
+
|
|
7
|
+
Mirrors the TS isTableauVersionAtLeast helper: builds from the main branch
|
|
8
|
+
("main") or unrecognized version formats are assumed to be fresh builds and
|
|
9
|
+
pass the check.
|
|
10
|
+
"""
|
|
11
|
+
if product_version == "main":
|
|
12
|
+
return True
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
year, major, minor = (int(part) for part in product_version.split("."))
|
|
16
|
+
except ValueError:
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
min_year, min_major, min_minor = (int(part) for part in min_version.split("."))
|
|
20
|
+
|
|
21
|
+
return (year, major, minor) >= (min_year, min_major, min_minor)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def construct_view_web_url(server: str, site_name: str, content_url: str) -> str:
|
|
7
|
+
"""Construct the web URL for opening a Tableau view in a browser.
|
|
8
|
+
|
|
9
|
+
The API returns contentUrl as 'workbook/sheets/Sheet1' while the web URL
|
|
10
|
+
uses 'workbook/Sheet1'. The default site omits the /site/{siteName} segment.
|
|
11
|
+
"""
|
|
12
|
+
url_path = content_url.replace("/sheets/", "/")
|
|
13
|
+
|
|
14
|
+
if not site_name or site_name == "Default":
|
|
15
|
+
return f"{server.rstrip('/')}/#/views/{url_path}"
|
|
16
|
+
return f"{server.rstrip('/')}/#/site/{site_name}/views/{url_path}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_default_view_web_url(workbook: dict[str, Any], server: str, site_name: str) -> str | None:
|
|
20
|
+
"""Web URL for a workbook's default view, falling back to its first view."""
|
|
21
|
+
views = (workbook.get("views") or {}).get("view") or []
|
|
22
|
+
if not views:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
default_view_id = workbook.get("defaultViewId")
|
|
26
|
+
target_view = next((v for v in views if v.get("id") == default_view_id), None) if default_view_id else None
|
|
27
|
+
if target_view is None:
|
|
28
|
+
target_view = views[0]
|
|
29
|
+
|
|
30
|
+
if not target_view.get("contentUrl"):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
return construct_view_web_url(server, site_name, target_view["contentUrl"])
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(python:*)",
|
|
5
|
-
"Bash(echo \"EXIT: $?\")",
|
|
6
|
-
"Bash(tableau-cli datasources:*)",
|
|
7
|
-
"Bash(tableau-cli convert:*)",
|
|
8
|
-
"Bash(ruff check:*)",
|
|
9
|
-
"Bash(git add:*)",
|
|
10
|
-
"Bash(git commit:*)",
|
|
11
|
-
"Bash(git push:*)",
|
|
12
|
-
"Bash(pip index:*)",
|
|
13
|
-
"Bash(pip install:*)",
|
|
14
|
-
"Bash(bash -l -c \"history\")",
|
|
15
|
-
"Bash(twine --version)"
|
|
16
|
-
]
|
|
17
|
-
}
|
|
18
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
../../.agents/skills/tableau-cli
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|