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.
Files changed (44) hide show
  1. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/PKG-INFO +28 -9
  2. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/README.md +27 -8
  3. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/pyproject.toml +1 -1
  4. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/skills/SKILL.md +8 -5
  5. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/skills/references/cli.md +55 -3
  6. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/skills/references/installation.md +7 -1
  7. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/api/client.py +93 -3
  8. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/cli.py +31 -1
  9. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/datasources_cmd.py +10 -2
  10. tableau_cli-0.1.3/src/tableau_cli/commands/projects_cmd.py +38 -0
  11. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/search_cmd.py +2 -1
  12. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/views_cmd.py +54 -4
  13. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/workbooks_cmd.py +10 -1
  14. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/utils/datasource_metadata_utils.py +16 -0
  15. tableau_cli-0.1.3/src/tableau_cli/utils/lineage_utils.py +216 -0
  16. tableau_cli-0.1.3/src/tableau_cli/utils/tableau_version.py +21 -0
  17. tableau_cli-0.1.3/src/tableau_cli/utils/web_url.py +33 -0
  18. tableau_cli-0.1.1/.claude/settings.local.json +0 -18
  19. tableau_cli-0.1.1/.claude/skills/tableau-cli +0 -1
  20. tableau_cli-0.1.1/skills-lock.json +0 -10
  21. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/.gitignore +0 -0
  22. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/LICENSE +0 -0
  23. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/NOTICE +0 -0
  24. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/__init__.py +0 -0
  25. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/api/__init__.py +0 -0
  26. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/auth/__init__.py +0 -0
  27. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/auth/with_auth.py +0 -0
  28. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/__init__.py +0 -0
  29. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/config_cmd.py +0 -0
  30. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/commands/convert_cmd.py +0 -0
  31. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/config/__init__.py +0 -0
  32. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/config/store.py +0 -0
  33. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/config/types.py +0 -0
  34. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/errors/__init__.py +0 -0
  35. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/errors/cli_error.py +0 -0
  36. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/errors/vds_error_handler.py +0 -0
  37. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/output/__init__.py +0 -0
  38. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/output/format.py +0 -0
  39. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/output/json_output.py +0 -0
  40. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/output/table_output.py +0 -0
  41. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/utils/__init__.py +0 -0
  42. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/utils/convert.py +0 -0
  43. {tableau_cli-0.1.1 → tableau_cli-0.1.3}/src/tableau_cli/utils/paginate.py +0 -0
  44. {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.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
- | Search | Content Exploration API |
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
- | Search | Content Exploration API |
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tableau-cli"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "CLI for Tableau Server/Cloud, designed for AI agent integration"
9
9
  license = "Apache-2.0"
10
10
  requires-python = ">=3.10"
@@ -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 workbooks — `ds list` / `views list` / `wb list`
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
- - Export view data as CSV — `views data`
41
- - Export view as image — `views image`
42
- - View workbook details and its views — `wb get`
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 `workbookId` to trace back to the parent workbook.
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 query_view_data(self, *, view_id: str, site_id: str) -> str:
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
- resp = self._client.get(url, headers=self._auth_headers())
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.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
- return paginate(page_size=page_size, limit=limit, get_data_fn=get_data_fn)
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
- def views_data(view_id):
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
- csv = with_auth(config, lambda api: api.query_view_data(view_id=view_id, site_id=api.site_id))
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
- return paginate(page_size=page_size, limit=limit, get_data_fn=get_data_fn)
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
@@ -1,10 +0,0 @@
1
- {
2
- "version": 1,
3
- "skills": {
4
- "tableau-cli": {
5
- "source": "i-richardwang/tableau-cli",
6
- "sourceType": "github",
7
- "computedHash": "26defe3fe68adfac902da3328240d33528db5fe9757f88f7e3a8bdc90744c6d9"
8
- }
9
- }
10
- }
File without changes
File without changes
File without changes