fivetran-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.
- {fivetran_cli-0.1.1/src/fivetran_cli.egg-info → fivetran_cli-0.1.3}/PKG-INFO +8 -3
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/README.md +7 -2
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/pyproject.toml +1 -1
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/src/fivetran_cli/__init__.py +1 -1
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/src/fivetran_cli/cli.py +10 -9
- fivetran_cli-0.1.3/src/fivetran_cli/output.py +201 -0
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3/src/fivetran_cli.egg-info}/PKG-INFO +8 -3
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/src/fivetran_cli.egg-info/SOURCES.txt +2 -1
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/tests/test_cli.py +26 -2
- fivetran_cli-0.1.3/tests/test_output.py +61 -0
- fivetran_cli-0.1.1/src/fivetran_cli/output.py +0 -121
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/LICENSE +0 -0
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/MANIFEST.in +0 -0
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/setup.cfg +0 -0
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/src/fivetran_cli/__main__.py +0 -0
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/src/fivetran_cli/client.py +0 -0
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/src/fivetran_cli.egg-info/dependency_links.txt +0 -0
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/src/fivetran_cli.egg-info/entry_points.txt +0 -0
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/src/fivetran_cli.egg-info/top_level.txt +0 -0
- {fivetran_cli-0.1.1 → fivetran_cli-0.1.3}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fivetran-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Command line wrapper for the Fivetran REST API
|
|
5
5
|
Author: Fivetran
|
|
6
6
|
License-Expression: MIT
|
|
@@ -40,7 +40,12 @@ fivetran connector-metadata get google_ads
|
|
|
40
40
|
fivetran public-connector list
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
Output defaults to JSON. Use `--
|
|
43
|
+
Output defaults to compact raw JSON for scripting. Use `--pretty` for readable output. Pretty output renders list responses as compact item blocks and detail responses as key/value sections.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
fivetran connection list --pretty
|
|
47
|
+
fivetran connection get connection_id --pretty
|
|
48
|
+
```
|
|
44
49
|
|
|
45
50
|
Request body commands accept either raw JSON or a JSON file:
|
|
46
51
|
|
|
@@ -113,7 +118,7 @@ These API areas are still intentionally not implemented:
|
|
|
113
118
|
- Group destination listing, pending confirmation of a supported REST endpoint
|
|
114
119
|
- Interactive connector setup workflows
|
|
115
120
|
- File upload/download handling for Connector SDK packages
|
|
116
|
-
- High-quality resource-specific
|
|
121
|
+
- High-quality resource-specific pretty views beyond the generic renderer
|
|
117
122
|
|
|
118
123
|
## Build And Publish
|
|
119
124
|
|
|
@@ -24,7 +24,12 @@ fivetran connector-metadata get google_ads
|
|
|
24
24
|
fivetran public-connector list
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Output defaults to JSON. Use `--
|
|
27
|
+
Output defaults to compact raw JSON for scripting. Use `--pretty` for readable output. Pretty output renders list responses as compact item blocks and detail responses as key/value sections.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
fivetran connection list --pretty
|
|
31
|
+
fivetran connection get connection_id --pretty
|
|
32
|
+
```
|
|
28
33
|
|
|
29
34
|
Request body commands accept either raw JSON or a JSON file:
|
|
30
35
|
|
|
@@ -97,7 +102,7 @@ These API areas are still intentionally not implemented:
|
|
|
97
102
|
- Group destination listing, pending confirmation of a supported REST endpoint
|
|
98
103
|
- Interactive connector setup workflows
|
|
99
104
|
- File upload/download handling for Connector SDK packages
|
|
100
|
-
- High-quality resource-specific
|
|
105
|
+
- High-quality resource-specific pretty views beyond the generic renderer
|
|
101
106
|
|
|
102
107
|
## Build And Publish
|
|
103
108
|
|
|
@@ -20,10 +20,9 @@ GLOBAL_OPTIONS_WITH_VALUES = {
|
|
|
20
20
|
"--api-key",
|
|
21
21
|
"--profile",
|
|
22
22
|
"--base-url",
|
|
23
|
-
"--format",
|
|
24
23
|
"--output",
|
|
25
24
|
}
|
|
26
|
-
GLOBAL_FLAG_OPTIONS = {"--quiet", "--verbose"}
|
|
25
|
+
GLOBAL_FLAG_OPTIONS = {"--pretty", "--quiet", "--verbose"}
|
|
27
26
|
OPERATION_EPILOG = (
|
|
28
27
|
"Credentials default to FIVETRAN_API_KEY, which should contain the Base64-encoded API key. "
|
|
29
28
|
"FIVETRAN_BASE_URL overrides the default API base URL. "
|
|
@@ -75,12 +74,13 @@ def run(
|
|
|
75
74
|
return 0
|
|
76
75
|
|
|
77
76
|
try:
|
|
77
|
+
output_format = _output_format(args)
|
|
78
78
|
config = resolve_config(args)
|
|
79
79
|
client = client_factory(config)
|
|
80
80
|
payload = args.handler(args, client)
|
|
81
81
|
emit_output(
|
|
82
82
|
payload,
|
|
83
|
-
output_format=
|
|
83
|
+
output_format=output_format,
|
|
84
84
|
output_path=args.output,
|
|
85
85
|
quiet=args.quiet,
|
|
86
86
|
stdout=stdout,
|
|
@@ -129,17 +129,18 @@ def _add_global_options(parser: argparse.ArgumentParser) -> None:
|
|
|
129
129
|
"--base-url",
|
|
130
130
|
help="Fivetran API base URL. Defaults to FIVETRAN_BASE_URL or https://api.fivetran.com.",
|
|
131
131
|
)
|
|
132
|
-
parser.add_argument(
|
|
133
|
-
"--format",
|
|
134
|
-
choices=("json", "table", "yaml"),
|
|
135
|
-
default="json",
|
|
136
|
-
help="Output format.",
|
|
137
|
-
)
|
|
132
|
+
parser.add_argument("--pretty", action="store_true", help="Render output as a readable summary.")
|
|
138
133
|
parser.add_argument("--output", help="Write output to a path instead of stdout.")
|
|
139
134
|
parser.add_argument("--quiet", action="store_true", help="Suppress response output.")
|
|
140
135
|
parser.add_argument("--verbose", action="store_true", help="Enable verbose diagnostics.")
|
|
141
136
|
|
|
142
137
|
|
|
138
|
+
def _output_format(args: argparse.Namespace) -> str:
|
|
139
|
+
if args.pretty:
|
|
140
|
+
return "pretty"
|
|
141
|
+
return "json"
|
|
142
|
+
|
|
143
|
+
|
|
143
144
|
def _add_account(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
144
145
|
account = resources.add_parser(
|
|
145
146
|
"account",
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, TextIO
|
|
6
|
+
|
|
7
|
+
from .client import CliError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def emit_output(
|
|
11
|
+
payload: Any,
|
|
12
|
+
*,
|
|
13
|
+
output_format: str,
|
|
14
|
+
output_path: str | None,
|
|
15
|
+
quiet: bool,
|
|
16
|
+
stdout: TextIO,
|
|
17
|
+
) -> None:
|
|
18
|
+
if quiet or payload is None:
|
|
19
|
+
return
|
|
20
|
+
rendered = render(payload, output_format)
|
|
21
|
+
if output_path:
|
|
22
|
+
try:
|
|
23
|
+
Path(output_path).write_text(rendered + "\n", encoding="utf-8")
|
|
24
|
+
except OSError as exc:
|
|
25
|
+
raise CliError(f"Could not write output file {output_path}: {exc}") from exc
|
|
26
|
+
return
|
|
27
|
+
stdout.write(rendered + "\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render(payload: Any, output_format: str) -> str:
|
|
31
|
+
if output_format == "json":
|
|
32
|
+
return json.dumps(payload, separators=(",", ":"))
|
|
33
|
+
if output_format == "pretty":
|
|
34
|
+
return _to_pretty(payload)
|
|
35
|
+
raise ValueError(f"Unsupported output format: {output_format}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _to_pretty(payload: Any) -> str:
|
|
39
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
40
|
+
lines: list[str] = []
|
|
41
|
+
code = payload.get("code")
|
|
42
|
+
message = payload.get("message")
|
|
43
|
+
if code and code != "Success":
|
|
44
|
+
lines.append(f"code: {code}")
|
|
45
|
+
if message:
|
|
46
|
+
lines.append(f"message: {message}")
|
|
47
|
+
|
|
48
|
+
rendered = _pretty_value(payload["data"])
|
|
49
|
+
if rendered:
|
|
50
|
+
if lines:
|
|
51
|
+
lines.append("")
|
|
52
|
+
lines.append(rendered)
|
|
53
|
+
return "\n".join(lines)
|
|
54
|
+
return _pretty_value(payload)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pretty_value(value: Any) -> str:
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
if isinstance(value.get("items"), list):
|
|
60
|
+
return _pretty_items(value["items"], next_cursor=value.get("next_cursor"))
|
|
61
|
+
return _pretty_object(value)
|
|
62
|
+
if isinstance(value, list):
|
|
63
|
+
return _pretty_items(value)
|
|
64
|
+
return _cell(value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _pretty_items(items: list[Any], *, next_cursor: Any = None) -> str:
|
|
68
|
+
count_label = f"{len(items)} item" if len(items) == 1 else f"{len(items)} items"
|
|
69
|
+
if not items:
|
|
70
|
+
lines = [count_label]
|
|
71
|
+
if next_cursor:
|
|
72
|
+
lines.append(f"next_cursor: {next_cursor}")
|
|
73
|
+
return "\n".join(lines)
|
|
74
|
+
|
|
75
|
+
if all(isinstance(item, dict) for item in items):
|
|
76
|
+
rendered_items = _pretty_item_blocks(items)
|
|
77
|
+
else:
|
|
78
|
+
rendered_items = "\n".join(f"- {_cell(item)}" for item in items)
|
|
79
|
+
|
|
80
|
+
lines = [count_label, "", rendered_items]
|
|
81
|
+
if next_cursor:
|
|
82
|
+
lines.extend(["", f"next_cursor: {next_cursor}"])
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pretty_object(value: dict[str, Any], *, indent: int = 0) -> str:
|
|
87
|
+
prefix = " " * indent
|
|
88
|
+
lines: list[str] = []
|
|
89
|
+
nested: list[tuple[str, Any]] = []
|
|
90
|
+
|
|
91
|
+
for key, item in value.items():
|
|
92
|
+
if _is_scalar(item):
|
|
93
|
+
lines.append(f"{prefix}{key}: {_pretty_cell(key, item)}")
|
|
94
|
+
else:
|
|
95
|
+
nested.append((key, item))
|
|
96
|
+
|
|
97
|
+
for key, item in nested:
|
|
98
|
+
if lines:
|
|
99
|
+
lines.append("")
|
|
100
|
+
lines.append(f"{prefix}{key}:")
|
|
101
|
+
rendered = _pretty_value(item)
|
|
102
|
+
if rendered:
|
|
103
|
+
lines.append(_indent(rendered, indent + 2))
|
|
104
|
+
|
|
105
|
+
return "\n".join(lines)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _pretty_item_blocks(rows: list[dict[str, Any]]) -> str:
|
|
109
|
+
blocks: list[str] = []
|
|
110
|
+
for index, row in enumerate(rows, start=1):
|
|
111
|
+
flattened = _flatten_scalars(row)
|
|
112
|
+
label = _item_label(flattened, index)
|
|
113
|
+
lines = [label]
|
|
114
|
+
for key in _pretty_keys(flattened):
|
|
115
|
+
if key == label.key:
|
|
116
|
+
continue
|
|
117
|
+
lines.append(f" {key}: {_cell(flattened[key])}")
|
|
118
|
+
blocks.append("\n".join(lines))
|
|
119
|
+
return "\n\n".join(blocks)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _flatten_scalars(row: dict[str, Any], *, prefix: str = "", depth: int = 0) -> dict[str, Any]:
|
|
123
|
+
flattened: dict[str, Any] = {}
|
|
124
|
+
for key, value in row.items():
|
|
125
|
+
column = f"{prefix}.{key}" if prefix else str(key)
|
|
126
|
+
if _is_scalar(value):
|
|
127
|
+
flattened[column] = _redact_if_sensitive(column, value)
|
|
128
|
+
elif isinstance(value, dict) and depth < 1:
|
|
129
|
+
flattened.update(_flatten_scalars(value, prefix=column, depth=depth + 1))
|
|
130
|
+
return flattened
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _ItemLabel(str):
|
|
134
|
+
key: str | None
|
|
135
|
+
|
|
136
|
+
def __new__(cls, value: str, key: str | None = None) -> "_ItemLabel":
|
|
137
|
+
obj = str.__new__(cls, value)
|
|
138
|
+
obj.key = key
|
|
139
|
+
return obj
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _item_label(row: dict[str, Any], index: int) -> _ItemLabel:
|
|
143
|
+
for key in ("id", "name", "service"):
|
|
144
|
+
value = row.get(key)
|
|
145
|
+
if value:
|
|
146
|
+
return _ItemLabel(str(value), key)
|
|
147
|
+
return _ItemLabel(f"item {index}", None)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _pretty_keys(rows: dict[str, Any]) -> list[str]:
|
|
151
|
+
preferred = [
|
|
152
|
+
"id",
|
|
153
|
+
"name",
|
|
154
|
+
"service",
|
|
155
|
+
"schema",
|
|
156
|
+
"group_id",
|
|
157
|
+
"paused",
|
|
158
|
+
"status.sync_state",
|
|
159
|
+
"status.setup_state",
|
|
160
|
+
"sync_state",
|
|
161
|
+
"setup_state",
|
|
162
|
+
"created_at",
|
|
163
|
+
"updated_at",
|
|
164
|
+
]
|
|
165
|
+
available = set(rows)
|
|
166
|
+
ordered = [column for column in preferred if column in available]
|
|
167
|
+
ordered.extend(sorted(available - set(ordered)))
|
|
168
|
+
return ordered
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_scalar(value: Any) -> bool:
|
|
172
|
+
return value is None or isinstance(value, (str, int, float, bool))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _pretty_cell(key: str, value: Any) -> str:
|
|
176
|
+
return _cell(_redact_if_sensitive(key, value))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _redact_if_sensitive(key: str, value: Any) -> Any:
|
|
180
|
+
if value is not None and _is_sensitive_name(key):
|
|
181
|
+
return "[redacted]"
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _is_sensitive_name(key: str) -> bool:
|
|
186
|
+
normalized = key.lower().replace("-", "_")
|
|
187
|
+
sensitive_parts = ("password", "secret", "token", "api_key", "private_key")
|
|
188
|
+
return any(part in normalized for part in sensitive_parts)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _indent(text: str, spaces: int) -> str:
|
|
192
|
+
prefix = " " * spaces
|
|
193
|
+
return "\n".join(f"{prefix}{line}" if line else "" for line in text.splitlines())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _cell(value: Any) -> str:
|
|
197
|
+
if value is None:
|
|
198
|
+
return ""
|
|
199
|
+
if isinstance(value, (dict, list)):
|
|
200
|
+
return json.dumps(value, sort_keys=True)
|
|
201
|
+
return str(value)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fivetran-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Command line wrapper for the Fivetran REST API
|
|
5
5
|
Author: Fivetran
|
|
6
6
|
License-Expression: MIT
|
|
@@ -40,7 +40,12 @@ fivetran connector-metadata get google_ads
|
|
|
40
40
|
fivetran public-connector list
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
Output defaults to JSON. Use `--
|
|
43
|
+
Output defaults to compact raw JSON for scripting. Use `--pretty` for readable output. Pretty output renders list responses as compact item blocks and detail responses as key/value sections.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
fivetran connection list --pretty
|
|
47
|
+
fivetran connection get connection_id --pretty
|
|
48
|
+
```
|
|
44
49
|
|
|
45
50
|
Request body commands accept either raw JSON or a JSON file:
|
|
46
51
|
|
|
@@ -113,7 +118,7 @@ These API areas are still intentionally not implemented:
|
|
|
113
118
|
- Group destination listing, pending confirmation of a supported REST endpoint
|
|
114
119
|
- Interactive connector setup workflows
|
|
115
120
|
- File upload/download handling for Connector SDK packages
|
|
116
|
-
- High-quality resource-specific
|
|
121
|
+
- High-quality resource-specific pretty views beyond the generic renderer
|
|
117
122
|
|
|
118
123
|
## Build And Publish
|
|
119
124
|
|
|
@@ -101,6 +101,14 @@ class CliTests(unittest.TestCase):
|
|
|
101
101
|
{"group_id": "group", "service": "github", "config": {}},
|
|
102
102
|
)
|
|
103
103
|
|
|
104
|
+
def test_default_output_is_compact_json(self):
|
|
105
|
+
code, stdout, stderr, _ = self.run_cli(
|
|
106
|
+
["--api-key", "key", "account", "get"]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self.assertEqual(code, 0, stderr)
|
|
110
|
+
self.assertEqual(stdout, '{"data":{"ok":true}}\n')
|
|
111
|
+
|
|
104
112
|
def test_global_options_work_after_resource_command(self):
|
|
105
113
|
code, stdout, stderr, client = self.run_cli(
|
|
106
114
|
[
|
|
@@ -109,8 +117,6 @@ class CliTests(unittest.TestCase):
|
|
|
109
117
|
"connection_id",
|
|
110
118
|
"--api-key",
|
|
111
119
|
"key",
|
|
112
|
-
"--format",
|
|
113
|
-
"table",
|
|
114
120
|
]
|
|
115
121
|
)
|
|
116
122
|
|
|
@@ -118,6 +124,24 @@ class CliTests(unittest.TestCase):
|
|
|
118
124
|
self.assertEqual(client.calls[0][0:2], ("GET", "/v1/connections/connection_id"))
|
|
119
125
|
self.assertIn("ok", stdout)
|
|
120
126
|
|
|
127
|
+
def test_pretty_option_works_after_resource_command(self):
|
|
128
|
+
code, stdout, stderr, client = self.run_cli(
|
|
129
|
+
[
|
|
130
|
+
"connection",
|
|
131
|
+
"list",
|
|
132
|
+
"--all",
|
|
133
|
+
"--api-key",
|
|
134
|
+
"key",
|
|
135
|
+
"--pretty",
|
|
136
|
+
]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
self.assertEqual(code, 0, stderr)
|
|
140
|
+
self.assertEqual(client.calls[0][0:2], ("GET", "/v1/connections"))
|
|
141
|
+
self.assertIn("2 items", stdout)
|
|
142
|
+
self.assertIn("one", stdout)
|
|
143
|
+
self.assertIn("two", stdout)
|
|
144
|
+
|
|
121
145
|
def test_delete_requires_yes_in_noninteractive_mode(self):
|
|
122
146
|
code, _, stderr, client = self.run_cli(
|
|
123
147
|
[
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from fivetran_cli.output import render
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OutputTests(unittest.TestCase):
|
|
9
|
+
def test_pretty_render_flattens_list_items_to_blocks(self):
|
|
10
|
+
payload = {
|
|
11
|
+
"code": "Success",
|
|
12
|
+
"data": {
|
|
13
|
+
"items": [
|
|
14
|
+
{
|
|
15
|
+
"id": "conn_1",
|
|
16
|
+
"service": "postgres",
|
|
17
|
+
"status": {"sync_state": "scheduled", "setup_state": "connected"},
|
|
18
|
+
"config": {"password": "hidden"},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "conn_2",
|
|
22
|
+
"service": "github",
|
|
23
|
+
"status": {"sync_state": "paused", "setup_state": "connected"},
|
|
24
|
+
"config": {"password": "hidden"},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
"next_cursor": "next",
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
rendered = render(payload, "pretty")
|
|
32
|
+
|
|
33
|
+
self.assertIn("2 items", rendered)
|
|
34
|
+
self.assertIn("conn_1", rendered)
|
|
35
|
+
self.assertIn("conn_2", rendered)
|
|
36
|
+
self.assertIn("service: postgres", rendered)
|
|
37
|
+
self.assertIn("status.sync_state", rendered)
|
|
38
|
+
self.assertIn("scheduled", rendered)
|
|
39
|
+
self.assertIn("next_cursor: next", rendered)
|
|
40
|
+
self.assertNotIn("hidden", rendered)
|
|
41
|
+
self.assertNotIn("------", rendered)
|
|
42
|
+
|
|
43
|
+
def test_pretty_render_formats_detail_objects(self):
|
|
44
|
+
payload = {
|
|
45
|
+
"data": {
|
|
46
|
+
"id": "conn_1",
|
|
47
|
+
"service": "postgres",
|
|
48
|
+
"status": {"sync_state": "scheduled"},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
rendered = render(payload, "pretty")
|
|
53
|
+
|
|
54
|
+
self.assertIn("id: conn_1", rendered)
|
|
55
|
+
self.assertIn("service: postgres", rendered)
|
|
56
|
+
self.assertIn("status:", rendered)
|
|
57
|
+
self.assertIn("sync_state: scheduled", rendered)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
unittest.main()
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Any, TextIO
|
|
6
|
-
|
|
7
|
-
from .client import CliError
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def emit_output(
|
|
11
|
-
payload: Any,
|
|
12
|
-
*,
|
|
13
|
-
output_format: str,
|
|
14
|
-
output_path: str | None,
|
|
15
|
-
quiet: bool,
|
|
16
|
-
stdout: TextIO,
|
|
17
|
-
) -> None:
|
|
18
|
-
if quiet or payload is None:
|
|
19
|
-
return
|
|
20
|
-
rendered = render(payload, output_format)
|
|
21
|
-
if output_path:
|
|
22
|
-
try:
|
|
23
|
-
Path(output_path).write_text(rendered + "\n", encoding="utf-8")
|
|
24
|
-
except OSError as exc:
|
|
25
|
-
raise CliError(f"Could not write output file {output_path}: {exc}") from exc
|
|
26
|
-
return
|
|
27
|
-
stdout.write(rendered + "\n")
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def render(payload: Any, output_format: str) -> str:
|
|
31
|
-
if output_format == "json":
|
|
32
|
-
return json.dumps(payload, indent=2, sort_keys=True)
|
|
33
|
-
if output_format == "yaml":
|
|
34
|
-
return _to_yaml(payload)
|
|
35
|
-
if output_format == "table":
|
|
36
|
-
return _to_table(payload)
|
|
37
|
-
raise ValueError(f"Unsupported output format: {output_format}")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _to_yaml(value: Any, indent: int = 0) -> str:
|
|
41
|
-
prefix = " " * indent
|
|
42
|
-
if isinstance(value, dict):
|
|
43
|
-
lines: list[str] = []
|
|
44
|
-
for key, item in value.items():
|
|
45
|
-
if isinstance(item, (dict, list)):
|
|
46
|
-
lines.append(f"{prefix}{key}:")
|
|
47
|
-
lines.append(_to_yaml(item, indent + 2))
|
|
48
|
-
else:
|
|
49
|
-
lines.append(f"{prefix}{key}: {_yaml_scalar(item)}")
|
|
50
|
-
return "\n".join(lines)
|
|
51
|
-
if isinstance(value, list):
|
|
52
|
-
lines = []
|
|
53
|
-
for item in value:
|
|
54
|
-
if isinstance(item, (dict, list)):
|
|
55
|
-
lines.append(f"{prefix}-")
|
|
56
|
-
lines.append(_to_yaml(item, indent + 2))
|
|
57
|
-
else:
|
|
58
|
-
lines.append(f"{prefix}- {_yaml_scalar(item)}")
|
|
59
|
-
return "\n".join(lines)
|
|
60
|
-
return f"{prefix}{_yaml_scalar(value)}"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _yaml_scalar(value: Any) -> str:
|
|
64
|
-
if value is None:
|
|
65
|
-
return "null"
|
|
66
|
-
if isinstance(value, bool):
|
|
67
|
-
return "true" if value else "false"
|
|
68
|
-
if isinstance(value, (int, float)):
|
|
69
|
-
return str(value)
|
|
70
|
-
return json.dumps(str(value))
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _to_table(payload: Any) -> str:
|
|
74
|
-
rows = _table_rows(payload)
|
|
75
|
-
if not rows:
|
|
76
|
-
return ""
|
|
77
|
-
if not all(isinstance(row, dict) for row in rows):
|
|
78
|
-
return json.dumps(payload, indent=2, sort_keys=True)
|
|
79
|
-
|
|
80
|
-
columns = _columns(rows)
|
|
81
|
-
widths = {
|
|
82
|
-
column: max(len(column), *(len(_cell(row.get(column))) for row in rows))
|
|
83
|
-
for column in columns
|
|
84
|
-
}
|
|
85
|
-
header = " ".join(column.ljust(widths[column]) for column in columns)
|
|
86
|
-
separator = " ".join("-" * widths[column] for column in columns)
|
|
87
|
-
body = [
|
|
88
|
-
" ".join(_cell(row.get(column)).ljust(widths[column]) for column in columns)
|
|
89
|
-
for row in rows
|
|
90
|
-
]
|
|
91
|
-
return "\n".join([header, separator, *body])
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _table_rows(payload: Any) -> list[Any]:
|
|
95
|
-
if isinstance(payload, dict):
|
|
96
|
-
data = payload.get("data")
|
|
97
|
-
if isinstance(data, dict) and isinstance(data.get("items"), list):
|
|
98
|
-
return data["items"]
|
|
99
|
-
if isinstance(data, list):
|
|
100
|
-
return data
|
|
101
|
-
if isinstance(data, dict):
|
|
102
|
-
return [data]
|
|
103
|
-
if isinstance(payload, list):
|
|
104
|
-
return payload
|
|
105
|
-
return []
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _columns(rows: list[dict[str, Any]]) -> list[str]:
|
|
109
|
-
preferred = ["id", "name", "service", "schema", "group_id", "created_at"]
|
|
110
|
-
available = {key for row in rows for key in row}
|
|
111
|
-
ordered = [column for column in preferred if column in available]
|
|
112
|
-
ordered.extend(sorted(available - set(ordered)))
|
|
113
|
-
return ordered
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _cell(value: Any) -> str:
|
|
117
|
-
if value is None:
|
|
118
|
-
return ""
|
|
119
|
-
if isinstance(value, (dict, list)):
|
|
120
|
-
return json.dumps(value, sort_keys=True)
|
|
121
|
-
return str(value)
|
|
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
|