connect-flow 0.1.1__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,4 @@
1
+ from connect_flow.cli import main_connect_flow
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main_connect_flow())
connect_flow/args.py ADDED
@@ -0,0 +1,647 @@
1
+ """Argument parser construction for the ``connect-flow`` console script, plus
2
+ the two cross-flag validators that belong with it.
3
+
4
+ Split out of cli.py: parser construction is ~500 lines of pure declaration
5
+ with no behaviour, and it depends on nothing else in the CLI beyond an exit
6
+ code. Keeping it here leaves cli.py as the verb dispatch it is named for.
7
+ See docs/adr/0011-cli-module-split.md.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+
15
+ from connect_flow import __version__
16
+ from connect_flow.exits import EXIT_USAGE_ERROR
17
+
18
+
19
+ class _ArgumentParser(argparse.ArgumentParser):
20
+ """argparse exits with status 2 on its own usage errors; we reserve 2 for
21
+ safety violations and want 3 for bad usage (see module docstring)."""
22
+
23
+ def error(self, message: str) -> None:
24
+ self.print_usage(sys.stderr)
25
+ self.exit(EXIT_USAGE_ERROR, f"{self.prog}: error: {message}\n")
26
+
27
+
28
+ def _add_common_output_arguments(
29
+ parser: argparse.ArgumentParser, *, suppress_defaults: bool = False
30
+ ) -> None:
31
+ """Flags shared by every ``connect-flow`` subcommand, and by the top-level
32
+ parser itself so they can also be given before the subcommand, aws-cli
33
+ style (``connect-flow --profile p --region r list``).
34
+
35
+ A subparser's ``parse_known_args`` is handed a *fresh* sub-namespace, not
36
+ the parent's - once it returns, argparse copies every attribute that
37
+ sub-namespace holds onto the parent namespace unconditionally, clobbering
38
+ whatever the top-level parsing already set. ``suppress_defaults=True``
39
+ (used only for the per-subcommand copies of these flags) makes an
40
+ unsupplied flag simply absent from that sub-namespace instead of present
41
+ with a default of ``None``/``False``, so the merge has nothing to
42
+ clobber with and a value given before the subcommand survives.
43
+ """
44
+ str_default = argparse.SUPPRESS if suppress_defaults else None
45
+ bool_default = argparse.SUPPRESS if suppress_defaults else False
46
+ parser.add_argument(
47
+ "--profile", metavar="NAME", default=str_default, help="AWS profile to use"
48
+ )
49
+ parser.add_argument(
50
+ "--region",
51
+ metavar="NAME",
52
+ default=str_default,
53
+ help="AWS region; inferred from a target ARN if omitted, or checked for a "
54
+ "conflict if a target ARN disagrees. Where a region must be known up "
55
+ "front (--instance addressing, list) and none is given, falls back "
56
+ "to your AWS profile/config default",
57
+ )
58
+ parser.add_argument(
59
+ "--json",
60
+ action="store_true",
61
+ default=bool_default,
62
+ help="machine-readable report on stdout",
63
+ )
64
+ parser.add_argument(
65
+ "--quiet", action="store_true", default=bool_default, help="errors only"
66
+ )
67
+
68
+
69
+ def _add_instance_argument(parser: argparse.ArgumentParser) -> None:
70
+ """--instance: a Connect instance id or alias, auto-detected via
71
+ aws.is_id_like() - a name is resolved via ListInstances (exact match).
72
+ Added everywhere instance addressing is needed, so this is the one place
73
+ the flag is defined. See docs/adr/0008-unified-instance-flow-addressing.md."""
74
+ parser.add_argument(
75
+ "--instance",
76
+ metavar="ID-OR-ALIAS",
77
+ default=None,
78
+ help="Connect instance id, or alias resolved via ListInstances (exact match)",
79
+ )
80
+
81
+
82
+ def _add_flow_arguments(
83
+ parser: argparse.ArgumentParser, *, flow_help: str, module_help: str
84
+ ) -> None:
85
+ """--flow/--flow-module: each value may be a full ARN, a bare id, a bare
86
+ name (resolved against --instance), or INSTANCE/NAME-OR-ID embedding its
87
+ own instance (id or alias, resolved independently of --instance). Added
88
+ everywhere this addressing is needed, so this is the one place these
89
+ flags are defined. See docs/adr/0008-unified-instance-flow-addressing.md."""
90
+ parser.add_argument(
91
+ "--flow",
92
+ metavar="[INSTANCE/]ID-OR-NAME-OR-ARN",
93
+ action="append",
94
+ default=[],
95
+ help=flow_help,
96
+ )
97
+ parser.add_argument(
98
+ "--flow-module",
99
+ metavar="[INSTANCE/]ID-OR-NAME-OR-ARN",
100
+ action="append",
101
+ default=[],
102
+ help=module_help,
103
+ )
104
+
105
+
106
+ def _add_prettify_arguments(parser: argparse.ArgumentParser) -> None:
107
+ parser.add_argument(
108
+ "targets",
109
+ nargs="*",
110
+ metavar="TARGET",
111
+ help="local flow JSON file(s), or flow/flow-module ARN(s)",
112
+ )
113
+ _add_instance_argument(parser)
114
+ _add_flow_arguments(
115
+ parser,
116
+ flow_help="contact flow to target (repeatable): a bare id, a name (resolved via "
117
+ "ListContactFlows against --instance), a full ARN, or INSTANCE/NAME-OR-ID embedding "
118
+ "its own instance",
119
+ module_help="flow module to target (repeatable): a bare id, a name (resolved via "
120
+ "ListContactFlowModules against --instance), a full ARN, or INSTANCE/NAME-OR-ID "
121
+ "embedding its own instance",
122
+ )
123
+ parser.add_argument(
124
+ "--stage",
125
+ choices=["published", "saved"],
126
+ default=None,
127
+ help="which remote content to read/write (default: published for reads, "
128
+ "saved for writes)",
129
+ )
130
+ parser.add_argument(
131
+ "--in-place",
132
+ action="store_true",
133
+ help="write the result back to its source (an upload, for a remote target)",
134
+ )
135
+ parser.add_argument(
136
+ "--output",
137
+ "-o",
138
+ metavar="FILE",
139
+ default=None,
140
+ help="write the prettified flow to this exact path (single target only)",
141
+ )
142
+ parser.add_argument(
143
+ "--output-dir",
144
+ metavar="DIR",
145
+ default=None,
146
+ help="write each prettified flow to DIR/STEM.json",
147
+ )
148
+ parser.add_argument(
149
+ "--stdout",
150
+ action="store_true",
151
+ help="emit the rewritten JSON to stdout instead of writing anywhere (single target only)",
152
+ )
153
+ parser.add_argument(
154
+ "--check",
155
+ action="store_true",
156
+ help="never write; exit 1 if any target would change (CI / pre-commit gate)",
157
+ )
158
+ parser.add_argument(
159
+ "--dry-run", action="store_true", help="never write; report what would change"
160
+ )
161
+ parser.add_argument(
162
+ "--diff", action="store_true", help="print a unified diff of changes"
163
+ )
164
+ parser.add_argument(
165
+ "--no-backup",
166
+ action="store_true",
167
+ help="skip writing a .bak file (local targets only)",
168
+ )
169
+ parser.add_argument(
170
+ "--render",
171
+ action="store_true",
172
+ help="also write STEM.before.png and STEM.after.png for each target "
173
+ "(next to a local input, or into --render-dir)",
174
+ )
175
+ parser.add_argument(
176
+ "--render-dir",
177
+ metavar="DIR",
178
+ default=None,
179
+ help="directory to write --render PNGs into (required for a remote target)",
180
+ )
181
+ _add_remote_write_arguments(parser)
182
+
183
+
184
+ def _add_remote_write_arguments(parser: argparse.ArgumentParser) -> None:
185
+ """Flags that only make sense alongside a remote write (--in-place on a
186
+ RemoteTarget, or push). Shared between the prettify and push subparsers."""
187
+ parser.add_argument(
188
+ "--publish",
189
+ action="store_true",
190
+ help="write the live content instead of leaving it a saved draft",
191
+ )
192
+ parser.add_argument(
193
+ "--snapshot",
194
+ choices=["before", "after", "both", "none"],
195
+ default="before",
196
+ help="create an immutable version snapshot around the upload (default: before)",
197
+ )
198
+ parser.add_argument(
199
+ "--snapshot-description",
200
+ metavar="TEXT",
201
+ default=None,
202
+ help=f"description for the snapshot (default: connect-flow {__version__}: pre-format snapshot)",
203
+ )
204
+ parser.add_argument(
205
+ "--local-backup",
206
+ metavar="DIR",
207
+ default=None,
208
+ help="write the pre-change content to DIR/STEM.json before uploading - a rollback "
209
+ "copy that doesn't depend on Create*Version, which Connect only supports for "
210
+ "Campaign-type contact flows",
211
+ )
212
+ parser.add_argument(
213
+ "--yes",
214
+ action="store_true",
215
+ help="skip the interactive confirmation before a remote write",
216
+ )
217
+ parser.add_argument(
218
+ "--force",
219
+ action="store_true",
220
+ help="proceed despite a sha conflict or an ARCHIVED resource",
221
+ )
222
+ parser.add_argument(
223
+ "--no-verify",
224
+ action="store_true",
225
+ help="skip the post-upload read-back digest check",
226
+ )
227
+
228
+
229
+ def _add_multi_target_arguments(parser: argparse.ArgumentParser) -> None:
230
+ """TARGET positionals plus the --instance/--flow/--flow-module
231
+ desugaring, shared by lint and digest (same shape as prettify's)."""
232
+ parser.add_argument(
233
+ "targets",
234
+ nargs="*",
235
+ metavar="TARGET",
236
+ help="local flow JSON file(s), or flow/flow-module ARN(s)",
237
+ )
238
+ _add_instance_argument(parser)
239
+ _add_flow_arguments(
240
+ parser,
241
+ flow_help="contact flow (repeatable): a bare id, a name (resolved via "
242
+ "ListContactFlows against --instance), a full ARN, or INSTANCE/NAME-OR-ID embedding "
243
+ "its own instance",
244
+ module_help="flow module (repeatable): a bare id, a name (resolved via "
245
+ "ListContactFlowModules against --instance), a full ARN, or INSTANCE/NAME-OR-ID "
246
+ "embedding its own instance",
247
+ )
248
+
249
+
250
+ def _add_translate_common_arguments(parser: argparse.ArgumentParser) -> None:
251
+ """Flags shared by export/import/normalize: the environment map, the
252
+ discovery bootstrap, and the same destination-flag set prettify uses -
253
+ any of the three can also re-layout via --prettify, reusing prettify()'s
254
+ own engine rather than a second implementation."""
255
+ parser.add_argument(
256
+ "--map",
257
+ metavar="PATH",
258
+ action="append",
259
+ default=[],
260
+ help="environment map file (repeatable); a local path, or (export/import "
261
+ "only) an s3://bucket/key URI",
262
+ )
263
+ parser.add_argument(
264
+ "--emit-map",
265
+ metavar="FILE",
266
+ default=None,
267
+ help="write a map of every literal discovered in TARGET (single target "
268
+ "only) instead of translating it, auto-naming Lambda references from "
269
+ "their ARN; merges into FILE if it already exists, so a loop of "
270
+ "invocations against the same FILE accumulates one shared map",
271
+ )
272
+ parser.add_argument(
273
+ "--prettify",
274
+ action="store_true",
275
+ help="also rewrite block positions into a readable layout",
276
+ )
277
+ parser.add_argument(
278
+ "--in-place",
279
+ action="store_true",
280
+ help="write the result back to its source (an upload, for a remote target)",
281
+ )
282
+ parser.add_argument(
283
+ "--output",
284
+ "-o",
285
+ metavar="FILE",
286
+ default=None,
287
+ help="write the result to this exact path (single target only)",
288
+ )
289
+ parser.add_argument(
290
+ "--output-dir",
291
+ metavar="DIR",
292
+ default=None,
293
+ help="write each result to DIR/STEM.json",
294
+ )
295
+ parser.add_argument(
296
+ "--stdout",
297
+ action="store_true",
298
+ help="emit the rewritten JSON to stdout instead of writing anywhere (single target only)",
299
+ )
300
+ parser.add_argument(
301
+ "--check",
302
+ action="store_true",
303
+ help="never write; exit 1 if any target would change (CI / pre-commit gate)",
304
+ )
305
+ parser.add_argument(
306
+ "--dry-run", action="store_true", help="never write; report what would change"
307
+ )
308
+ parser.add_argument(
309
+ "--diff", action="store_true", help="print a unified diff of changes"
310
+ )
311
+ parser.add_argument(
312
+ "--no-backup",
313
+ action="store_true",
314
+ help="skip writing a .bak file (local targets only)",
315
+ )
316
+
317
+
318
+ def build_connect_flow_parser() -> argparse.ArgumentParser:
319
+ """``connect-flow``'s parser: git-style subcommands, shared output flags,
320
+ long-form-only (no short flags anywhere)."""
321
+ parser = _ArgumentParser(
322
+ prog="connect-flow",
323
+ description="Promote Amazon Connect contact flow JSON between environments and "
324
+ "auto-arrange its layout, without changing behaviour - for local files and live "
325
+ "Connect instances alike.",
326
+ )
327
+ parser.add_argument(
328
+ "--version", action="version", version=f"connect-flow {__version__}"
329
+ )
330
+ # Also accepted before the subcommand, aws-cli style (`connect-flow --profile
331
+ # p --region r list`), in addition to after it. argparse only applies a
332
+ # default when the namespace attribute isn't already set, so whichever
333
+ # position actually supplies a flag wins; the two placements are never in
334
+ # conflict with each other.
335
+ _add_common_output_arguments(parser)
336
+ subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
337
+
338
+ prettify_parser = subparsers.add_parser(
339
+ "prettify",
340
+ help="rewrite block positions into a readable layout (the core verb)",
341
+ )
342
+ _add_common_output_arguments(prettify_parser, suppress_defaults=True)
343
+ _add_prettify_arguments(prettify_parser)
344
+
345
+ copy_parser = subparsers.add_parser(
346
+ "copy",
347
+ help="promote a flow from --from to --to (1:1); a live --to shows the diff "
348
+ "and prompts before writing",
349
+ )
350
+ _add_common_output_arguments(copy_parser, suppress_defaults=True)
351
+ copy_parser.add_argument(
352
+ "--from",
353
+ dest="from_",
354
+ metavar="SOURCE",
355
+ required=True,
356
+ help="a local flow JSON file, '-' (stdin), a full ARN, or INSTANCE/NAME-OR-ID "
357
+ "(see docs/adr/0009-copy-verb.md for the disambiguation rule)",
358
+ )
359
+ copy_parser.add_argument(
360
+ "--to",
361
+ metavar="DEST",
362
+ required=True,
363
+ help="a local flow JSON file, a full ARN, or INSTANCE/NAME-OR-ID; a live --to is "
364
+ "written as a saved draft (add --publish to go live) after showing the diff "
365
+ "and prompting, or not written at all with --output/--stdout",
366
+ )
367
+ copy_parser.add_argument(
368
+ "--kind",
369
+ choices=["flow", "flow-module"],
370
+ default="flow",
371
+ help="resource kind addressed by an INSTANCE/NAME --from/--to value (default: flow)",
372
+ )
373
+ copy_parser.add_argument(
374
+ "--from-map",
375
+ metavar="PATH",
376
+ action="append",
377
+ default=[],
378
+ help="environment map (repeatable) normalising --from's real identifiers to "
379
+ "placeholders; a local path or s3://bucket/key URI",
380
+ )
381
+ copy_parser.add_argument(
382
+ "--to-map",
383
+ metavar="PATH",
384
+ action="append",
385
+ default=[],
386
+ help="environment map (repeatable) resolving placeholders to --to's real "
387
+ "identifiers; a local path or s3://bucket/key URI",
388
+ )
389
+ copy_parser.add_argument(
390
+ "--emit-map",
391
+ metavar="FILE",
392
+ default=None,
393
+ help="write a map of every literal discovered in --from instead of "
394
+ "copying, auto-naming ContactFlow/FlowModule/Queue/Lambda references "
395
+ "(a live --from name lookup, disabled by --no-auto-resolve); merges "
396
+ "into FILE if it already exists, so a loop of invocations against the "
397
+ "same FILE accumulates one shared map",
398
+ )
399
+ copy_parser.add_argument(
400
+ "--prettify",
401
+ action="store_true",
402
+ help="also rewrite block positions into a readable layout",
403
+ )
404
+ copy_parser.add_argument(
405
+ "--stage",
406
+ choices=["published", "saved"],
407
+ default=None,
408
+ help="which remote content to read/write (default: published for reads, "
409
+ "saved for writes)",
410
+ )
411
+ # A live --to is written by default (after a diff and a prompt), so --apply
412
+ # no longer gates anything. Kept as an accepted no-op rather than removed:
413
+ # it shipped in the help text and in ADR 0009's examples, and an unknown
414
+ # flag would abort a script that has it hard-coded, which is a worse
415
+ # outcome than one warning line. See docs/adr/0015-copy-writes-by-default.md.
416
+ copy_parser.add_argument("--apply", action="store_true", help=argparse.SUPPRESS)
417
+ emit_group = copy_parser.add_mutually_exclusive_group()
418
+ emit_group.add_argument(
419
+ "--output",
420
+ metavar="FILE",
421
+ default=None,
422
+ help="resolve against a live --to and write the result to FILE instead of "
423
+ "uploading it",
424
+ )
425
+ emit_group.add_argument(
426
+ "--stdout",
427
+ action="store_true",
428
+ help="resolve against a live --to and print the result instead of uploading it",
429
+ )
430
+ copy_parser.add_argument(
431
+ "--no-backup",
432
+ action="store_true",
433
+ help="skip writing a .bak file (local --to only)",
434
+ )
435
+ copy_parser.add_argument(
436
+ "--no-auto-resolve",
437
+ action="store_true",
438
+ help="don't carry sibling flow/module/queue references across by name "
439
+ "between two live instances; only --from-map/--to-map entries apply "
440
+ "(also disables --emit-map's live name lookup) - see "
441
+ "docs/adr/0013-copy-name-auto-resolution.md",
442
+ )
443
+ copy_parser.add_argument(
444
+ "--exit-code",
445
+ action="store_true",
446
+ help="with --output/--stdout, exit 1 if the destination would change, like "
447
+ "diff(1); without it a successful run exits 0 - see "
448
+ "docs/adr/0014-copy-preview-exit-code.md",
449
+ )
450
+ _add_remote_write_arguments(copy_parser)
451
+
452
+ normalize_parser = subparsers.add_parser(
453
+ "normalize",
454
+ help="offline: local files only, no AWS - translate identifiers via --map, either direction",
455
+ )
456
+ _add_common_output_arguments(normalize_parser, suppress_defaults=True)
457
+ normalize_parser.add_argument(
458
+ "targets", nargs="+", metavar="TARGET", help="local flow JSON file(s)"
459
+ )
460
+ _add_translate_common_arguments(normalize_parser)
461
+ normalize_parser.add_argument(
462
+ "--reverse",
463
+ action="store_true",
464
+ help="resolve placeholders to real values instead of normalising real values to placeholders",
465
+ )
466
+ normalize_parser.add_argument(
467
+ "--force",
468
+ action="store_true",
469
+ help="proceed despite unresolved identifiers (warn instead of erroring)",
470
+ )
471
+
472
+ list_parser = subparsers.add_parser(
473
+ "list", help="list instances, or flows / flow modules within one (read-only)"
474
+ )
475
+ _add_common_output_arguments(list_parser, suppress_defaults=True)
476
+ list_parser.add_argument(
477
+ "--instance",
478
+ metavar="ID-OR-ALIAS",
479
+ default=None,
480
+ help="Connect instance id, or alias resolved via ListInstances (exact match); "
481
+ "omit to list instances instead",
482
+ )
483
+ _add_flow_arguments(
484
+ list_parser,
485
+ flow_help="describe this contact flow instead of listing (repeatable, but only "
486
+ "one flow may resolve in total): a bare id, a name (resolved via ListContactFlows "
487
+ "against --instance), a full ARN, or INSTANCE/NAME-OR-ID embedding its own instance",
488
+ module_help="describe this flow module instead of listing (repeatable, but only "
489
+ "one flow may resolve in total): a bare id, a name (resolved via "
490
+ "ListContactFlowModules against --instance), a full ARN, or INSTANCE/NAME-OR-ID "
491
+ "embedding its own instance",
492
+ )
493
+ kind_group = list_parser.add_mutually_exclusive_group()
494
+ kind_group.add_argument(
495
+ "--flows", action="store_true", help="list contact flows (default)"
496
+ )
497
+ kind_group.add_argument(
498
+ "--flow-modules", action="store_true", help="list flow modules"
499
+ )
500
+ list_parser.add_argument(
501
+ "--state",
502
+ choices=["ACTIVE", "ARCHIVED"],
503
+ default=None,
504
+ help="filter by resource state",
505
+ )
506
+
507
+ diff_parser = subparsers.add_parser(
508
+ "diff", help="semantic diff between two targets (local file and/or live ARN)"
509
+ )
510
+ _add_common_output_arguments(diff_parser, suppress_defaults=True)
511
+ diff_parser.add_argument(
512
+ "target_a",
513
+ metavar="TARGET_A",
514
+ help="a local flow JSON file, a full ARN, or INSTANCE/NAME-OR-ID "
515
+ "(see docs/adr/0009-copy-verb.md for the disambiguation rule)",
516
+ )
517
+ diff_parser.add_argument(
518
+ "target_b",
519
+ metavar="TARGET_B",
520
+ nargs="?",
521
+ default=None,
522
+ help="a local flow JSON file, a full ARN, or INSTANCE/NAME-OR-ID "
523
+ "(omit with --both-stages)",
524
+ )
525
+ # See docs/adr/0010-diff-instance-addressing.md: reuses copy's --kind
526
+ # rather than diff's own --instance/--flow/--flow-module flags.
527
+ diff_parser.add_argument(
528
+ "--kind",
529
+ choices=["flow", "flow-module"],
530
+ default="flow",
531
+ help="resource kind addressed by an INSTANCE/NAME-OR-ID TARGET_A/TARGET_B "
532
+ "value (default: flow)",
533
+ )
534
+ diff_parser.add_argument(
535
+ "--stage",
536
+ choices=["published", "saved"],
537
+ default=None,
538
+ help="which remote content to read for a remote target with no explicit "
539
+ ":$SAVED (or :SAVED) qualifier (default: published)",
540
+ )
541
+ diff_parser.add_argument(
542
+ "--both-stages",
543
+ action="store_true",
544
+ help="diff TARGET_A's saved draft against its published content - TARGET_A "
545
+ "must be a remote ARN, and TARGET_B must be omitted",
546
+ )
547
+ diff_parser.add_argument(
548
+ "--functional",
549
+ action="store_true",
550
+ help="compare only what the flow does at runtime - drop ActionMetadata, its "
551
+ "key order, Annotations and snapToGrid, which the console rewrites on every "
552
+ "save and which therefore differ between two snapshots of a flow nobody "
553
+ "edited; combine with --normalize-opaque to compare across instances",
554
+ )
555
+ diff_parser.add_argument(
556
+ "--normalize-opaque",
557
+ action="store_true",
558
+ help="replace ${...} templatefile placeholders and ARN-shaped literals with "
559
+ "<opaque> before comparing, so a local .tftpl can compare cleanly against live "
560
+ "content; for a remote target, also resolves a referenced ContactFlowId/"
561
+ "FlowModuleId/QueueId to its Name so the same sibling resource in a different "
562
+ "instance doesn't show as a diff - hides genuine wrong-reference changes, so "
563
+ "it's off by default",
564
+ )
565
+
566
+ lint_parser = subparsers.add_parser(
567
+ "lint",
568
+ help="structural checks: dangling transitions, unreachable actions, empty branches",
569
+ )
570
+ _add_common_output_arguments(lint_parser, suppress_defaults=True)
571
+ _add_multi_target_arguments(lint_parser)
572
+ lint_parser.add_argument(
573
+ "--stage",
574
+ choices=["published", "saved"],
575
+ default=None,
576
+ help="which remote content to check (default: published)",
577
+ )
578
+
579
+ digest_parser = subparsers.add_parser(
580
+ "digest", help="print each target's semantic digest"
581
+ )
582
+ _add_common_output_arguments(digest_parser, suppress_defaults=True)
583
+ _add_multi_target_arguments(digest_parser)
584
+ digest_parser.add_argument(
585
+ "--stage",
586
+ choices=["published", "saved"],
587
+ default=None,
588
+ help="which remote content to digest (default: published)",
589
+ )
590
+
591
+ return parser
592
+
593
+
594
+ _DESTINATION_FLAGS = ("in_place", "output", "output_dir", "stdout", "check", "dry_run")
595
+
596
+
597
+ def _validate_destination_args(
598
+ args: argparse.Namespace, target_count: int, *, allow_default_stdout: bool = False
599
+ ) -> str | None:
600
+ """Returns an error message, or None if args are fine. Shared by every
601
+ subcommand that writes somewhere (prettify, export, import, normalize) -
602
+ they all use the same mutually-exclusive destination flag set.
603
+
604
+ ``target_count`` is the *resolved* target count (positionals plus any
605
+ --instance/--flow desugaring), not len(args.targets) - a TARGET can
606
+ be entirely absent when --instance/--flow supplies it instead, and
607
+ checking the raw positional list would wrongly reject that combination
608
+ with --stdout/--output.
609
+
610
+ ``allow_default_stdout``: for prettify/export/normalize, no destination
611
+ flag at all with exactly one target is treated as ``--stdout`` rather
612
+ than an error - their result is inspected as often as written. This also
613
+ implies ``--quiet``, since the point is the content itself, not a report
614
+ about it; an explicit ``--stdout`` is unaffected and still reports
615
+ normally unless ``--quiet`` is passed too. ``import`` always writes
616
+ something real (a local destination or ``--to`` a live ARN), so it keeps
617
+ requiring an explicit destination flag. See
618
+ docs/adr/0005-default-to-stdout.md."""
619
+ given = [name for name in _DESTINATION_FLAGS if getattr(args, name)]
620
+ if not given:
621
+ if allow_default_stdout and target_count == 1:
622
+ args.stdout = True
623
+ args.quiet = (
624
+ True # implicit stdout: the content itself is the point, not a report
625
+ )
626
+ return None
627
+ return (
628
+ "a destination is required: one of --in-place, --output FILE, "
629
+ "--output-dir DIR, --stdout, --check or --dry-run"
630
+ )
631
+ if len(given) > 1:
632
+ readable = ", ".join(f"--{n.replace('_', '-')}" for n in given)
633
+ return f"{readable} are mutually exclusive - pass exactly one destination"
634
+ if args.stdout and target_count != 1:
635
+ return "--stdout requires exactly one TARGET"
636
+ if args.output and target_count != 1:
637
+ return "--output FILE requires exactly one TARGET - use --output-dir DIR for more than one"
638
+ return None
639
+
640
+
641
+ def _validate_prettify_args(args: argparse.Namespace, target_count: int) -> str | None:
642
+ error = _validate_destination_args(args, target_count, allow_default_stdout=True)
643
+ if error is not None:
644
+ return error
645
+ if args.render_dir is not None and not args.render:
646
+ return "--render-dir requires --render"
647
+ return None