fivetran-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.
fivetran_cli/cli.py ADDED
@@ -0,0 +1,1472 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import tomllib
8
+ import urllib.parse
9
+ from dataclasses import dataclass
10
+ from difflib import get_close_matches
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Sequence, TextIO
13
+
14
+ from .client import DEFAULT_BASE_URL, CliError, ClientConfig, FivetranClient
15
+ from .output import emit_output
16
+
17
+
18
+ Handler = Callable[[argparse.Namespace, FivetranClient], Any]
19
+ GLOBAL_OPTIONS_WITH_VALUES = {
20
+ "--api-key",
21
+ "--api-secret",
22
+ "--profile",
23
+ "--base-url",
24
+ "--format",
25
+ "--output",
26
+ }
27
+ GLOBAL_FLAG_OPTIONS = {"--quiet", "--verbose"}
28
+ OPERATION_EPILOG = (
29
+ "Credentials default to FIVETRAN_API_KEY and FIVETRAN_API_SECRET. "
30
+ "FIVETRAN_BASE_URL overrides the default API base URL. "
31
+ "Global options may be placed before or after the resource command."
32
+ )
33
+ PUBLIC_OPERATION_EPILOG = (
34
+ "This command does not require Fivetran API credentials. "
35
+ "FIVETRAN_BASE_URL overrides the default API base URL. "
36
+ "Global options may be placed before or after the resource command."
37
+ )
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Endpoint:
42
+ method: str
43
+ path_template: str
44
+ id_args: tuple[str, ...] = ()
45
+ query_args: tuple[str, ...] = ()
46
+ body_required: bool = False
47
+ body_optional: bool = False
48
+ destructive: bool = False
49
+ paginated: bool = False
50
+ auth_required: bool = True
51
+
52
+
53
+ class SuggestingParser(argparse.ArgumentParser):
54
+ def error(self, message: str) -> None:
55
+ suggestion = _suggestion(message, self)
56
+ if suggestion:
57
+ message = f"{message}\n\nDid you mean '{suggestion}'?"
58
+ super().error(message)
59
+
60
+
61
+ def main(argv: Sequence[str] | None = None) -> int:
62
+ return run(argv if argv is not None else sys.argv[1:])
63
+
64
+
65
+ def run(
66
+ argv: Sequence[str],
67
+ *,
68
+ client_factory: Callable[[ClientConfig], FivetranClient] = FivetranClient,
69
+ stdout: TextIO = sys.stdout,
70
+ stderr: TextIO = sys.stderr,
71
+ ) -> int:
72
+ parser = build_parser()
73
+ args = parser.parse_args(_normalize_global_options(list(argv)))
74
+ if not hasattr(args, "handler"):
75
+ parser.print_help(stdout)
76
+ return 0
77
+
78
+ try:
79
+ config = resolve_config(args)
80
+ client = client_factory(config)
81
+ payload = args.handler(args, client)
82
+ emit_output(
83
+ payload,
84
+ output_format=args.format,
85
+ output_path=args.output,
86
+ quiet=args.quiet,
87
+ stdout=stdout,
88
+ )
89
+ return 0
90
+ except CliError as exc:
91
+ stderr.write(f"fivetran: error: {exc}\n")
92
+ return 1
93
+
94
+
95
+ def build_parser() -> argparse.ArgumentParser:
96
+ parser = SuggestingParser(
97
+ prog="fivetran",
98
+ description="Command line wrapper for the Fivetran REST API.",
99
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
100
+ allow_abbrev=False,
101
+ )
102
+ _add_global_options(parser)
103
+
104
+ resources = parser.add_subparsers(dest="resource", metavar="<resource>")
105
+ _add_account(resources)
106
+ _add_connection(resources)
107
+ _add_destination(resources)
108
+ _add_group(resources)
109
+ _add_user(resources)
110
+ _add_team(resources)
111
+ _add_role(resources)
112
+ _add_system_key(resources)
113
+ _add_connection_schema(resources)
114
+ _add_connector_metadata(resources)
115
+ _add_public_connector(resources)
116
+ _add_hybrid_deployment_agent(resources)
117
+ _add_proxy_agent(resources)
118
+ _add_private_link(resources)
119
+ _add_transformation(resources)
120
+ return parser
121
+
122
+
123
+ def _add_global_options(parser: argparse.ArgumentParser) -> None:
124
+ parser.add_argument("--api-key", help="Fivetran API key. Defaults to FIVETRAN_API_KEY.")
125
+ parser.add_argument(
126
+ "--api-secret",
127
+ help="Fivetran API secret. Defaults to FIVETRAN_API_SECRET.",
128
+ )
129
+ parser.add_argument("--profile", help="Named credential profile.")
130
+ parser.add_argument(
131
+ "--base-url",
132
+ help="Fivetran API base URL. Defaults to FIVETRAN_BASE_URL or https://api.fivetran.com.",
133
+ )
134
+ parser.add_argument(
135
+ "--format",
136
+ choices=("json", "table", "yaml"),
137
+ default="json",
138
+ help="Output format.",
139
+ )
140
+ parser.add_argument("--output", help="Write output to a path instead of stdout.")
141
+ parser.add_argument("--quiet", action="store_true", help="Suppress response output.")
142
+ parser.add_argument("--verbose", action="store_true", help="Enable verbose diagnostics.")
143
+
144
+
145
+ def _add_account(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
146
+ account = resources.add_parser(
147
+ "account",
148
+ help="Manage account-level information.",
149
+ description="Manage account-level information.",
150
+ )
151
+ operations = account.add_subparsers(dest="operation", required=True)
152
+ _add_endpoint_command(
153
+ operations,
154
+ "get",
155
+ "Get account information.",
156
+ Endpoint("GET", "/v1/account/info"),
157
+ epilog=(
158
+ "Example: fivetran account get\n\n"
159
+ "Note: this uses the current Fivetran account info endpoint, /v1/account/info."
160
+ ),
161
+ )
162
+
163
+
164
+ def _add_connection(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
165
+ connection = resources.add_parser(
166
+ "connection",
167
+ help="Create, configure, and manage connections.",
168
+ description="Create, configure, and manage connections.",
169
+ )
170
+ operations = connection.add_subparsers(dest="operation", required=True)
171
+ _add_endpoint_command(
172
+ operations,
173
+ "list",
174
+ "List connections.",
175
+ Endpoint(
176
+ "GET",
177
+ "/v1/connections",
178
+ query_args=("group_id", "schema", "limit", "cursor"),
179
+ paginated=True,
180
+ ),
181
+ query_options=(("group-id", "group_id"), ("schema", "schema")),
182
+ pagination=True,
183
+ )
184
+ _add_endpoint_command(
185
+ operations,
186
+ "get",
187
+ "Get a connection.",
188
+ Endpoint("GET", "/v1/connections/{connection_id}", id_args=("connection_id",)),
189
+ arguments=(("connection_id", "Connection ID."),),
190
+ )
191
+ _add_endpoint_command(
192
+ operations,
193
+ "create",
194
+ "Create a connection.",
195
+ Endpoint("POST", "/v1/connections", body_required=True),
196
+ body=True,
197
+ body_required=True,
198
+ )
199
+ _add_endpoint_command(
200
+ operations,
201
+ "update",
202
+ "Update a connection.",
203
+ Endpoint(
204
+ "PATCH",
205
+ "/v1/connections/{connection_id}",
206
+ id_args=("connection_id",),
207
+ body_required=True,
208
+ ),
209
+ arguments=(("connection_id", "Connection ID."),),
210
+ body=True,
211
+ body_required=True,
212
+ )
213
+ _add_endpoint_command(
214
+ operations,
215
+ "delete",
216
+ "Delete a connection.",
217
+ Endpoint(
218
+ "DELETE",
219
+ "/v1/connections/{connection_id}",
220
+ id_args=("connection_id",),
221
+ destructive=True,
222
+ ),
223
+ arguments=(("connection_id", "Connection ID."),),
224
+ destructive=True,
225
+ )
226
+ _add_endpoint_command(
227
+ operations,
228
+ "sync",
229
+ "Trigger a connection sync.",
230
+ Endpoint(
231
+ "POST",
232
+ "/v1/connections/{connection_id}/sync",
233
+ id_args=("connection_id",),
234
+ body_optional=True,
235
+ ),
236
+ arguments=(("connection_id", "Connection ID."),),
237
+ body=True,
238
+ )
239
+ _add_endpoint_command(
240
+ operations,
241
+ "resync",
242
+ "Trigger a historical connection re-sync.",
243
+ Endpoint(
244
+ "POST",
245
+ "/v1/connections/{connection_id}/resync",
246
+ id_args=("connection_id",),
247
+ body_optional=True,
248
+ ),
249
+ arguments=(("connection_id", "Connection ID."),),
250
+ body=True,
251
+ )
252
+ _add_endpoint_command(
253
+ operations,
254
+ "test",
255
+ "Run connection setup tests.",
256
+ Endpoint("POST", "/v1/connections/{connection_id}/test", id_args=("connection_id",)),
257
+ arguments=(("connection_id", "Connection ID."),),
258
+ )
259
+
260
+
261
+ def _add_destination(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
262
+ destination = resources.add_parser(
263
+ "destination",
264
+ help="Create and manage destinations.",
265
+ description="Create and manage destinations.",
266
+ )
267
+ operations = destination.add_subparsers(dest="operation", required=True)
268
+ _add_endpoint_command(
269
+ operations,
270
+ "list",
271
+ "List destinations.",
272
+ Endpoint("GET", "/v1/destinations", query_args=("limit", "cursor"), paginated=True),
273
+ pagination=True,
274
+ )
275
+ _add_endpoint_command(
276
+ operations,
277
+ "get",
278
+ "Get a destination.",
279
+ Endpoint("GET", "/v1/destinations/{destination_id}", id_args=("destination_id",)),
280
+ arguments=(("destination_id", "Destination ID."),),
281
+ )
282
+ _add_endpoint_command(
283
+ operations,
284
+ "create",
285
+ "Create a destination.",
286
+ Endpoint("POST", "/v1/destinations", body_required=True),
287
+ body=True,
288
+ body_required=True,
289
+ )
290
+ _add_endpoint_command(
291
+ operations,
292
+ "update",
293
+ "Update a destination.",
294
+ Endpoint(
295
+ "PATCH",
296
+ "/v1/destinations/{destination_id}",
297
+ id_args=("destination_id",),
298
+ body_required=True,
299
+ ),
300
+ arguments=(("destination_id", "Destination ID."),),
301
+ body=True,
302
+ body_required=True,
303
+ )
304
+ _add_endpoint_command(
305
+ operations,
306
+ "delete",
307
+ "Delete a destination.",
308
+ Endpoint(
309
+ "DELETE",
310
+ "/v1/destinations/{destination_id}",
311
+ id_args=("destination_id",),
312
+ destructive=True,
313
+ ),
314
+ arguments=(("destination_id", "Destination ID."),),
315
+ destructive=True,
316
+ )
317
+ _add_endpoint_command(
318
+ operations,
319
+ "test",
320
+ "Run destination setup tests.",
321
+ Endpoint(
322
+ "POST",
323
+ "/v1/destinations/{destination_id}/test",
324
+ id_args=("destination_id",),
325
+ ),
326
+ arguments=(("destination_id", "Destination ID."),),
327
+ )
328
+
329
+
330
+ def _add_group(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
331
+ group = resources.add_parser(
332
+ "group",
333
+ help="Manage groups that organize connections and destinations.",
334
+ description="Manage groups that organize connections and destinations.",
335
+ )
336
+ operations = group.add_subparsers(dest="operation", required=True)
337
+ _add_endpoint_command(
338
+ operations,
339
+ "list",
340
+ "List groups.",
341
+ Endpoint("GET", "/v1/groups", query_args=("limit", "cursor"), paginated=True),
342
+ pagination=True,
343
+ )
344
+ _add_endpoint_command(
345
+ operations,
346
+ "get",
347
+ "Get a group.",
348
+ Endpoint("GET", "/v1/groups/{group_id}", id_args=("group_id",)),
349
+ arguments=(("group_id", "Group ID."),),
350
+ )
351
+ _add_endpoint_command(
352
+ operations,
353
+ "create",
354
+ "Create a group.",
355
+ Endpoint("POST", "/v1/groups", body_required=True),
356
+ body=True,
357
+ body_required=True,
358
+ )
359
+ _add_endpoint_command(
360
+ operations,
361
+ "update",
362
+ "Update a group.",
363
+ Endpoint("PATCH", "/v1/groups/{group_id}", id_args=("group_id",), body_required=True),
364
+ arguments=(("group_id", "Group ID."),),
365
+ body=True,
366
+ body_required=True,
367
+ )
368
+ _add_endpoint_command(
369
+ operations,
370
+ "delete",
371
+ "Delete a group.",
372
+ Endpoint("DELETE", "/v1/groups/{group_id}", id_args=("group_id",), destructive=True),
373
+ arguments=(("group_id", "Group ID."),),
374
+ destructive=True,
375
+ )
376
+ _add_group_get_subresource(
377
+ operations,
378
+ "public-key",
379
+ "public key",
380
+ "/v1/groups/{group_id}/public-key",
381
+ )
382
+ _add_group_get_subresource(
383
+ operations,
384
+ "service-account",
385
+ "service account",
386
+ "/v1/groups/{group_id}/service-account",
387
+ )
388
+ _add_group_connection_commands(operations)
389
+ _add_group_user_commands(operations)
390
+
391
+
392
+ def _add_group_get_subresource(
393
+ operations: argparse._SubParsersAction[argparse.ArgumentParser],
394
+ name: str,
395
+ label: str,
396
+ path: str,
397
+ ) -> None:
398
+ parser = operations.add_parser(
399
+ name,
400
+ help=f"Manage group {label}.",
401
+ description=f"Manage group {label}.",
402
+ )
403
+ subresource_ops = parser.add_subparsers(dest=f"{name.replace('-', '_')}_operation", required=True)
404
+ _add_endpoint_command(
405
+ subresource_ops,
406
+ "get",
407
+ f"Get a group's {label}.",
408
+ Endpoint("GET", path, id_args=("group_id",)),
409
+ arguments=(("group_id", "Group ID."),),
410
+ )
411
+
412
+
413
+ def _add_group_connection_commands(
414
+ operations: argparse._SubParsersAction[argparse.ArgumentParser],
415
+ ) -> None:
416
+ connection = operations.add_parser(
417
+ "connection",
418
+ help="Manage group connections.",
419
+ description="Manage group connections.",
420
+ )
421
+ connection_ops = connection.add_subparsers(dest="connection_operation", required=True)
422
+ _add_endpoint_command(
423
+ connection_ops,
424
+ "list",
425
+ "List group connections.",
426
+ Endpoint(
427
+ "GET",
428
+ "/v1/groups/{group_id}/connections",
429
+ id_args=("group_id",),
430
+ query_args=("limit", "cursor"),
431
+ paginated=True,
432
+ ),
433
+ arguments=(("group_id", "Group ID."),),
434
+ pagination=True,
435
+ )
436
+
437
+
438
+ def _add_group_user_commands(
439
+ operations: argparse._SubParsersAction[argparse.ArgumentParser],
440
+ ) -> None:
441
+ user = operations.add_parser(
442
+ "user",
443
+ help="Manage group users.",
444
+ description="Manage group users.",
445
+ )
446
+ user_ops = user.add_subparsers(dest="user_operation", required=True)
447
+ _add_endpoint_command(
448
+ user_ops,
449
+ "list",
450
+ "List group users.",
451
+ Endpoint(
452
+ "GET",
453
+ "/v1/groups/{group_id}/users",
454
+ id_args=("group_id",),
455
+ query_args=("limit", "cursor"),
456
+ paginated=True,
457
+ ),
458
+ arguments=(("group_id", "Group ID."),),
459
+ pagination=True,
460
+ )
461
+ _add_endpoint_command(
462
+ user_ops,
463
+ "add",
464
+ "Add a user to a group.",
465
+ Endpoint("POST", "/v1/groups/{group_id}/users", id_args=("group_id",), body_required=True),
466
+ arguments=(("group_id", "Group ID."),),
467
+ body=True,
468
+ body_required=True,
469
+ )
470
+ _add_endpoint_command(
471
+ user_ops,
472
+ "remove",
473
+ "Remove a user from a group.",
474
+ Endpoint(
475
+ "DELETE",
476
+ "/v1/groups/{group_id}/users/{user_id}",
477
+ id_args=("group_id", "user_id"),
478
+ destructive=True,
479
+ ),
480
+ arguments=(("group_id", "Group ID."), ("user_id", "User ID.")),
481
+ destructive=True,
482
+ )
483
+
484
+
485
+ def _add_user(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
486
+ user = resources.add_parser(
487
+ "user",
488
+ help="Manage user accounts and access.",
489
+ description="Manage user accounts and access.",
490
+ )
491
+ operations = user.add_subparsers(dest="operation", required=True)
492
+ _add_endpoint_command(
493
+ operations,
494
+ "list",
495
+ "List users.",
496
+ Endpoint("GET", "/v1/users", query_args=("limit", "cursor"), paginated=True),
497
+ pagination=True,
498
+ )
499
+ _add_endpoint_command(
500
+ operations,
501
+ "get",
502
+ "Get a user.",
503
+ Endpoint("GET", "/v1/users/{user_id}", id_args=("user_id",)),
504
+ arguments=(("user_id", "User ID."),),
505
+ )
506
+ _add_endpoint_command(
507
+ operations,
508
+ "invite",
509
+ "Invite a user.",
510
+ Endpoint("POST", "/v1/users", body_required=True),
511
+ body=True,
512
+ body_required=True,
513
+ )
514
+ _add_endpoint_command(
515
+ operations,
516
+ "update",
517
+ "Update a user.",
518
+ Endpoint("PATCH", "/v1/users/{user_id}", id_args=("user_id",), body_required=True),
519
+ arguments=(("user_id", "User ID."),),
520
+ body=True,
521
+ body_required=True,
522
+ )
523
+ _add_endpoint_command(
524
+ operations,
525
+ "delete",
526
+ "Delete a user.",
527
+ Endpoint("DELETE", "/v1/users/{user_id}", id_args=("user_id",), destructive=True),
528
+ arguments=(("user_id", "User ID."),),
529
+ destructive=True,
530
+ )
531
+ _add_account_role_remove(operations, "/v1/users/{user_id}/role", "user_id", "User ID.")
532
+ _add_user_membership_group(
533
+ operations,
534
+ "connection",
535
+ "connection_id",
536
+ "Connection ID.",
537
+ "/v1/users/{user_id}/connections",
538
+ "/v1/users/{user_id}/connections/{connection_id}",
539
+ first_id=("user_id", "User ID."),
540
+ add_requires_second_id=False,
541
+ )
542
+ _add_user_membership_group(
543
+ operations,
544
+ "group",
545
+ "group_id",
546
+ "Group ID.",
547
+ "/v1/users/{user_id}/groups",
548
+ "/v1/users/{user_id}/groups/{group_id}",
549
+ first_id=("user_id", "User ID."),
550
+ add_requires_second_id=False,
551
+ )
552
+
553
+
554
+ def _add_team(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
555
+ team = resources.add_parser(
556
+ "team",
557
+ help="Manage teams and their access.",
558
+ description="Manage teams and their access.",
559
+ )
560
+ operations = team.add_subparsers(dest="operation", required=True)
561
+ _add_endpoint_command(
562
+ operations,
563
+ "list",
564
+ "List teams.",
565
+ Endpoint("GET", "/v1/teams", query_args=("limit", "cursor"), paginated=True),
566
+ pagination=True,
567
+ )
568
+ _add_endpoint_command(
569
+ operations,
570
+ "get",
571
+ "Get a team.",
572
+ Endpoint("GET", "/v1/teams/{team_id}", id_args=("team_id",)),
573
+ arguments=(("team_id", "Team ID."),),
574
+ )
575
+ _add_endpoint_command(
576
+ operations,
577
+ "create",
578
+ "Create a team.",
579
+ Endpoint("POST", "/v1/teams", body_required=True),
580
+ body=True,
581
+ body_required=True,
582
+ )
583
+ _add_endpoint_command(
584
+ operations,
585
+ "update",
586
+ "Update a team.",
587
+ Endpoint("PATCH", "/v1/teams/{team_id}", id_args=("team_id",), body_required=True),
588
+ arguments=(("team_id", "Team ID."),),
589
+ body=True,
590
+ body_required=True,
591
+ )
592
+ _add_endpoint_command(
593
+ operations,
594
+ "delete",
595
+ "Delete a team.",
596
+ Endpoint("DELETE", "/v1/teams/{team_id}", id_args=("team_id",), destructive=True),
597
+ arguments=(("team_id", "Team ID."),),
598
+ destructive=True,
599
+ )
600
+ _add_account_role_remove(operations, "/v1/teams/{team_id}/role", "team_id", "Team ID.")
601
+ _add_user_membership_group(
602
+ operations,
603
+ "connection",
604
+ "connection_id",
605
+ "Connection ID.",
606
+ "/v1/teams/{team_id}/connections",
607
+ "/v1/teams/{team_id}/connections/{connection_id}",
608
+ first_id=("team_id", "Team ID."),
609
+ add_requires_second_id=True,
610
+ )
611
+ _add_user_membership_group(
612
+ operations,
613
+ "group",
614
+ "group_id",
615
+ "Group ID.",
616
+ "/v1/teams/{team_id}/groups",
617
+ "/v1/teams/{team_id}/groups/{group_id}",
618
+ first_id=("team_id", "Team ID."),
619
+ add_requires_second_id=True,
620
+ )
621
+ _add_user_membership_group(
622
+ operations,
623
+ "user",
624
+ "user_id",
625
+ "User ID.",
626
+ "/v1/teams/{team_id}/users",
627
+ "/v1/teams/{team_id}/users/{user_id}",
628
+ first_id=("team_id", "Team ID."),
629
+ add_requires_second_id=False,
630
+ )
631
+
632
+
633
+ def _add_role(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
634
+ role = resources.add_parser(
635
+ "role",
636
+ help="Retrieve available roles and permissions.",
637
+ description="Retrieve available roles and permissions.",
638
+ )
639
+ operations = role.add_subparsers(dest="operation", required=True)
640
+ _add_endpoint_command(
641
+ operations,
642
+ "list",
643
+ "List roles.",
644
+ Endpoint("GET", "/v1/roles", query_args=("limit", "cursor"), paginated=True),
645
+ pagination=True,
646
+ )
647
+
648
+
649
+ def _add_system_key(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
650
+ system_key = resources.add_parser(
651
+ "system-key",
652
+ help="Manage system keys.",
653
+ description="Manage system keys.",
654
+ )
655
+ operations = system_key.add_subparsers(dest="operation", required=True)
656
+ _add_endpoint_command(
657
+ operations,
658
+ "list",
659
+ "List system keys.",
660
+ Endpoint("GET", "/v1/system-keys", query_args=("limit", "cursor"), paginated=True),
661
+ pagination=True,
662
+ )
663
+ _add_endpoint_command(
664
+ operations,
665
+ "get",
666
+ "Get a system key.",
667
+ Endpoint("GET", "/v1/system-keys/{key_id}", id_args=("key_id",)),
668
+ arguments=(("key_id", "System key ID."),),
669
+ )
670
+ _add_endpoint_command(
671
+ operations,
672
+ "create",
673
+ "Create a system key.",
674
+ Endpoint("POST", "/v1/system-keys", body_required=True),
675
+ body=True,
676
+ body_required=True,
677
+ )
678
+ _add_endpoint_command(
679
+ operations,
680
+ "update",
681
+ "Update a system key.",
682
+ Endpoint("PATCH", "/v1/system-keys/{key_id}", id_args=("key_id",), body_required=True),
683
+ arguments=(("key_id", "System key ID."),),
684
+ body=True,
685
+ body_required=True,
686
+ )
687
+ _add_endpoint_command(
688
+ operations,
689
+ "delete",
690
+ "Delete a system key.",
691
+ Endpoint("DELETE", "/v1/system-keys/{key_id}", id_args=("key_id",), destructive=True),
692
+ arguments=(("key_id", "System key ID."),),
693
+ destructive=True,
694
+ )
695
+ _add_endpoint_command(
696
+ operations,
697
+ "rotate",
698
+ "Rotate a system key.",
699
+ Endpoint(
700
+ "POST",
701
+ "/v1/system-keys/{key_id}/rotate",
702
+ id_args=("key_id",),
703
+ body_optional=True,
704
+ ),
705
+ arguments=(("key_id", "System key ID."),),
706
+ body=True,
707
+ )
708
+
709
+
710
+ def _add_connection_schema(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
711
+ connection_schema = resources.add_parser(
712
+ "connection-schema",
713
+ help="Manage connection schema configuration.",
714
+ description="Manage connection schema configuration.",
715
+ )
716
+ operations = connection_schema.add_subparsers(dest="operation", required=True)
717
+ _add_endpoint_command(
718
+ operations,
719
+ "get",
720
+ "Get connection schema configuration.",
721
+ Endpoint("GET", "/v1/connections/{connection_id}/schemas", id_args=("connection_id",)),
722
+ arguments=(("connection_id", "Connection ID."),),
723
+ )
724
+ _add_endpoint_command(
725
+ operations,
726
+ "setup",
727
+ "Set up connection schema configuration.",
728
+ Endpoint(
729
+ "POST",
730
+ "/v1/connections/{connection_id}/schemas",
731
+ id_args=("connection_id",),
732
+ body_required=True,
733
+ ),
734
+ arguments=(("connection_id", "Connection ID."),),
735
+ body=True,
736
+ body_required=True,
737
+ )
738
+ _add_endpoint_command(
739
+ operations,
740
+ "update",
741
+ "Update connection schema configuration.",
742
+ Endpoint(
743
+ "PATCH",
744
+ "/v1/connections/{connection_id}/schemas",
745
+ id_args=("connection_id",),
746
+ body_required=True,
747
+ ),
748
+ arguments=(("connection_id", "Connection ID."),),
749
+ body=True,
750
+ body_required=True,
751
+ )
752
+ _add_endpoint_command(
753
+ operations,
754
+ "reload",
755
+ "Reload connection schema configuration.",
756
+ Endpoint("POST", "/v1/connections/{connection_id}/schemas/reload", id_args=("connection_id",)),
757
+ arguments=(("connection_id", "Connection ID."),),
758
+ )
759
+ _add_endpoint_command(
760
+ operations,
761
+ "resync-table",
762
+ "Re-sync connection table data.",
763
+ Endpoint(
764
+ "POST",
765
+ "/v1/connections/{connection_id}/schemas/tables/resync",
766
+ id_args=("connection_id",),
767
+ body_required=True,
768
+ ),
769
+ arguments=(("connection_id", "Connection ID."),),
770
+ body=True,
771
+ body_required=True,
772
+ )
773
+ _add_connection_schema_schema_commands(operations)
774
+ _add_connection_schema_table_commands(operations)
775
+ _add_connection_schema_column_commands(operations)
776
+
777
+
778
+ def _add_connection_schema_schema_commands(
779
+ operations: argparse._SubParsersAction[argparse.ArgumentParser],
780
+ ) -> None:
781
+ schema = operations.add_parser(
782
+ "schema",
783
+ help="Manage a database schema within a connection schema config.",
784
+ description="Manage a database schema within a connection schema config.",
785
+ )
786
+ schema_ops = schema.add_subparsers(dest="schema_operation", required=True)
787
+ _add_endpoint_command(
788
+ schema_ops,
789
+ "update",
790
+ "Update a connection database schema config.",
791
+ Endpoint(
792
+ "PATCH",
793
+ "/v1/connections/{connection_id}/schemas/{schema_name}",
794
+ id_args=("connection_id", "schema_name"),
795
+ body_required=True,
796
+ ),
797
+ arguments=(("connection_id", "Connection ID."), ("schema_name", "Schema name.")),
798
+ body=True,
799
+ body_required=True,
800
+ )
801
+
802
+
803
+ def _add_connection_schema_table_commands(
804
+ operations: argparse._SubParsersAction[argparse.ArgumentParser],
805
+ ) -> None:
806
+ table = operations.add_parser(
807
+ "table",
808
+ help="Manage table schema configuration.",
809
+ description="Manage table schema configuration.",
810
+ )
811
+ table_ops = table.add_subparsers(dest="table_operation", required=True)
812
+ _add_endpoint_command(
813
+ table_ops,
814
+ "update",
815
+ "Update a connection table config.",
816
+ Endpoint(
817
+ "PATCH",
818
+ "/v1/connections/{connection_id}/schemas/{schema_name}/tables/{table_name}",
819
+ id_args=("connection_id", "schema_name", "table_name"),
820
+ body_required=True,
821
+ ),
822
+ arguments=(
823
+ ("connection_id", "Connection ID."),
824
+ ("schema_name", "Schema name."),
825
+ ("table_name", "Table name."),
826
+ ),
827
+ body=True,
828
+ body_required=True,
829
+ )
830
+
831
+
832
+ def _add_connection_schema_column_commands(
833
+ operations: argparse._SubParsersAction[argparse.ArgumentParser],
834
+ ) -> None:
835
+ column = operations.add_parser(
836
+ "column",
837
+ help="Manage column schema configuration.",
838
+ description="Manage column schema configuration.",
839
+ )
840
+ column_ops = column.add_subparsers(dest="column_operation", required=True)
841
+ _add_endpoint_command(
842
+ column_ops,
843
+ "list",
844
+ "List source table column config.",
845
+ Endpoint(
846
+ "GET",
847
+ "/v1/connections/{connection_id}/schemas/{schema_name}/tables/{table_name}/columns",
848
+ id_args=("connection_id", "schema_name", "table_name"),
849
+ ),
850
+ arguments=(
851
+ ("connection_id", "Connection ID."),
852
+ ("schema_name", "Schema name."),
853
+ ("table_name", "Table name."),
854
+ ),
855
+ )
856
+ _add_endpoint_command(
857
+ column_ops,
858
+ "update",
859
+ "Update a connection column config.",
860
+ Endpoint(
861
+ "PATCH",
862
+ "/v1/connections/{connection_id}/schemas/{schema_name}/tables/{table_name}/columns/{column_name}",
863
+ id_args=("connection_id", "schema_name", "table_name", "column_name"),
864
+ body_required=True,
865
+ ),
866
+ arguments=(
867
+ ("connection_id", "Connection ID."),
868
+ ("schema_name", "Schema name."),
869
+ ("table_name", "Table name."),
870
+ ("column_name", "Column name."),
871
+ ),
872
+ body=True,
873
+ body_required=True,
874
+ )
875
+ _add_endpoint_command(
876
+ column_ops,
877
+ "drop",
878
+ "Drop a blocked column from the destination.",
879
+ Endpoint(
880
+ "DELETE",
881
+ "/v1/connections/{connection_id}/schemas/{schema_name}/tables/{table_name}/columns/{column_name}",
882
+ id_args=("connection_id", "schema_name", "table_name", "column_name"),
883
+ destructive=True,
884
+ ),
885
+ arguments=(
886
+ ("connection_id", "Connection ID."),
887
+ ("schema_name", "Schema name."),
888
+ ("table_name", "Table name."),
889
+ ("column_name", "Column name."),
890
+ ),
891
+ destructive=True,
892
+ )
893
+ _add_endpoint_command(
894
+ column_ops,
895
+ "drop-blocked",
896
+ "Drop blocked columns from the destination.",
897
+ Endpoint(
898
+ "POST",
899
+ "/v1/connections/{connection_id}/schemas/drop-columns",
900
+ id_args=("connection_id",),
901
+ body_required=True,
902
+ destructive=True,
903
+ ),
904
+ arguments=(("connection_id", "Connection ID."),),
905
+ body=True,
906
+ body_required=True,
907
+ destructive=True,
908
+ )
909
+
910
+
911
+ def _add_connector_metadata(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
912
+ metadata = resources.add_parser(
913
+ "connector-metadata",
914
+ help="Retrieve authenticated connector metadata.",
915
+ description="Retrieve authenticated connector metadata.",
916
+ )
917
+ operations = metadata.add_subparsers(dest="operation", required=True)
918
+ _add_endpoint_command(
919
+ operations,
920
+ "list",
921
+ "List connector metadata.",
922
+ Endpoint("GET", "/v1/metadata/connector-types", query_args=("limit", "cursor"), paginated=True),
923
+ pagination=True,
924
+ )
925
+ _add_endpoint_command(
926
+ operations,
927
+ "get",
928
+ "Get connector metadata.",
929
+ Endpoint("GET", "/v1/metadata/connector-types/{service}", id_args=("service",)),
930
+ arguments=(("service", "Connector service name."),),
931
+ )
932
+
933
+
934
+ def _add_public_connector(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
935
+ public_connector = resources.add_parser(
936
+ "public-connector",
937
+ help="Discover public connector metadata without authentication.",
938
+ description="Discover public connector metadata without authentication.",
939
+ )
940
+ operations = public_connector.add_subparsers(dest="operation", required=True)
941
+ _add_endpoint_command(
942
+ operations,
943
+ "list",
944
+ "List public connector metadata.",
945
+ Endpoint("GET", "/public/connector-types", auth_required=False),
946
+ )
947
+
948
+
949
+ def _add_hybrid_deployment_agent(
950
+ resources: argparse._SubParsersAction[argparse.ArgumentParser],
951
+ ) -> None:
952
+ agent = resources.add_parser(
953
+ "hybrid-deployment-agent",
954
+ help="Manage Hybrid Deployment agents.",
955
+ description="Manage Hybrid Deployment agents.",
956
+ )
957
+ operations = agent.add_subparsers(dest="operation", required=True)
958
+ _add_endpoint_command(
959
+ operations,
960
+ "list",
961
+ "List Hybrid Deployment agents.",
962
+ Endpoint(
963
+ "GET",
964
+ "/v1/hybrid-deployment-agents",
965
+ query_args=("group_id", "limit", "cursor"),
966
+ paginated=True,
967
+ ),
968
+ query_options=(("group-id", "group_id"),),
969
+ pagination=True,
970
+ )
971
+ _add_endpoint_command(
972
+ operations,
973
+ "get",
974
+ "Get Hybrid Deployment agent details.",
975
+ Endpoint("GET", "/v1/hybrid-deployment-agents/{agent_id}", id_args=("agent_id",)),
976
+ arguments=(("agent_id", "Agent ID."),),
977
+ )
978
+ _add_endpoint_command(
979
+ operations,
980
+ "create",
981
+ "Create a Hybrid Deployment agent.",
982
+ Endpoint("POST", "/v1/hybrid-deployment-agents", body_required=True),
983
+ body=True,
984
+ body_required=True,
985
+ )
986
+ _add_endpoint_command(
987
+ operations,
988
+ "delete",
989
+ "Delete a Hybrid Deployment agent.",
990
+ Endpoint(
991
+ "DELETE",
992
+ "/v1/hybrid-deployment-agents/{agent_id}",
993
+ id_args=("agent_id",),
994
+ destructive=True,
995
+ ),
996
+ arguments=(("agent_id", "Agent ID."),),
997
+ destructive=True,
998
+ )
999
+ _add_endpoint_command(
1000
+ operations,
1001
+ "regenerate-auth",
1002
+ "Regenerate Hybrid Deployment agent authentication.",
1003
+ Endpoint(
1004
+ "PATCH",
1005
+ "/v1/hybrid-deployment-agents/{agent_id}/re-auth",
1006
+ id_args=("agent_id",),
1007
+ ),
1008
+ arguments=(("agent_id", "Agent ID."),),
1009
+ )
1010
+ _add_endpoint_command(
1011
+ operations,
1012
+ "reset-credentials",
1013
+ "Reset Hybrid Deployment agent credentials.",
1014
+ Endpoint(
1015
+ "POST",
1016
+ "/v1/hybrid-deployment-agents/{agent_id}/reset-credentials",
1017
+ id_args=("agent_id",),
1018
+ body_required=True,
1019
+ ),
1020
+ arguments=(("agent_id", "Agent ID."),),
1021
+ body=True,
1022
+ body_required=True,
1023
+ )
1024
+
1025
+
1026
+ def _add_proxy_agent(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
1027
+ agent = resources.add_parser(
1028
+ "proxy-agent",
1029
+ help="Manage proxy agents.",
1030
+ description="Manage proxy agents.",
1031
+ )
1032
+ operations = agent.add_subparsers(dest="operation", required=True)
1033
+ _add_endpoint_command(
1034
+ operations,
1035
+ "list",
1036
+ "List proxy agents.",
1037
+ Endpoint("GET", "/v1/proxy", query_args=("limit", "cursor"), paginated=True),
1038
+ pagination=True,
1039
+ )
1040
+ _add_endpoint_command(
1041
+ operations,
1042
+ "get",
1043
+ "Get proxy agent details.",
1044
+ Endpoint("GET", "/v1/proxy/{agent_id}", id_args=("agent_id",)),
1045
+ arguments=(("agent_id", "Agent ID."),),
1046
+ )
1047
+ _add_endpoint_command(
1048
+ operations,
1049
+ "create",
1050
+ "Create a proxy agent.",
1051
+ Endpoint("POST", "/v1/proxy", body_required=True),
1052
+ body=True,
1053
+ body_required=True,
1054
+ )
1055
+ _add_endpoint_command(
1056
+ operations,
1057
+ "delete",
1058
+ "Delete a proxy agent.",
1059
+ Endpoint("DELETE", "/v1/proxy/{agent_id}", id_args=("agent_id",), destructive=True),
1060
+ arguments=(("agent_id", "Agent ID."),),
1061
+ destructive=True,
1062
+ )
1063
+ _add_endpoint_command(
1064
+ operations,
1065
+ "regenerate-secrets",
1066
+ "Regenerate proxy agent secrets.",
1067
+ Endpoint("POST", "/v1/proxy/{agent_id}/regenerate-secrets", id_args=("agent_id",)),
1068
+ arguments=(("agent_id", "Agent ID."),),
1069
+ )
1070
+ _add_proxy_agent_connection_commands(operations)
1071
+
1072
+
1073
+ def _add_proxy_agent_connection_commands(
1074
+ operations: argparse._SubParsersAction[argparse.ArgumentParser],
1075
+ ) -> None:
1076
+ connection = operations.add_parser(
1077
+ "connection",
1078
+ help="List proxy agent connections.",
1079
+ description="List proxy agent connections.",
1080
+ )
1081
+ connection_ops = connection.add_subparsers(dest="connection_operation", required=True)
1082
+ _add_endpoint_command(
1083
+ connection_ops,
1084
+ "list",
1085
+ "List connections attached to a proxy agent.",
1086
+ Endpoint(
1087
+ "GET",
1088
+ "/v1/proxy/{agent_id}/connections",
1089
+ id_args=("agent_id",),
1090
+ query_args=("limit", "cursor"),
1091
+ paginated=True,
1092
+ ),
1093
+ arguments=(("agent_id", "Agent ID."),),
1094
+ pagination=True,
1095
+ )
1096
+
1097
+
1098
+ def _add_private_link(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
1099
+ private_link = resources.add_parser(
1100
+ "private-link",
1101
+ help="Manage private links.",
1102
+ description="Manage private links.",
1103
+ )
1104
+ operations = private_link.add_subparsers(dest="operation", required=True)
1105
+ _add_endpoint_command(
1106
+ operations,
1107
+ "list",
1108
+ "List private links.",
1109
+ Endpoint("GET", "/v1/private-links", query_args=("limit", "cursor"), paginated=True),
1110
+ pagination=True,
1111
+ )
1112
+ _add_endpoint_command(
1113
+ operations,
1114
+ "get",
1115
+ "Get private link details.",
1116
+ Endpoint("GET", "/v1/private-links/{private_link_id}", id_args=("private_link_id",)),
1117
+ arguments=(("private_link_id", "Private link ID."),),
1118
+ )
1119
+ _add_endpoint_command(
1120
+ operations,
1121
+ "create",
1122
+ "Create a private link.",
1123
+ Endpoint("POST", "/v1/private-links", body_required=True),
1124
+ body=True,
1125
+ body_required=True,
1126
+ )
1127
+ _add_endpoint_command(
1128
+ operations,
1129
+ "update",
1130
+ "Update a private link.",
1131
+ Endpoint(
1132
+ "PATCH",
1133
+ "/v1/private-links/{private_link_id}",
1134
+ id_args=("private_link_id",),
1135
+ body_required=True,
1136
+ ),
1137
+ arguments=(("private_link_id", "Private link ID."),),
1138
+ body=True,
1139
+ body_required=True,
1140
+ )
1141
+ _add_endpoint_command(
1142
+ operations,
1143
+ "delete",
1144
+ "Delete a private link.",
1145
+ Endpoint(
1146
+ "DELETE",
1147
+ "/v1/private-links/{private_link_id}",
1148
+ id_args=("private_link_id",),
1149
+ destructive=True,
1150
+ ),
1151
+ arguments=(("private_link_id", "Private link ID."),),
1152
+ destructive=True,
1153
+ )
1154
+
1155
+
1156
+ def _add_transformation(resources: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
1157
+ transformation = resources.add_parser(
1158
+ "transformation",
1159
+ help="List transformations.",
1160
+ description="List transformations.",
1161
+ )
1162
+ operations = transformation.add_subparsers(dest="operation", required=True)
1163
+ _add_endpoint_command(
1164
+ operations,
1165
+ "list",
1166
+ "List transformations.",
1167
+ Endpoint("GET", "/v1/transformations", query_args=("limit", "cursor"), paginated=True),
1168
+ pagination=True,
1169
+ )
1170
+
1171
+
1172
+ def _add_account_role_remove(
1173
+ operations: argparse._SubParsersAction[argparse.ArgumentParser],
1174
+ path: str,
1175
+ id_name: str,
1176
+ id_help: str,
1177
+ ) -> None:
1178
+ account_role = operations.add_parser(
1179
+ "account-role",
1180
+ help="Manage account-level role assignment.",
1181
+ description="Manage account-level role assignment.",
1182
+ )
1183
+ account_role_ops = account_role.add_subparsers(dest="account_role_operation", required=True)
1184
+ _add_endpoint_command(
1185
+ account_role_ops,
1186
+ "remove",
1187
+ "Remove account-level role assignment.",
1188
+ Endpoint("DELETE", path, id_args=(id_name,), destructive=True),
1189
+ arguments=((id_name, id_help),),
1190
+ destructive=True,
1191
+ )
1192
+
1193
+
1194
+ def _add_user_membership_group(
1195
+ operations: argparse._SubParsersAction[argparse.ArgumentParser],
1196
+ name: str,
1197
+ member_id_name: str,
1198
+ member_id_help: str,
1199
+ collection_path: str,
1200
+ item_path: str,
1201
+ *,
1202
+ first_id: tuple[str, str],
1203
+ add_requires_second_id: bool,
1204
+ ) -> None:
1205
+ group = operations.add_parser(
1206
+ name,
1207
+ help=f"Manage {name} memberships.",
1208
+ description=f"Manage {name} memberships.",
1209
+ )
1210
+ group_ops = group.add_subparsers(dest=f"{name.replace('-', '_')}_operation", required=True)
1211
+ first_id_name, first_id_help = first_id
1212
+ _add_endpoint_command(
1213
+ group_ops,
1214
+ "list",
1215
+ f"List {name} memberships.",
1216
+ Endpoint("GET", collection_path, id_args=(first_id_name,), query_args=("limit", "cursor"), paginated=True),
1217
+ arguments=((first_id_name, first_id_help),),
1218
+ pagination=True,
1219
+ )
1220
+ _add_endpoint_command(
1221
+ group_ops,
1222
+ "get",
1223
+ f"Get {name} membership.",
1224
+ Endpoint("GET", item_path, id_args=(first_id_name, member_id_name)),
1225
+ arguments=((first_id_name, first_id_help), (member_id_name, member_id_help)),
1226
+ )
1227
+ add_path = item_path if add_requires_second_id else collection_path
1228
+ add_ids = (first_id_name, member_id_name) if add_requires_second_id else (first_id_name,)
1229
+ add_arguments = (
1230
+ ((first_id_name, first_id_help), (member_id_name, member_id_help))
1231
+ if add_requires_second_id
1232
+ else ((first_id_name, first_id_help),)
1233
+ )
1234
+ _add_endpoint_command(
1235
+ group_ops,
1236
+ "add",
1237
+ f"Add {name} membership.",
1238
+ Endpoint("POST", add_path, id_args=add_ids, body_required=True),
1239
+ arguments=add_arguments,
1240
+ body=True,
1241
+ body_required=True,
1242
+ )
1243
+ _add_endpoint_command(
1244
+ group_ops,
1245
+ "update",
1246
+ f"Update {name} membership.",
1247
+ Endpoint("PATCH", item_path, id_args=(first_id_name, member_id_name), body_required=True),
1248
+ arguments=((first_id_name, first_id_help), (member_id_name, member_id_help)),
1249
+ body=True,
1250
+ body_required=True,
1251
+ )
1252
+ _add_endpoint_command(
1253
+ group_ops,
1254
+ "remove",
1255
+ f"Remove {name} membership.",
1256
+ Endpoint("DELETE", item_path, id_args=(first_id_name, member_id_name), destructive=True),
1257
+ arguments=((first_id_name, first_id_help), (member_id_name, member_id_help)),
1258
+ destructive=True,
1259
+ )
1260
+
1261
+
1262
+ def _add_endpoint_command(
1263
+ operations: argparse._SubParsersAction[argparse.ArgumentParser],
1264
+ name: str,
1265
+ help_text: str,
1266
+ endpoint: Endpoint,
1267
+ *,
1268
+ arguments: Sequence[tuple[str, str]] = (),
1269
+ query_options: Sequence[tuple[str, str]] = (),
1270
+ body: bool = False,
1271
+ body_required: bool = False,
1272
+ pagination: bool = False,
1273
+ destructive: bool = False,
1274
+ epilog: str | None = None,
1275
+ ) -> None:
1276
+ parser = operations.add_parser(
1277
+ name,
1278
+ help=help_text,
1279
+ description=help_text,
1280
+ epilog=_join_epilog(epilog, auth_required=endpoint.auth_required),
1281
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
1282
+ )
1283
+ for arg_name, arg_help in arguments:
1284
+ parser.add_argument(
1285
+ arg_name,
1286
+ metavar=f"<{arg_name.replace('_', '-')}>",
1287
+ help=arg_help,
1288
+ )
1289
+ for flag, dest in query_options:
1290
+ parser.add_argument(f"--{flag}", dest=dest)
1291
+ if pagination:
1292
+ parser.add_argument("--limit", type=int, help="Maximum items for one API page.")
1293
+ parser.add_argument("--cursor", help="Pagination cursor to start from.")
1294
+ parser.add_argument("--all", action="store_true", help="Follow pagination until exhausted.")
1295
+ if body:
1296
+ _add_body_options(parser, required=body_required)
1297
+ if destructive:
1298
+ parser.add_argument("--yes", action="store_true", help="Confirm the destructive operation.")
1299
+ parser.set_defaults(handler=_handler(endpoint))
1300
+
1301
+
1302
+ def _join_epilog(epilog: str | None, *, auth_required: bool) -> str:
1303
+ operation_epilog = OPERATION_EPILOG if auth_required else PUBLIC_OPERATION_EPILOG
1304
+ if epilog:
1305
+ return f"{epilog}\n\n{operation_epilog}"
1306
+ return operation_epilog
1307
+
1308
+
1309
+ def _normalize_global_options(argv: list[str]) -> list[str]:
1310
+ global_args: list[str] = []
1311
+ command_args: list[str] = []
1312
+ index = 0
1313
+ while index < len(argv):
1314
+ token = argv[index]
1315
+ option_name = token.split("=", 1)[0]
1316
+ if option_name in GLOBAL_OPTIONS_WITH_VALUES:
1317
+ if "=" in token:
1318
+ global_args.append(token)
1319
+ elif index + 1 < len(argv):
1320
+ global_args.extend([token, argv[index + 1]])
1321
+ index += 1
1322
+ else:
1323
+ command_args.append(token)
1324
+ elif token in GLOBAL_FLAG_OPTIONS:
1325
+ global_args.append(token)
1326
+ else:
1327
+ command_args.append(token)
1328
+ index += 1
1329
+ return [*global_args, *command_args]
1330
+
1331
+
1332
+ def _add_body_options(parser: argparse.ArgumentParser, *, required: bool) -> None:
1333
+ group = parser.add_mutually_exclusive_group(required=required)
1334
+ group.add_argument("--data", help="Raw JSON request body.")
1335
+ group.add_argument("--data-file", help="Path to a JSON request body, or '-' for stdin.")
1336
+
1337
+
1338
+ def _handler(endpoint: Endpoint) -> Handler:
1339
+ def handle(args: argparse.Namespace, client: FivetranClient) -> Any:
1340
+ if endpoint.destructive:
1341
+ _confirm_destructive(args)
1342
+
1343
+ body = _load_body(args) if endpoint.body_required or endpoint.body_optional else None
1344
+ path = _format_path(endpoint.path_template, args, endpoint.id_args)
1345
+ query = {arg: getattr(args, arg, None) for arg in endpoint.query_args}
1346
+
1347
+ if endpoint.paginated and getattr(args, "all", False):
1348
+ return client.paginated_request(
1349
+ endpoint.method,
1350
+ path,
1351
+ query=query,
1352
+ auth_required=endpoint.auth_required,
1353
+ )
1354
+ return client.request(
1355
+ endpoint.method,
1356
+ path,
1357
+ query=query,
1358
+ body=body,
1359
+ auth_required=endpoint.auth_required,
1360
+ )
1361
+
1362
+ return handle
1363
+
1364
+
1365
+ def _format_path(
1366
+ path_template: str,
1367
+ args: argparse.Namespace,
1368
+ id_args: tuple[str, ...],
1369
+ ) -> str:
1370
+ path_args = {
1371
+ arg: urllib.parse.quote(str(getattr(args, arg)), safe="")
1372
+ for arg in id_args
1373
+ }
1374
+ return path_template.format(**path_args)
1375
+
1376
+
1377
+ def _load_body(args: argparse.Namespace) -> Any:
1378
+ raw: str | None = None
1379
+ if getattr(args, "data", None) is not None:
1380
+ raw = args.data
1381
+ elif getattr(args, "data_file", None) is not None:
1382
+ if args.data_file == "-":
1383
+ raw = sys.stdin.read()
1384
+ else:
1385
+ try:
1386
+ raw = Path(args.data_file).read_text(encoding="utf-8")
1387
+ except OSError as exc:
1388
+ raise CliError(f"Could not read data file {args.data_file}: {exc}") from exc
1389
+
1390
+ if raw is None:
1391
+ return None
1392
+ try:
1393
+ return json.loads(raw)
1394
+ except json.JSONDecodeError as exc:
1395
+ raise CliError(f"Invalid JSON request body: {exc}") from exc
1396
+
1397
+
1398
+ def _confirm_destructive(args: argparse.Namespace) -> None:
1399
+ if getattr(args, "yes", False):
1400
+ return
1401
+ if not sys.stdin.isatty():
1402
+ raise CliError("This destructive operation requires --yes in non-interactive mode.")
1403
+ target = " ".join(
1404
+ str(getattr(args, name))
1405
+ for name in vars(args)
1406
+ if name.endswith("_id") and getattr(args, name, None)
1407
+ )
1408
+ prompt = f"Proceed with destructive operation{f' on {target}' if target else ''}? [y/N] "
1409
+ if input(prompt).strip().lower() not in {"y", "yes"}:
1410
+ raise CliError("Operation cancelled.")
1411
+
1412
+
1413
+ def resolve_config(args: argparse.Namespace) -> ClientConfig:
1414
+ profile = _load_profile(args.profile) if args.profile else {}
1415
+ api_key = args.api_key or profile.get("api_key") or os.environ.get("FIVETRAN_API_KEY")
1416
+ api_secret = (
1417
+ args.api_secret
1418
+ or profile.get("api_secret")
1419
+ or os.environ.get("FIVETRAN_API_SECRET")
1420
+ )
1421
+ base_url = (
1422
+ args.base_url
1423
+ or profile.get("base_url")
1424
+ or os.environ.get("FIVETRAN_BASE_URL")
1425
+ or DEFAULT_BASE_URL
1426
+ )
1427
+ return ClientConfig(
1428
+ api_key=api_key,
1429
+ api_secret=api_secret,
1430
+ base_url=base_url,
1431
+ verbose=args.verbose,
1432
+ )
1433
+
1434
+
1435
+ def _load_profile(profile_name: str) -> dict[str, str]:
1436
+ config_path = Path(
1437
+ os.environ.get(
1438
+ "FIVETRAN_CONFIG",
1439
+ str(Path.home() / ".config" / "fivetran-cli" / "config.toml"),
1440
+ )
1441
+ )
1442
+ if not config_path.exists():
1443
+ raise CliError(f"Profile '{profile_name}' not found; config file does not exist: {config_path}")
1444
+ try:
1445
+ config = tomllib.loads(config_path.read_text(encoding="utf-8"))
1446
+ except (OSError, tomllib.TOMLDecodeError) as exc:
1447
+ raise CliError(f"Could not read config file {config_path}: {exc}") from exc
1448
+ profiles = config.get("profiles", {})
1449
+ profile = profiles.get(profile_name)
1450
+ if not isinstance(profile, dict):
1451
+ raise CliError(f"Profile '{profile_name}' not found in {config_path}")
1452
+ return {str(key): str(value) for key, value in profile.items() if value is not None}
1453
+
1454
+
1455
+ def _suggestion(message: str, parser: argparse.ArgumentParser) -> str | None:
1456
+ if "invalid choice:" not in message:
1457
+ return None
1458
+ parts = message.split("'")
1459
+ if len(parts) < 2:
1460
+ return None
1461
+ invalid = parts[1]
1462
+ choices = _subparser_choices(parser)
1463
+ matches = get_close_matches(invalid, choices, n=1)
1464
+ return matches[0] if matches else None
1465
+
1466
+
1467
+ def _subparser_choices(parser: argparse.ArgumentParser) -> list[str]:
1468
+ choices: list[str] = []
1469
+ for action in parser._actions:
1470
+ if isinstance(action, argparse._SubParsersAction):
1471
+ choices.extend(action.choices)
1472
+ return choices