tableau-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
File without changes
@@ -0,0 +1,429 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from ..errors.cli_error import CliError, FeatureDisabledError
10
+ from ..errors.vds_error_handler import handle_vds_error
11
+
12
+ API_VERSION = "3.24"
13
+ TIMEOUT_S = 120
14
+
15
+ VDS_DISABLED_MESSAGE = (
16
+ "The VizQL Data Service is disabled on this Tableau Server. "
17
+ "To enable it, use TSM using the instructions at "
18
+ "https://help.tableau.com/current/server-linux/en-us/cli_configuration-set_tsm.htm"
19
+ "#featuresvizqldataservicedeploywithtsm."
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class Credentials:
25
+ site_id: str
26
+ user_id: str
27
+ token: str
28
+
29
+
30
+ class RestApi:
31
+ def __init__(self, host: str) -> None:
32
+ self._host = host.rstrip("/")
33
+ self._creds: Credentials | None = None
34
+ self._client = httpx.Client(
35
+ timeout=TIMEOUT_S,
36
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
37
+ )
38
+
39
+ @property
40
+ def _base_url(self) -> str:
41
+ return f"{self._host}/api/{API_VERSION}"
42
+
43
+ @property
44
+ def _base_url_no_version(self) -> str:
45
+ return f"{self._host}/api/-"
46
+
47
+ @property
48
+ def _creds_required(self) -> Credentials:
49
+ if not self._creds:
50
+ raise RuntimeError("Not authenticated. Call sign_in() first.")
51
+ return self._creds
52
+
53
+ @property
54
+ def site_id(self) -> str:
55
+ return self._creds_required.site_id
56
+
57
+ @property
58
+ def user_id(self) -> str:
59
+ return self._creds_required.user_id
60
+
61
+ def _auth_headers(self) -> dict[str, str]:
62
+ return {"X-Tableau-Auth": self._creds_required.token}
63
+
64
+ # ── Authentication ──────────────────────────────────────────────
65
+
66
+ def sign_in(self, *, site_name: str, pat_name: str, pat_value: str) -> None:
67
+ url = f"{self._base_url}/auth/signin"
68
+ body = {
69
+ "credentials": {
70
+ "site": {"contentUrl": site_name},
71
+ "personalAccessTokenName": pat_name,
72
+ "personalAccessTokenSecret": pat_value,
73
+ }
74
+ }
75
+ resp = self._client.post(url, json=body)
76
+ resp.raise_for_status()
77
+ creds = resp.json()["credentials"]
78
+ self._creds = Credentials(
79
+ site_id=creds["site"]["id"],
80
+ user_id=creds["user"]["id"],
81
+ token=creds["token"],
82
+ )
83
+
84
+ def sign_out(self) -> None:
85
+ if not self._creds:
86
+ return
87
+ url = f"{self._base_url}/auth/signout"
88
+ with contextlib.suppress(Exception):
89
+ self._client.post(url, headers=self._auth_headers())
90
+ self._creds = None
91
+
92
+ # ── Datasources (REST API) ──────────────────────────────────────
93
+
94
+ def list_datasources(
95
+ self,
96
+ *,
97
+ site_id: str,
98
+ filter_: str = "",
99
+ page_size: int | None = None,
100
+ page_number: int | None = None,
101
+ ) -> dict[str, Any]:
102
+ url = f"{self._base_url}/sites/{site_id}/datasources"
103
+ params: dict[str, Any] = {}
104
+ if filter_:
105
+ params["filter"] = filter_
106
+ if page_size is not None:
107
+ params["pageSize"] = page_size
108
+ if page_number is not None:
109
+ params["pageNumber"] = page_number
110
+ resp = self._client.get(url, params=params, headers=self._auth_headers())
111
+ resp.raise_for_status()
112
+ data = resp.json()
113
+ raw_list = data.get("datasources", {}).get("datasource") or []
114
+ return {
115
+ "pagination": _parse_pagination(data["pagination"]),
116
+ "datasources": [_shape_datasource(d) for d in raw_list],
117
+ }
118
+
119
+ def download_datasource(self, *, datasource_id: str, site_id: str) -> tuple[bytes, str]:
120
+ """Download datasource content. Returns (data, filename)."""
121
+ url = f"{self._base_url}/sites/{site_id}/datasources/{datasource_id}/content"
122
+ resp = self._client.get(url, headers=self._auth_headers())
123
+ resp.raise_for_status()
124
+
125
+ # Extract filename from Content-Disposition header
126
+ filename = f"{datasource_id}.tdsx"
127
+ cd = resp.headers.get("content-disposition", "")
128
+ if "filename*=" in cd:
129
+ # RFC 5987: filename*=UTF-8''encoded_name.tdsx
130
+ part = cd.split("filename*=", 1)[1].split(";", 1)[0].strip()
131
+ if "''" in part:
132
+ from urllib.parse import unquote
133
+
134
+ filename = unquote(part.split("''", 1)[1])
135
+ elif "filename=" in cd:
136
+ from urllib.parse import unquote_plus
137
+
138
+ part = cd.split("filename=", 1)[1].split(";", 1)[0].strip()
139
+ filename = unquote_plus(part.strip('"'))
140
+
141
+ return resp.content, filename
142
+
143
+ # ── Views (REST API) ────────────────────────────────────────────
144
+
145
+ def query_views_for_site(
146
+ self,
147
+ *,
148
+ site_id: str,
149
+ filter_: str = "",
150
+ page_size: int | None = None,
151
+ page_number: int | None = None,
152
+ ) -> dict[str, Any]:
153
+ url = f"{self._base_url}/sites/{site_id}/views"
154
+ params: dict[str, Any] = {"includeUsageStatistics": "true"}
155
+ if filter_:
156
+ params["filter"] = filter_
157
+ if page_size is not None:
158
+ params["pageSize"] = page_size
159
+ if page_number is not None:
160
+ params["pageNumber"] = page_number
161
+ resp = self._client.get(url, params=params, headers=self._auth_headers())
162
+ resp.raise_for_status()
163
+ data = resp.json()
164
+ raw_list = data.get("views", {}).get("view") or []
165
+ return {
166
+ "pagination": _parse_pagination(data["pagination"]),
167
+ "views": [_shape_view(v) for v in raw_list],
168
+ }
169
+
170
+ def query_view_data(self, *, view_id: str, site_id: str) -> str:
171
+ url = f"{self._base_url}/sites/{site_id}/views/{view_id}/data"
172
+ resp = self._client.get(url, headers=self._auth_headers())
173
+ resp.raise_for_status()
174
+ return resp.text
175
+
176
+ def query_view_image(
177
+ self,
178
+ *,
179
+ view_id: str,
180
+ site_id: str,
181
+ width: int | None = None,
182
+ height: int | None = None,
183
+ format_: str = "PNG",
184
+ ) -> bytes:
185
+ url = f"{self._base_url}/sites/{site_id}/views/{view_id}/image"
186
+ params: dict[str, Any] = {"resolution": "high"}
187
+ if width is not None:
188
+ params["vizWidth"] = width
189
+ if height is not None:
190
+ params["vizHeight"] = height
191
+ if format_:
192
+ params["format"] = format_
193
+ resp = self._client.get(url, params=params, headers=self._auth_headers())
194
+ if resp.status_code >= 400:
195
+ error_data: dict[str, Any] = {}
196
+ try:
197
+ error_data = resp.json()
198
+ except Exception:
199
+ try:
200
+ error_data = {"error": None}
201
+ text = resp.text
202
+ import json as _json
203
+
204
+ error_data = _json.loads(text)
205
+ except Exception:
206
+ resp.raise_for_status()
207
+ if error_data.get("error", {}).get("code") == "403157":
208
+ is_svg = format_ == "SVG"
209
+ raise FeatureDisabledError(
210
+ "SVG format is not supported on this Tableau Server. It requires Tableau 2026.2.0 or later."
211
+ if is_svg
212
+ else "The image format feature is disabled on this Tableau Server.",
213
+ hint="Retry with --img-format PNG instead." if is_svg else None,
214
+ )
215
+ if error_data.get("error"):
216
+ err = error_data["error"]
217
+ summary = err.get("summary", "")
218
+ detail = err.get("detail", "")
219
+ raise CliError(
220
+ error_type="view-image-error",
221
+ message=f"{summary}: {detail}" if detail else summary,
222
+ )
223
+ resp.raise_for_status()
224
+ return resp.content
225
+
226
+ def query_views_for_workbook(self, *, workbook_id: str, site_id: str) -> list[dict[str, Any]]:
227
+ url = f"{self._base_url}/sites/{site_id}/workbooks/{workbook_id}/views"
228
+ params = {"includeUsageStatistics": "true"}
229
+ resp = self._client.get(url, params=params, headers=self._auth_headers())
230
+ resp.raise_for_status()
231
+ raw_list = resp.json()["views"]["view"]
232
+ return [_shape_view(v) for v in raw_list]
233
+
234
+ # ── Workbooks (REST API) ────────────────────────────────────────
235
+
236
+ def get_workbook(self, *, workbook_id: str, site_id: str) -> dict[str, Any]:
237
+ url = f"{self._base_url}/sites/{site_id}/workbooks/{workbook_id}"
238
+ resp = self._client.get(url, headers=self._auth_headers())
239
+ resp.raise_for_status()
240
+ return _shape_workbook(resp.json()["workbook"])
241
+
242
+ def query_workbooks_for_site(
243
+ self,
244
+ *,
245
+ site_id: str,
246
+ filter_: str = "",
247
+ page_size: int | None = None,
248
+ page_number: int | None = None,
249
+ ) -> dict[str, Any]:
250
+ url = f"{self._base_url}/sites/{site_id}/workbooks"
251
+ params: dict[str, Any] = {}
252
+ if filter_:
253
+ params["filter"] = filter_
254
+ if page_size is not None:
255
+ params["pageSize"] = page_size
256
+ if page_number is not None:
257
+ params["pageNumber"] = page_number
258
+ resp = self._client.get(url, params=params, headers=self._auth_headers())
259
+ resp.raise_for_status()
260
+ data = resp.json()
261
+ raw_list = data.get("workbooks", {}).get("workbook") or []
262
+ return {
263
+ "pagination": _parse_pagination(data["pagination"]),
264
+ "workbooks": [_shape_workbook(w) for w in raw_list],
265
+ }
266
+
267
+ # ── Content Exploration (Search) ────────────────────────────────
268
+
269
+ def search_content(
270
+ self,
271
+ *,
272
+ terms: str | None = None,
273
+ page: int | None = None,
274
+ limit: int | None = None,
275
+ order_by: str | None = None,
276
+ filter_: str | None = None,
277
+ ) -> dict[str, Any]:
278
+ url = f"{self._base_url_no_version}/search"
279
+ params: dict[str, Any] = {}
280
+ if terms is not None:
281
+ params["terms"] = terms
282
+ if page is not None:
283
+ params["page"] = page
284
+ if limit is not None:
285
+ params["limit"] = limit
286
+ if order_by is not None:
287
+ params["order_by"] = order_by
288
+ if filter_ is not None:
289
+ params["filter"] = filter_
290
+ resp = self._client.get(url, params=params, headers=self._auth_headers())
291
+ resp.raise_for_status()
292
+ return resp.json()["hits"]
293
+
294
+ # ── VizQL Data Service ──────────────────────────────────────────
295
+
296
+ def read_metadata(self, *, datasource_luid: str) -> dict[str, Any]:
297
+ url = f"{self._host}/api/v1/vizql-data-service/read-metadata"
298
+ body = {"datasource": {"datasourceLuid": datasource_luid}}
299
+ try:
300
+ resp = self._client.post(url, json=body, headers=self._auth_headers())
301
+ except httpx.TransportError as exc:
302
+ raise FeatureDisabledError(VDS_DISABLED_MESSAGE) from exc
303
+ if resp.status_code == 404:
304
+ raise FeatureDisabledError(VDS_DISABLED_MESSAGE)
305
+ resp.raise_for_status()
306
+ return resp.json()
307
+
308
+ def query_datasource(self, *, datasource_luid: str, query: dict, options: dict | None = None) -> dict[str, Any]:
309
+ url = f"{self._host}/api/v1/vizql-data-service/query-datasource"
310
+ body: dict[str, Any] = {
311
+ "datasource": {"datasourceLuid": datasource_luid},
312
+ "query": query,
313
+ }
314
+ if options:
315
+ body["options"] = options
316
+ try:
317
+ resp = self._client.post(url, json=body, headers=self._auth_headers())
318
+ except httpx.TransportError as exc:
319
+ raise FeatureDisabledError(VDS_DISABLED_MESSAGE) from exc
320
+ if resp.status_code == 404:
321
+ raise FeatureDisabledError(VDS_DISABLED_MESSAGE)
322
+ if resp.status_code >= 400:
323
+ try:
324
+ data = resp.json()
325
+ except Exception:
326
+ resp.raise_for_status()
327
+ return {} # unreachable
328
+ error_message = data.get("message", "Unknown Tableau error")
329
+ error_code = data.get("errorCode") or data.get("tab-error-code")
330
+ raise handle_vds_error(error_message, resp.status_code, error_code)
331
+ return resp.json()
332
+
333
+ # ── Metadata API (GraphQL) ──────────────────────────────────────
334
+
335
+ def graphql(self, query: str) -> dict[str, Any]:
336
+ url = f"{self._host}/api/metadata/graphql"
337
+ resp = self._client.post(url, json={"query": query}, headers=self._auth_headers())
338
+ resp.raise_for_status()
339
+ return resp.json()
340
+
341
+ def close(self) -> None:
342
+ self._client.close()
343
+
344
+
345
+ def _parse_pagination(p: dict[str, Any]) -> dict[str, int]:
346
+ return {
347
+ "pageNumber": int(p["pageNumber"]),
348
+ "pageSize": int(p["pageSize"]),
349
+ "totalAvailable": int(p["totalAvailable"]),
350
+ }
351
+
352
+
353
+ # ── Response shaping (equivalent to Zod schemas in TS version) ──────────
354
+
355
+
356
+ def _shape_project(raw: Any) -> dict[str, str]:
357
+ if not isinstance(raw, dict):
358
+ return {"name": "", "id": ""}
359
+ return {"name": raw.get("name", ""), "id": raw.get("id", "")}
360
+
361
+
362
+ def _shape_tags(raw: Any) -> dict[str, Any]:
363
+ if not isinstance(raw, dict):
364
+ return {}
365
+ tag = raw.get("tag")
366
+ if isinstance(tag, list):
367
+ return {"tag": [{"label": t["label"]} for t in tag if isinstance(t, dict) and "label" in t]}
368
+ return {}
369
+
370
+
371
+ def _shape_datasource(raw: dict[str, Any]) -> dict[str, Any]:
372
+ """Match TS dataSourceSchema: {id, name, description?, project, tags}"""
373
+ result: dict[str, Any] = {
374
+ "id": raw.get("id", ""),
375
+ "name": raw.get("name", ""),
376
+ }
377
+ if "description" in raw:
378
+ result["description"] = raw["description"]
379
+ result["project"] = _shape_project(raw.get("project"))
380
+ result["tags"] = _shape_tags(raw.get("tags"))
381
+ return result
382
+
383
+
384
+ def _shape_view(raw: dict[str, Any]) -> dict[str, Any]:
385
+ """Match TS viewSchema: {id, name, createdAt, updatedAt, workbook?, owner?, project?, tags, usage?}"""
386
+ result: dict[str, Any] = {
387
+ "id": raw.get("id", ""),
388
+ "name": raw.get("name", ""),
389
+ "createdAt": raw.get("createdAt", ""),
390
+ "updatedAt": raw.get("updatedAt", ""),
391
+ }
392
+ if "workbook" in raw and isinstance(raw["workbook"], dict):
393
+ result["workbook"] = {"id": raw["workbook"].get("id", "")}
394
+ if "owner" in raw and isinstance(raw["owner"], dict):
395
+ result["owner"] = {"id": raw["owner"].get("id", "")}
396
+ if "project" in raw and isinstance(raw["project"], dict):
397
+ result["project"] = {"id": raw["project"].get("id", "")}
398
+ result["tags"] = _shape_tags(raw.get("tags"))
399
+ if "usage" in raw and isinstance(raw["usage"], dict):
400
+ result["usage"] = {"totalViewCount": int(raw["usage"].get("totalViewCount", 0))}
401
+ return result
402
+
403
+
404
+ def _shape_workbook(raw: dict[str, Any]) -> dict[str, Any]:
405
+ """Match TS workbookSchema: id, name, description?, webpageUrl?, contentUrl, project?, showTabs, etc."""
406
+ result: dict[str, Any] = {
407
+ "id": raw.get("id", ""),
408
+ "name": raw.get("name", ""),
409
+ }
410
+ if "description" in raw:
411
+ result["description"] = raw["description"]
412
+ if "webpageUrl" in raw:
413
+ result["webpageUrl"] = raw["webpageUrl"]
414
+ result["contentUrl"] = raw.get("contentUrl", "")
415
+ if "project" in raw:
416
+ result["project"] = _shape_project(raw.get("project"))
417
+ # z.coerce.boolean() — convert string "true"/"false" to bool
418
+ show_tabs = raw.get("showTabs", False)
419
+ if isinstance(show_tabs, str):
420
+ result["showTabs"] = show_tabs.lower() == "true"
421
+ else:
422
+ result["showTabs"] = bool(show_tabs)
423
+ if "defaultViewId" in raw:
424
+ result["defaultViewId"] = raw["defaultViewId"]
425
+ result["tags"] = _shape_tags(raw.get("tags"))
426
+ if "views" in raw and isinstance(raw["views"], dict):
427
+ views_list = raw["views"].get("view", [])
428
+ result["views"] = {"view": [_shape_view(v) for v in views_list] if isinstance(views_list, list) else []}
429
+ return result
File without changes
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import TypeVar
5
+
6
+ import httpx
7
+
8
+ from ..api.client import RestApi
9
+ from ..config.types import Config
10
+ from ..errors.cli_error import AuthenticationError
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ def with_auth(config: Config, fn: Callable[[RestApi], T]) -> T:
16
+ api = RestApi(config.server)
17
+ try:
18
+ api.sign_in(
19
+ site_name=config.site_name,
20
+ pat_name=config.pat_name,
21
+ pat_value=config.pat_value,
22
+ )
23
+ except httpx.HTTPStatusError as e:
24
+ status = e.response.status_code
25
+ if status in (401, 403):
26
+ raise AuthenticationError(f"Sign-in failed (HTTP {status}). The PAT may be expired or invalid.") from e
27
+ raise
28
+ except httpx.TransportError as e:
29
+ # Covers ConnectError, ConnectTimeout, ReadTimeout, PoolTimeout, DNS failures, etc.
30
+ # Equivalent to TS version's `if (!error.response)` check.
31
+ raise AuthenticationError(
32
+ f"Cannot connect to Tableau Server at {config.server}: {e}. Check the server URL."
33
+ ) from e
34
+ try:
35
+ return fn(api)
36
+ finally:
37
+ api.sign_out()
38
+ api.close()
tableau_cli/cli.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any
6
+
7
+ import click
8
+ import httpx
9
+
10
+ from .errors.cli_error import CliError
11
+
12
+
13
+ def to_error_output(err: Exception) -> dict[str, Any]:
14
+ # Our own structured errors
15
+ if isinstance(err, CliError):
16
+ return err.to_output()
17
+
18
+ # httpx HTTP errors (from Tableau REST API)
19
+ if isinstance(err, httpx.HTTPStatusError):
20
+ status = err.response.status_code
21
+ try:
22
+ data = err.response.json()
23
+ except Exception:
24
+ data = None
25
+
26
+ # Tableau REST API error format: { error: { summary, detail, code } }
27
+ if isinstance(data, dict) and data.get("error"):
28
+ error_info = data["error"]
29
+ summary = error_info.get("summary", "")
30
+ detail = error_info.get("detail", "")
31
+ code = error_info.get("code")
32
+ return {
33
+ "isError": True,
34
+ "errorType": "tableau-api-error",
35
+ "message": f"{summary}: {detail}" if detail else (summary or str(err)),
36
+ **({"details": f"Tableau error code: {code}"} if code else {}),
37
+ }
38
+
39
+ return {
40
+ "isError": True,
41
+ "errorType": "http-error",
42
+ "message": f"HTTP {status}: {err}",
43
+ **({"details": err.response.text} if err.response.text else {}),
44
+ }
45
+
46
+ # httpx transport errors (network unreachable, timeout, DNS failure, etc.)
47
+ # Equivalent to TS: isAxiosError(err) && !err.response
48
+ if isinstance(err, httpx.TransportError):
49
+ return {
50
+ "isError": True,
51
+ "errorType": "http-error",
52
+ "message": f"Network error: {err}",
53
+ }
54
+
55
+ # Config validation errors
56
+ if isinstance(err, RuntimeError) and str(err).startswith("Missing required config:"):
57
+ return {
58
+ "isError": True,
59
+ "errorType": "config-error",
60
+ "message": str(err),
61
+ "hint": "Run `tableau-cli config set` to configure, or set environment variables.",
62
+ }
63
+
64
+ # JSON parse errors (bad --query input)
65
+ if isinstance(err, json.JSONDecodeError):
66
+ return {
67
+ "isError": True,
68
+ "errorType": "invalid-input",
69
+ "message": str(err),
70
+ "hint": "Check that your --query argument is valid JSON.",
71
+ }
72
+
73
+ # Fallback
74
+ return {
75
+ "isError": True,
76
+ "errorType": "unknown-error",
77
+ "message": str(err),
78
+ }
79
+
80
+
81
+ class TableauCli(click.Group):
82
+ def invoke(self, ctx: click.Context) -> Any:
83
+ try:
84
+ return super().invoke(ctx)
85
+ except click.exceptions.Exit:
86
+ raise
87
+ except click.exceptions.Abort:
88
+ raise
89
+ except click.UsageError:
90
+ raise
91
+ except Exception as exc:
92
+ output = to_error_output(exc)
93
+ sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
94
+ ctx.exit(1)
95
+
96
+
97
+ @click.group(cls=TableauCli)
98
+ @click.version_option("0.1.0")
99
+ def cli():
100
+ """CLI tool for interacting with Tableau Server/Cloud."""
101
+
102
+
103
+ def main() -> None:
104
+ from .commands.config_cmd import config_group
105
+ from .commands.convert_cmd import convert_command
106
+ from .commands.datasources_cmd import datasources_group
107
+ from .commands.search_cmd import search_command
108
+ from .commands.views_cmd import views_group
109
+ from .commands.workbooks_cmd import workbooks_group
110
+
111
+ cli.add_command(config_group)
112
+ cli.add_command(convert_command)
113
+ cli.add_command(datasources_group)
114
+ cli.add_command(datasources_group, name="ds") # alias
115
+ cli.add_command(views_group)
116
+ cli.add_command(workbooks_group)
117
+ cli.add_command(workbooks_group, name="wb") # alias
118
+ cli.add_command(search_command)
119
+
120
+ cli()
File without changes
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ import click
7
+
8
+ from ..config.store import load_file_config, save_file_config
9
+
10
+
11
+ @click.group("config")
12
+ def config_group():
13
+ """Manage CLI configuration."""
14
+
15
+
16
+ @config_group.command("set")
17
+ @click.option("--server", default=None, help="Tableau server URL")
18
+ @click.option("--site-name", default=None, help="Tableau site name")
19
+ @click.option("--pat-name", default=None, help="Personal Access Token name")
20
+ @click.option("--pat-value", default=None, help="Personal Access Token value")
21
+ def config_set(server, site_name, pat_name, pat_value):
22
+ """Set configuration values."""
23
+ existing = load_file_config()
24
+ if server is not None:
25
+ existing.server = server
26
+ if site_name is not None:
27
+ existing.site_name = site_name
28
+ if pat_name is not None:
29
+ existing.pat_name = pat_name
30
+ if pat_value is not None:
31
+ existing.pat_value = pat_value
32
+ save_file_config(existing)
33
+ sys.stderr.write("Configuration saved.\n")
34
+
35
+
36
+ @config_group.command("show")
37
+ def config_show():
38
+ """Show current configuration."""
39
+ config = load_file_config()
40
+ display = {}
41
+ if config.server is not None:
42
+ display["server"] = config.server
43
+ if config.site_name is not None:
44
+ display["siteName"] = config.site_name
45
+ if config.pat_name is not None:
46
+ display["patName"] = config.pat_name
47
+ display["patValue"] = "****" if config.pat_value else None
48
+ print(json.dumps(display, indent=2))