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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fivetran-cli
3
- Version: 0.1.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 `--format table`, `--format yaml`, or `--output path` when needed.
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 table formats beyond the generic table renderer
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 `--format table`, `--format yaml`, or `--output path` when needed.
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 table formats beyond the generic table renderer
105
+ - High-quality resource-specific pretty views beyond the generic renderer
101
106
 
102
107
  ## Build And Publish
103
108
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fivetran-cli"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Command line wrapper for the Fivetran REST API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Fivetran CLI package."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
@@ -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=args.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.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 `--format table`, `--format yaml`, or `--output path` when needed.
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 table formats beyond the generic table renderer
121
+ - High-quality resource-specific pretty views beyond the generic renderer
117
122
 
118
123
  ## Build And Publish
119
124
 
@@ -13,4 +13,5 @@ src/fivetran_cli.egg-info/dependency_links.txt
13
13
  src/fivetran_cli.egg-info/entry_points.txt
14
14
  src/fivetran_cli.egg-info/top_level.txt
15
15
  tests/test_cli.py
16
- tests/test_client.py
16
+ tests/test_client.py
17
+ tests/test_output.py
@@ -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