hlsync 1.16.3__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.

Potentially problematic release.


This version of hlsync might be problematic. Click here for more details.

hlsync/cli.py ADDED
@@ -0,0 +1,2879 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import shlex
6
+ import shutil
7
+ import sys
8
+ import time
9
+ from collections.abc import Callable, Sequence
10
+ from dataclasses import dataclass
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import TextIO, TypeVar
13
+
14
+ from hlsync import __version__
15
+ from hlsync.comparison import (
16
+ ComparisonEntry,
17
+ ComparisonPlan,
18
+ build_comparison,
19
+ mark_untraversed_directories,
20
+ )
21
+ from hlsync.config import (
22
+ DEFAULT_PASSWORD_ENV,
23
+ DEFAULT_USERNAME_ENV,
24
+ ApplicationConfiguration,
25
+ ConfigurationError,
26
+ ConfigurationStore,
27
+ GlobalRuleStore,
28
+ ProfileConfiguration,
29
+ RuleUpdate,
30
+ canonical_local_root,
31
+ validate_profile_name,
32
+ )
33
+ from hlsync.gitignore import GitIgnores
34
+ from hlsync.pattern_operands import (
35
+ PatternOperandError,
36
+ add_pattern_operands,
37
+ normalize_pattern_operands,
38
+ )
39
+ from hlsync.progress import DirectoryReadProgress
40
+ from hlsync.rules import (
41
+ RuleError,
42
+ RuleSet,
43
+ SyncRule,
44
+ expand_path_operands,
45
+ patterns_from_global_operands,
46
+ patterns_from_operands,
47
+ patterns_from_remote_operands,
48
+ )
49
+ from hlsync.selection import (
50
+ DirectoryContentsSelection,
51
+ FileSelection,
52
+ FileSelector,
53
+ FileSelectorSet,
54
+ SelectionError,
55
+ )
56
+ from hlsync.snapshot import (
57
+ SnapshotError,
58
+ TreeEntry,
59
+ TreeSnapshot,
60
+ list_local_directory,
61
+ snapshot_local,
62
+ )
63
+ from hlsync.transfer import (
64
+ TransferError,
65
+ TransferIssue,
66
+ TransferOperation,
67
+ TransferResult,
68
+ execute_transfer,
69
+ )
70
+ from hlsync.transport import ExplicitFTPSTransport, PathOperationError, TransportError
71
+
72
+ _EntryT = TypeVar("_EntryT")
73
+
74
+ _RESET = "\033[0m"
75
+ _DIRECTORY_COLOR = "\033[38;5;75m"
76
+ _EXCLUDED_DIRECTORY_COLOR = "\033[38;5;24m"
77
+ _COLLAPSED_DIRECTORY_COLOR = "\033[3;38;5;24m"
78
+ _DIFF_MARKER_COLORS = {
79
+ "+": "\033[38;5;82m",
80
+ "~": "\033[33m",
81
+ "-": "\033[31m",
82
+ "?": "\033[35m",
83
+ "x": "\033[90m",
84
+ "#": "\033[90m",
85
+ "r": "\033[38;5;30m",
86
+ "l": "\033[38;5;51m",
87
+ }
88
+ _TRANSFER_MARKERS = {
89
+ "add": "+",
90
+ "update": "~",
91
+ "delete": "-",
92
+ "create": "+",
93
+ }
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class _DiffTraversalRoot:
98
+ path: PurePosixPath
99
+ include_container: bool = False
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class _PendingDiffDirectory:
104
+ path: PurePosixPath
105
+ has_local: bool
106
+ has_remote: bool
107
+ include_container: bool
108
+ is_root: bool
109
+ display_root: PurePosixPath | None
110
+ display_anchor: bool
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class _PendingDiffOutput:
115
+ entry: ComparisonEntry
116
+ collapsed_paths: frozenset[str]
117
+ display_root: PurePosixPath | None
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class _DisplayedRule:
122
+ rule: SyncRule
123
+ global_scope: bool
124
+
125
+ @property
126
+ def identifier(self) -> str:
127
+ return _format_rule_id(self.rule, global_scope=self.global_scope)
128
+
129
+
130
+ CANONICAL_COMMANDS = (
131
+ "create",
132
+ "connect",
133
+ "map",
134
+ "remove",
135
+ "root",
136
+ "profile",
137
+ "profiles",
138
+ "list",
139
+ "diff",
140
+ "push",
141
+ "pull",
142
+ "rules",
143
+ "exclude",
144
+ "include",
145
+ "help",
146
+ "version",
147
+ )
148
+ COMMAND_ALIASES = {"ls": "list", "lsr": "list"}
149
+ PROFILE_AWARE_COMMANDS = frozenset(
150
+ {
151
+ "connect",
152
+ "map",
153
+ "remove",
154
+ "root",
155
+ "profile",
156
+ "list",
157
+ "diff",
158
+ "push",
159
+ "pull",
160
+ "rules",
161
+ "exclude",
162
+ "include",
163
+ }
164
+ )
165
+
166
+
167
+ def _add_included_only_argument(parser: argparse.ArgumentParser) -> None:
168
+ parser.add_argument(
169
+ "-i",
170
+ "--inc",
171
+ "--included-only",
172
+ dest="included_only",
173
+ action="store_true",
174
+ help="show only included paths",
175
+ )
176
+
177
+
178
+ def _active_options(arguments: argparse.Namespace) -> str | None:
179
+ options: list[str] = []
180
+ if getattr(arguments, "remote", False):
181
+ options.append("remote (--remote)")
182
+ if getattr(arguments, "pull", False):
183
+ options.append("pull perspective (--pull)")
184
+ if getattr(arguments, "recursive", False):
185
+ options.append("recursive (-r)")
186
+ if getattr(arguments, "shallow", False):
187
+ options.append("shallow (-s)")
188
+ if getattr(arguments, "show_all", False):
189
+ options.append("all entries (-a)")
190
+ if getattr(arguments, "included_only", False):
191
+ options.append("included paths only (-i)")
192
+ if getattr(arguments, "show_excluded", False):
193
+ options.append("excluded paths (-x)")
194
+ if getattr(arguments, "prune", False):
195
+ options.append("prune remote-only paths (-p)")
196
+ if getattr(arguments, "force", False):
197
+ options.append("overwrite newer destinations (--force)")
198
+ if getattr(arguments, "dry", False):
199
+ options.append("dry run (--dry)")
200
+ if getattr(arguments, "paged", False):
201
+ options.append("paged output (--paged)")
202
+ if getattr(arguments, "resume", None) is not None:
203
+ options.append(f"resume at {arguments.resume!r} (--resume)")
204
+ if not options:
205
+ return None
206
+ return f"Options: {'; '.join(options)}."
207
+
208
+
209
+ def _add_recursive_depth_arguments(parser: argparse.ArgumentParser) -> None:
210
+ depth = parser.add_mutually_exclusive_group()
211
+ depth.add_argument(
212
+ "-r", "--recursive", action="store_true",
213
+ help="recurse into selected directories (default)",
214
+ )
215
+ depth.add_argument(
216
+ "-s", "--shallow", action="store_true",
217
+ help="process immediate contents without entering child directories",
218
+ )
219
+
220
+
221
+ def _resolve_command_name(
222
+ value: str,
223
+ *,
224
+ stdin: TextIO | None = None,
225
+ stdout: TextIO | None = None,
226
+ ) -> str:
227
+ if value in CANONICAL_COMMANDS:
228
+ return value
229
+ if value in COMMAND_ALIASES:
230
+ return COMMAND_ALIASES[value]
231
+ matches = tuple(
232
+ command for command in CANONICAL_COMMANDS if command.startswith(value)
233
+ )
234
+ if not matches:
235
+ raise ConfigurationError(f"unknown command '{value}'")
236
+ if len(matches) > 1:
237
+ is_terminal = getattr(stdin, "isatty", None)
238
+ if stdin is not None and stdout is not None and is_terminal and is_terminal():
239
+ print(f"'{value}' matches multiple commands:\n", file=stdout)
240
+ for index, command in enumerate(matches, start=1):
241
+ print(f" {index}. {command}", file=stdout)
242
+ while True:
243
+ print(
244
+ f"\nChoose a command [1-{len(matches)}]: ",
245
+ end="",
246
+ file=stdout,
247
+ flush=True,
248
+ )
249
+ answer = stdin.readline()
250
+ if answer == "":
251
+ raise ConfigurationError("command selection cancelled")
252
+ try:
253
+ selection = int(answer.strip())
254
+ except ValueError:
255
+ selection = 0
256
+ if 1 <= selection <= len(matches):
257
+ return matches[selection - 1]
258
+ print("Enter one of the listed numbers.", file=stdout)
259
+ raise ConfigurationError(
260
+ f"ambiguous command '{value}': {', '.join(matches)}"
261
+ )
262
+ return matches[0]
263
+
264
+
265
+ def _could_be_command(value: str) -> bool:
266
+ return value in COMMAND_ALIASES or any(
267
+ command.startswith(value) for command in CANONICAL_COMMANDS
268
+ )
269
+
270
+
271
+ def _resolve_invocation(
272
+ raw_arguments: list[str],
273
+ *,
274
+ stdin: TextIO,
275
+ stdout: TextIO,
276
+ ) -> tuple[list[str], str | None]:
277
+ if not raw_arguments or raw_arguments[0].startswith("-"):
278
+ return raw_arguments, None
279
+ first = raw_arguments[0]
280
+ if (
281
+ first not in CANONICAL_COMMANDS
282
+ and first not in COMMAND_ALIASES
283
+ and len(raw_arguments) > 1
284
+ and not raw_arguments[1].startswith("-")
285
+ and _could_be_command(raw_arguments[1])
286
+ ):
287
+ profile_name = validate_profile_name(first)
288
+ raw_arguments = raw_arguments[1:]
289
+ remote_list_alias = raw_arguments[0] == "lsr"
290
+ raw_arguments[0] = _resolve_command_name(
291
+ raw_arguments[0], stdin=stdin, stdout=stdout
292
+ )
293
+ if remote_list_alias:
294
+ raw_arguments.insert(1, "--remote")
295
+ return raw_arguments, profile_name
296
+ remote_list_alias = first == "lsr"
297
+ raw_arguments[0] = _resolve_command_name(first, stdin=stdin, stdout=stdout)
298
+ if remote_list_alias:
299
+ raw_arguments.insert(1, "--remote")
300
+ return raw_arguments, None
301
+
302
+
303
+ def _validate_profile_prefix(arguments: argparse.Namespace) -> None:
304
+ profile_name = getattr(arguments, "profile_prefix", None)
305
+ if profile_name is not None and getattr(arguments, "global_rules", False):
306
+ raise ConfigurationError("a profile prefix cannot be combined with --global")
307
+ if profile_name is not None and arguments.command not in PROFILE_AWARE_COMMANDS:
308
+ raise ConfigurationError(
309
+ f"profile prefix '{profile_name}' cannot be used with "
310
+ f"'{arguments.command}'"
311
+ )
312
+
313
+
314
+ def build_parser() -> argparse.ArgumentParser:
315
+ parser = argparse.ArgumentParser(
316
+ prog="hlsync",
317
+ description=(
318
+ "Transfer mapped files over explicit FTP over TLS. Run commands "
319
+ "inside a mapped profile; prefix a command with a profile only for "
320
+ "a one-command override. Commands may be shortened to any unique "
321
+ "prefix."
322
+ ),
323
+ )
324
+ parser.add_argument("-v", "--version", action="version", version=__version__)
325
+ parser.add_argument(
326
+ "--legend",
327
+ action="store_true",
328
+ help="show diff symbols and exit",
329
+ )
330
+ subparsers = parser.add_subparsers(dest="command")
331
+
332
+ create_parser = subparsers.add_parser(
333
+ "create",
334
+ help="create an FTPS profile with a mapped local root",
335
+ description=(
336
+ "Create an FTPS profile using environment-variable credentials. "
337
+ "Without --local-root, choose the current or another directory."
338
+ ),
339
+ )
340
+ create_parser.add_argument("profile_name", help="new profile name")
341
+ create_parser.add_argument("--host", required=True, help="FTPS server host")
342
+ create_parser.add_argument(
343
+ "--remote-root", required=True, help="absolute server directory"
344
+ )
345
+ create_parser.add_argument(
346
+ "--protocol",
347
+ choices=("ftps",),
348
+ default="ftps",
349
+ help="transfer protocol (default: ftps)",
350
+ )
351
+ create_parser.add_argument(
352
+ "--port", type=int, default=21, help="server port (default: 21)"
353
+ )
354
+ create_parser.add_argument(
355
+ "--username-env",
356
+ help=f"username environment variable (default: {DEFAULT_USERNAME_ENV})",
357
+ )
358
+ create_parser.add_argument(
359
+ "--password-env",
360
+ help=f"password environment variable (default: {DEFAULT_PASSWORD_ENV})",
361
+ )
362
+ create_parser.add_argument(
363
+ "--local-root",
364
+ metavar="PATH",
365
+ help=(
366
+ "local profile directory; when omitted, offer the current "
367
+ "directory and prompt for another if declined"
368
+ ),
369
+ )
370
+
371
+ connect_parser = subparsers.add_parser(
372
+ "connect",
373
+ help="verify a profile's FTPS connection",
374
+ usage="hlsync connect [PROFILE]\n hlsync PROFILE connect",
375
+ description=(
376
+ "Check secure FTPS connectivity, then disconnect. Infer the profile "
377
+ "from the current directory unless named."
378
+ ),
379
+ )
380
+ connect_parser.add_argument(
381
+ "profile_name", nargs="?", help="profile; inferred when omitted"
382
+ )
383
+
384
+ map_parser = subparsers.add_parser(
385
+ "map",
386
+ help="change a profile's local or remote root",
387
+ usage=(
388
+ "hlsync map [PROFILE] [--local-root PATH] [--remote-root PATH]\n"
389
+ " hlsync PROFILE map [--local-root PATH] [--remote-root PATH]"
390
+ ),
391
+ description=(
392
+ "Change mapped roots after a [y/N] confirmation. Omitted roots stay "
393
+ "unchanged; with neither option, use the current local directory."
394
+ ),
395
+ )
396
+ map_parser.add_argument(
397
+ "profile_name", nargs="?", help="profile; inferred when omitted"
398
+ )
399
+ map_parser.add_argument(
400
+ "--local-root",
401
+ metavar="PATH",
402
+ help="new local root; defaults to the current directory",
403
+ )
404
+ map_parser.add_argument(
405
+ "--remote-root",
406
+ metavar="PATH",
407
+ help="new absolute remote root; omitted leaves it unchanged",
408
+ )
409
+
410
+ rule_change_help = {
411
+ "exclude": "exclude paths from synchronization",
412
+ "include": "include paths, overriding broader exclusions",
413
+ }
414
+ for command in ("exclude", "include"):
415
+ rule_parser = subparsers.add_parser(
416
+ command,
417
+ help=rule_change_help[command],
418
+ usage=(
419
+ f"hlsync [PROFILE] {command} [--remote] "
420
+ "[--pattern | --anywhere] PATH ...\n"
421
+ f" hlsync {command} -g [--remote] "
422
+ "[--pattern | --anywhere] PATH ..."
423
+ ),
424
+ description=(
425
+ f"Record {command} rules for the current profile, or for every "
426
+ "profile with -g. List rules with 'hlsync rules'."
427
+ ),
428
+ )
429
+ add_pattern_operands(
430
+ rule_parser,
431
+ required=True,
432
+ metavar="PATH",
433
+ help_text="paths, wildcards, or comma-separated groups",
434
+ )
435
+ rule_parser.add_argument(
436
+ "--remote",
437
+ action="store_true",
438
+ help="apply to protected remote paths",
439
+ )
440
+ rule_matching = rule_parser.add_mutually_exclusive_group()
441
+ rule_matching.add_argument(
442
+ "--pattern",
443
+ action="store_true",
444
+ help="record operands as reusable wildcard patterns",
445
+ )
446
+ rule_matching.add_argument(
447
+ "--anywhere",
448
+ action="store_true",
449
+ help=(
450
+ "match names at any depth below the current directory; "
451
+ "-g: every profile"
452
+ ),
453
+ )
454
+ rule_parser.add_argument(
455
+ "-g",
456
+ "--global",
457
+ dest="global_rules",
458
+ action="store_true",
459
+ help="record a rule shared by every profile",
460
+ )
461
+
462
+ rules_parser = subparsers.add_parser(
463
+ "rules",
464
+ help="list or remove synchronization rules",
465
+ usage=(
466
+ "hlsync [PROFILE] rules\n"
467
+ " hlsync [PROFILE] rules rm RULE_ID\n"
468
+ " hlsync rules -g [rm GLOBAL_RULE_ID]"
469
+ ),
470
+ description=(
471
+ "List rules, or remove one by its displayed ID with rm. Use -g for "
472
+ "global rules. Add rules with 'hlsync exclude' and 'hlsync include'."
473
+ ),
474
+ )
475
+ rules_parser.add_argument(
476
+ "operation",
477
+ nargs="?",
478
+ choices=("rm",),
479
+ metavar="rm",
480
+ help="remove the rule with the displayed RULE_ID",
481
+ )
482
+ rules_parser.add_argument("rule_id", nargs="?", metavar="RULE_ID")
483
+ rules_parser.add_argument(
484
+ "-g",
485
+ "--global",
486
+ dest="global_rules",
487
+ action="store_true",
488
+ help="manage rules shared by every profile",
489
+ )
490
+ remove_parser = subparsers.add_parser(
491
+ "remove",
492
+ help="remove a profile",
493
+ usage="hlsync remove [PROFILE]\n hlsync PROFILE remove",
494
+ description=(
495
+ "Remove local profile configuration only; remote files stay "
496
+ "unchanged. Infer the profile when omitted."
497
+ ),
498
+ )
499
+ remove_parser.add_argument(
500
+ "profile_name", nargs="?", help="profile; inferred when omitted"
501
+ )
502
+
503
+ root_parser = subparsers.add_parser(
504
+ "root",
505
+ help="print a profile's mapped local root",
506
+ usage="hlsync root [PROFILE]\n hlsync PROFILE root",
507
+ description=(
508
+ "Print only the canonical local root, suitable for "
509
+ "cd \"$(hlsync root PROFILE)\"."
510
+ ),
511
+ )
512
+ root_parser.add_argument(
513
+ "profile_name",
514
+ nargs="?",
515
+ metavar="PROFILE",
516
+ help="profile name; inferred from the current directory when omitted",
517
+ )
518
+
519
+ profile_parser = subparsers.add_parser(
520
+ "profile",
521
+ help="show the current profile or detailed profile information",
522
+ usage=(
523
+ "hlsync profile [PROFILE] [--details]\n"
524
+ " hlsync PROFILE profile [--details]"
525
+ ),
526
+ description=(
527
+ "Print the inferred or named profile. --details adds its "
528
+ "connection, mapping, credentials, and rules."
529
+ ),
530
+ )
531
+ profile_parser.add_argument(
532
+ "profile_name",
533
+ nargs="?",
534
+ metavar="PROFILE",
535
+ help="profile name; inferred from the current directory when omitted",
536
+ )
537
+ profile_parser.add_argument(
538
+ "--details",
539
+ dest="profile_details",
540
+ action="store_true",
541
+ help="show endpoint, mapping, credentials, and rule count",
542
+ )
543
+
544
+ subparsers.add_parser(
545
+ "profiles",
546
+ help="list configured profiles",
547
+ description="List profiles; * marks the one containing this directory.",
548
+ )
549
+
550
+ list_parser = subparsers.add_parser(
551
+ "list",
552
+ aliases=("lsr",),
553
+ help="list the current local or remote directory",
554
+ usage=(
555
+ "hlsync [PROFILE] list [--remote] [-r] [-i] [PATH ...]\n"
556
+ " hlsync [PROFILE] lsr [-r] [-i] [PATH ...]"
557
+ ),
558
+ description=(
559
+ "With no PATH, list the current directory one level. A directory "
560
+ "PATH is also shallow unless -r. Use --remote/lsr for remote and "
561
+ "-i to hide exclusions."
562
+ ),
563
+ )
564
+ add_pattern_operands(
565
+ list_parser,
566
+ required=False,
567
+ metavar="PATH",
568
+ help_text=(
569
+ "relative paths or wildcards; default: current directory"
570
+ ),
571
+ )
572
+ list_parser.add_argument(
573
+ "--remote",
574
+ action="store_true",
575
+ help="list the mapped remote directory over FTPS",
576
+ )
577
+ list_parser.add_argument(
578
+ "-r",
579
+ "--recursive",
580
+ action="store_true",
581
+ help="include descendants of the selected directories",
582
+ )
583
+ _add_included_only_argument(list_parser)
584
+
585
+ diff_parser = subparsers.add_parser(
586
+ "diff",
587
+ help="preview file changes without modifying anything",
588
+ usage=(
589
+ "hlsync [PROFILE] diff [PATH ...]\n"
590
+ " [--pull | --prune] [-r | -s] [-a] [-i] [--paged]\n"
591
+ " [--resume DIRECTORY]"
592
+ ),
593
+ description=(
594
+ "With no PATH, preview the current subtree recursively. Directory "
595
+ "PATHs are also recursive; use -s for immediate contents only. "
596
+ "Use --pull to reverse direction, "
597
+ "-i to hide exclusions, and -a to show everything."
598
+ ),
599
+ )
600
+ add_pattern_operands(
601
+ diff_parser,
602
+ required=False,
603
+ metavar="PATH",
604
+ help_text=(
605
+ "relative paths or wildcards; default: current directory"
606
+ ),
607
+ )
608
+ diff_direction = diff_parser.add_mutually_exclusive_group()
609
+ diff_direction.add_argument(
610
+ "--pull",
611
+ action="store_true",
612
+ help="show changes from the remote perspective without remote deletion",
613
+ )
614
+ _add_recursive_depth_arguments(diff_parser)
615
+ diff_parser.add_argument(
616
+ "-x",
617
+ "--excluded",
618
+ dest="show_excluded",
619
+ action="store_true",
620
+ help="also show excluded paths",
621
+ )
622
+ diff_parser.add_argument(
623
+ "-a",
624
+ "--all",
625
+ dest="show_all",
626
+ action="store_true",
627
+ help="show every compared path, including unchanged and kept ones",
628
+ )
629
+ diff_parser.add_argument(
630
+ "--paged",
631
+ action="store_true",
632
+ help="show one directory, then exit with a resume command",
633
+ )
634
+ diff_parser.add_argument(
635
+ "--resume",
636
+ metavar="DIRECTORY",
637
+ help="resume a paged diff at a profile-relative directory",
638
+ )
639
+ diff_direction.add_argument(
640
+ "-p",
641
+ "--prune",
642
+ action="store_true",
643
+ help="show remote-only paths as deleted, as push --prune would",
644
+ )
645
+ transfer_help = {
646
+ "push": "upload local changes to the remote profile",
647
+ "pull": "replace changed local files from the remote profile",
648
+ }
649
+ transfer_description = {
650
+ "push": (
651
+ "Push local changes. With no PATH, push the current subtree "
652
+ "recursively. Directory PATHs are also recursive; use -s for "
653
+ "immediate contents only. Remote-only paths are kept unless "
654
+ "-p is given, and a remote file is replaced only when the local "
655
+ "copy is newer unless --force is given. Use --dry to "
656
+ "preview the exact push without changing either side."
657
+ ),
658
+ "pull": (
659
+ "Pull requires a PATH. A directory PATH is shallow unless -r; "
660
+ "missing local paths stay missing. A local file is replaced only "
661
+ "when the remote copy is newer unless --force is given."
662
+ ),
663
+ }
664
+ for command in ("push", "pull"):
665
+ transfer_parser = subparsers.add_parser(
666
+ command,
667
+ help=transfer_help[command],
668
+ description=transfer_description[command],
669
+ usage=(
670
+ (
671
+ "hlsync [PROFILE] pull PATH [PATH ...] [-r] [--force]"
672
+ if command == "pull"
673
+ else "hlsync [PROFILE] push [PATH ...] [-r | -s]"
674
+ )
675
+ + (" [-p] [--force] [--dry]" if command == "push" else "")
676
+ ),
677
+ )
678
+ add_pattern_operands(
679
+ transfer_parser,
680
+ required=command == "pull",
681
+ metavar="PATH",
682
+ help_text=(
683
+ "relative paths or wildcards; default: current subtree"
684
+ if command == "push"
685
+ else "relative paths or wildcards"
686
+ ),
687
+ )
688
+ if command == "push":
689
+ _add_recursive_depth_arguments(transfer_parser)
690
+ transfer_parser.add_argument(
691
+ "-p",
692
+ "--prune",
693
+ action="store_true",
694
+ help="delete selected remote-only paths",
695
+ )
696
+ transfer_parser.add_argument(
697
+ "--dry",
698
+ action="store_true",
699
+ help="show the exact push without changing either side",
700
+ )
701
+ else:
702
+ transfer_parser.add_argument(
703
+ "-r", "--recursive", action="store_true",
704
+ help="recurse into selected directories",
705
+ )
706
+ transfer_parser.add_argument(
707
+ "--force",
708
+ action="store_true",
709
+ help="overwrite destination files that are not older than the source",
710
+ )
711
+
712
+ help_parser = subparsers.add_parser("help", help="show command help")
713
+ help_parser.add_argument("topic", nargs="?")
714
+
715
+ subparsers.add_parser("version", help="show the installed version")
716
+ return parser
717
+
718
+
719
+ def _confirm(
720
+ prompt: str,
721
+ stdin: TextIO,
722
+ stdout: TextIO,
723
+ *,
724
+ default: bool,
725
+ ) -> bool:
726
+ while True:
727
+ print(prompt, end="", file=stdout, flush=True)
728
+ answer = stdin.readline()
729
+ if answer == "":
730
+ raise ConfigurationError("confirmation requires yes or no")
731
+ normalized = answer.strip().lower()
732
+ if normalized in {"y", "yes"}:
733
+ return True
734
+ if normalized in {"n", "no"}:
735
+ return False
736
+ if normalized == "":
737
+ return default
738
+ print("Please answer yes or no.", file=stdout)
739
+
740
+
741
+ def _create_profile(
742
+ arguments: argparse.Namespace,
743
+ store: ConfigurationStore,
744
+ stdin: TextIO,
745
+ stdout: TextIO,
746
+ ) -> str:
747
+ name = validate_profile_name(arguments.profile_name)
748
+ configuration = store.load()
749
+ if name in configuration.profiles:
750
+ raise ConfigurationError(f"profile '{name}' already exists")
751
+ profile = ProfileConfiguration(
752
+ type=arguments.protocol,
753
+ host=arguments.host,
754
+ remote_root=arguments.remote_root,
755
+ port=arguments.port,
756
+ username_env=(
757
+ arguments.username_env
758
+ if arguments.username_env is not None
759
+ else DEFAULT_USERNAME_ENV
760
+ ),
761
+ password_env=(
762
+ arguments.password_env
763
+ if arguments.password_env is not None
764
+ else DEFAULT_PASSWORD_ENV
765
+ ),
766
+ )
767
+ configuration.profiles[name] = profile
768
+ if arguments.local_root is not None:
769
+ local_root = canonical_local_root(arguments.local_root)
770
+ else:
771
+ current_root = canonical_local_root(Path.cwd())
772
+ prompt = (
773
+ f"Map current directory '{current_root}' to "
774
+ f"'{name}:{profile.remote_root}'? [Y/n] "
775
+ )
776
+ if _confirm(prompt, stdin, stdout, default=True):
777
+ local_root = current_root
778
+ else:
779
+ print(
780
+ "What local folder should be mapped instead? ",
781
+ end="",
782
+ file=stdout,
783
+ flush=True,
784
+ )
785
+ supplied_root = stdin.readline()
786
+ if supplied_root == "" or not supplied_root.strip():
787
+ raise ConfigurationError("a local folder is required")
788
+ local_root = canonical_local_root(supplied_root.strip())
789
+ configuration.set_profile_roots(
790
+ name,
791
+ local_root=local_root,
792
+ remote_root=profile.remote_root,
793
+ )
794
+ message = (
795
+ f"Created FTPS profile '{name}'.\n"
796
+ f"Mapped '{local_root}' to '{name}:{profile.remote_root}'."
797
+ )
798
+ _global_rule_store(store).load()
799
+ store.save(configuration)
800
+ return message
801
+
802
+
803
+ def _global_rule_store(store: ConfigurationStore) -> GlobalRuleStore:
804
+ return GlobalRuleStore(store.path.with_name("rules.json"))
805
+
806
+
807
+ def _effective_rules(
808
+ store: ConfigurationStore,
809
+ profile: ProfileConfiguration,
810
+ ) -> RuleSet:
811
+ global_rules = _global_rule_store(store).load().rules
812
+ return RuleSet.layered(
813
+ global_rules,
814
+ profile.rules,
815
+ gitignores=GitIgnores(Path(profile.local_root)) if profile.local_root else None,
816
+ )
817
+
818
+
819
+ def _resolve_profile(
820
+ arguments: argparse.Namespace, store: ConfigurationStore
821
+ ) -> tuple[ApplicationConfiguration, str, ProfileConfiguration]:
822
+ configuration = store.load()
823
+ prefix_name = getattr(arguments, "profile_prefix", None)
824
+ positional_name = getattr(arguments, "profile_name", None)
825
+ if prefix_name is not None and positional_name is not None:
826
+ raise ConfigurationError(
827
+ "profile was specified both before the command and as an argument"
828
+ )
829
+ supplied_name = prefix_name or positional_name
830
+ if supplied_name is None:
831
+ active = configuration.profile_for_path(Path.cwd().resolve(strict=True))
832
+ if active is None:
833
+ raise ConfigurationError(
834
+ "current directory is not inside a mapped profile; prefix the "
835
+ "command with a profile (hlsync PROFILE COMMAND)"
836
+ )
837
+ name, profile = active
838
+ return configuration, name, profile
839
+ name = validate_profile_name(supplied_name)
840
+ if name not in configuration.profiles:
841
+ raise ConfigurationError(f"profile '{name}' does not exist")
842
+ return configuration, name, configuration.profiles[name]
843
+
844
+
845
+ def _effective_current_directory(
846
+ arguments: argparse.Namespace,
847
+ profile_root: Path,
848
+ ) -> Path:
849
+ if getattr(arguments, "profile_prefix", None) is not None:
850
+ return profile_root.resolve(strict=True)
851
+ return Path.cwd().resolve(strict=True)
852
+
853
+
854
+ def _connect(arguments: argparse.Namespace, store: ConfigurationStore) -> str:
855
+ _, name, profile = _resolve_profile(arguments, store)
856
+ with ExplicitFTPSTransport(profile):
857
+ pass
858
+ return f"Verified secure connectivity to profile '{name}'."
859
+
860
+
861
+ def _map(
862
+ arguments: argparse.Namespace,
863
+ store: ConfigurationStore,
864
+ stdin: TextIO,
865
+ stdout: TextIO,
866
+ ) -> str:
867
+ configuration, name, profile = _resolve_profile(arguments, store)
868
+ if arguments.local_root is not None:
869
+ local_root = canonical_local_root(arguments.local_root)
870
+ elif arguments.remote_root is None or profile.local_root is None:
871
+ local_root = canonical_local_root(Path.cwd())
872
+ else:
873
+ local_root = profile.local_root
874
+ remote_root = (
875
+ arguments.remote_root
876
+ if arguments.remote_root is not None
877
+ else profile.remote_root
878
+ )
879
+ candidate = profile.with_roots(
880
+ local_root=local_root,
881
+ remote_root=remote_root,
882
+ )
883
+ changes = []
884
+ if profile.local_root != candidate.local_root:
885
+ changes.append(
886
+ f" Local root: {profile.local_root or 'not mapped'} "
887
+ f"→ {candidate.local_root}"
888
+ )
889
+ if profile.remote_root != candidate.remote_root:
890
+ changes.append(
891
+ f" Remote root: {profile.remote_root} → {candidate.remote_root}"
892
+ )
893
+ if not changes:
894
+ return f"Profile '{name}' mapping is unchanged."
895
+
896
+ configuration.set_profile_roots(
897
+ name,
898
+ local_root=candidate.local_root,
899
+ remote_root=candidate.remote_root,
900
+ )
901
+ prompt = "\n".join(
902
+ (f"Change mapping for profile '{name}'?", *changes, "Continue? [y/N] ")
903
+ )
904
+ if not _confirm(prompt, stdin, stdout, default=False):
905
+ return f"Kept profile '{name}' mapping unchanged."
906
+ store.save(configuration)
907
+ return "\n".join((f"Updated profile '{name}' mapping:", *changes))
908
+
909
+
910
+ def _format_rules(
911
+ name: str,
912
+ rules: tuple[SyncRule, ...],
913
+ *,
914
+ heading: str,
915
+ global_scope: bool = False,
916
+ ) -> str:
917
+ scope_heading = (
918
+ f"Global {heading.lower()}"
919
+ if global_scope
920
+ else f"{heading} for profile '{name}'"
921
+ )
922
+ if not rules:
923
+ return f"No {scope_heading.lower()}."
924
+ identifiers = {
925
+ rule.id: _format_rule_id(rule, global_scope=global_scope) for rule in rules
926
+ }
927
+ width = max(len(identifier) for identifier in identifiers.values())
928
+ return "\n".join(
929
+ (
930
+ f"{scope_heading}:",
931
+ *(
932
+ f" {identifiers[rule.id]:>{width}} {rule.action:<7} "
933
+ f"{_format_rule_expression(rule)}"
934
+ for rule in rules
935
+ ),
936
+ )
937
+ )
938
+
939
+
940
+ def _format_rule_overview(
941
+ *,
942
+ profile_name: str | None,
943
+ global_rules: tuple[SyncRule, ...],
944
+ profile_rules: tuple[SyncRule, ...] = (),
945
+ ) -> str:
946
+ displayed = tuple(
947
+ _DisplayedRule(rule, True) for rule in global_rules
948
+ ) + tuple(_DisplayedRule(rule, False) for rule in profile_rules)
949
+ scope = (
950
+ "global rules"
951
+ if profile_name is None
952
+ else f"rules for profile '{profile_name}'"
953
+ )
954
+ if not displayed:
955
+ return f"No {scope}."
956
+ heading = (
957
+ "Global rules:"
958
+ if profile_name is None
959
+ else f"Rules for profile '{profile_name}':"
960
+ )
961
+ width = max(len(entry.identifier) for entry in displayed)
962
+ populated_targets = tuple(
963
+ (label, tuple(entry for entry in displayed if entry.rule.target == target))
964
+ for label, target in (("Local", "local"), ("Remote", "remote"))
965
+ if any(entry.rule.target == target for entry in displayed)
966
+ )
967
+ show_target_headings = len(populated_targets) > 1 or (
968
+ populated_targets and populated_targets[0][0] == "Remote"
969
+ )
970
+ lines = [heading]
971
+ for target_heading, target_rules in populated_targets:
972
+ group_indent = " "
973
+ if show_target_headings:
974
+ lines.append(f" {target_heading}:")
975
+ group_indent = " "
976
+ groups: dict[str, list[tuple[_DisplayedRule, str]]] = {}
977
+ for entry in target_rules:
978
+ group, expression = _rule_display_location(entry.rule)
979
+ groups.setdefault(group, []).append((entry, expression))
980
+ for group in sorted(groups, key=_rule_group_key):
981
+ lines.append(f"{group_indent}{group}")
982
+ for entry, expression in sorted(
983
+ groups[group],
984
+ key=lambda item: (
985
+ item[1].casefold(),
986
+ item[1],
987
+ item[0].global_scope is False,
988
+ item[0].rule.id,
989
+ ),
990
+ ):
991
+ lines.append(
992
+ f"{group_indent} {entry.identifier:>{width}} "
993
+ f"{entry.rule.action:<7} {expression}"
994
+ )
995
+ return "\n".join(lines)
996
+
997
+
998
+ def _rule_display_location(rule: SyncRule) -> tuple[str, str]:
999
+ parts = PurePosixPath(rule.pattern).parts
1000
+ wildcard_index = next(
1001
+ (index for index, part in enumerate(parts) if "*" in part),
1002
+ None,
1003
+ )
1004
+ if wildcard_index is None:
1005
+ parent = parts[:-1]
1006
+ return _rule_group_label(parent), parts[-1]
1007
+ if wildcard_index == 0 and parts[0] == "**":
1008
+ expression = "/".join(parts[1:]) or "all contents"
1009
+ return "Everywhere", expression
1010
+ parent = parts[:wildcard_index]
1011
+ expression = "/".join(parts[wildcard_index:])
1012
+ if expression == "**":
1013
+ expression = "all contents"
1014
+ return _rule_group_label(parent), expression
1015
+
1016
+
1017
+ def _rule_group_label(parts: tuple[str, ...]) -> str:
1018
+ return "./" if not parts else f"{PurePosixPath(*parts).as_posix()}/"
1019
+
1020
+
1021
+ def _rule_group_key(group: str) -> tuple[int, str, str]:
1022
+ if group == "./":
1023
+ return (0, "", "")
1024
+ if group == "Everywhere":
1025
+ return (2, "", "")
1026
+ return (1, group.casefold(), group)
1027
+
1028
+
1029
+ def _rules_need_precedence_note(rules: tuple[SyncRule, ...]) -> bool:
1030
+ return any(
1031
+ len({rule.action for rule in rules if rule.target == target}) > 1
1032
+ for target in ("local", "remote")
1033
+ )
1034
+
1035
+
1036
+ def _format_rule_expression(rule: SyncRule) -> str:
1037
+ return rule.pattern if "*" in rule.pattern else f"./{rule.pattern}"
1038
+
1039
+
1040
+ def _format_rule_id(rule: SyncRule, *, global_scope: bool) -> str:
1041
+ return f"g{rule.id}" if global_scope else str(rule.id)
1042
+
1043
+
1044
+ def _parse_rule_id(value: str, *, global_scope: bool) -> int:
1045
+ identifier = value[1:] if global_scope and value.startswith("g") else value
1046
+ valid_prefix = not global_scope or value.startswith("g")
1047
+ if not valid_prefix or not identifier.isdigit() or int(identifier) < 1:
1048
+ expected = "g-prefixed (for example g3)" if global_scope else "numeric"
1049
+ raise ConfigurationError(f"rule id must be {expected} in this scope")
1050
+ return int(identifier)
1051
+
1052
+
1053
+ def _change_rules(
1054
+ arguments: argparse.Namespace,
1055
+ store: ConfigurationStore,
1056
+ *,
1057
+ include: bool,
1058
+ ) -> str:
1059
+ patterns = normalize_pattern_operands(arguments)
1060
+ target = "remote" if arguments.remote else "local"
1061
+ if not patterns:
1062
+ raise ConfigurationError(f"{arguments.command} requires at least one path")
1063
+ if arguments.anywhere:
1064
+ for pattern in patterns:
1065
+ path = PurePosixPath(pattern)
1066
+ if path.is_absolute() or ".." in path.parts or not path.parts:
1067
+ raise ConfigurationError(
1068
+ "--anywhere requires relative names or patterns"
1069
+ )
1070
+ patterns = tuple(f"**/{pattern}" for pattern in patterns)
1071
+ if arguments.global_rules:
1072
+ global_store = _global_rule_store(store)
1073
+ configuration = global_store.load()
1074
+ normalized = patterns_from_global_operands(
1075
+ patterns,
1076
+ trailing_slash_tree=not arguments.remote,
1077
+ )
1078
+ configuration, update = configuration.with_appended_rules(
1079
+ "include" if include else "exclude",
1080
+ normalized,
1081
+ target=target,
1082
+ )
1083
+ global_store.save(configuration)
1084
+ return _format_rule_update(
1085
+ "",
1086
+ update,
1087
+ include=include,
1088
+ global_scope=True,
1089
+ target=target,
1090
+ )
1091
+ configuration, name, profile = _resolve_profile(arguments, store)
1092
+ root = _require_local_root(name, profile)
1093
+ current_directory = _effective_current_directory(arguments, root)
1094
+ operands = (
1095
+ patterns
1096
+ if arguments.pattern or arguments.anywhere or arguments.remote
1097
+ else expand_path_operands(
1098
+ patterns,
1099
+ profile_root=root,
1100
+ current_directory=current_directory,
1101
+ )
1102
+ )
1103
+ normalizer = (
1104
+ patterns_from_remote_operands
1105
+ if arguments.remote
1106
+ else patterns_from_operands
1107
+ )
1108
+ normalized = normalizer(
1109
+ operands, profile_root=root, current_directory=current_directory
1110
+ )
1111
+ update = configuration.append_rules(
1112
+ name,
1113
+ "include" if include else "exclude",
1114
+ normalized,
1115
+ target=target,
1116
+ base_rules=_global_rule_store(store).load().rules,
1117
+ )
1118
+ store.save(configuration)
1119
+ return _format_rule_update(name, update, include=include, target=target)
1120
+
1121
+
1122
+ def _format_rule_update(
1123
+ name: str,
1124
+ update: RuleUpdate,
1125
+ *,
1126
+ include: bool,
1127
+ global_scope: bool = False,
1128
+ target: str = "local",
1129
+ ) -> str:
1130
+ scope = "global synchronization policy" if global_scope else f"profile '{name}'"
1131
+ if update.added and update.removed:
1132
+ lines = [f"Updated synchronization rules for {scope}:"]
1133
+ lines.extend(
1134
+ f" added {_format_rule_id(rule, global_scope=global_scope)} "
1135
+ f"{rule.action:<7} {_format_rule_expression(rule)}"
1136
+ for rule in update.added
1137
+ )
1138
+ lines.extend(
1139
+ f" removed {_format_rule_id(rule, global_scope=global_scope)} "
1140
+ f"{rule.action:<7} {_format_rule_expression(rule)}"
1141
+ for rule in update.removed
1142
+ )
1143
+ return "\n".join(lines)
1144
+ if update.added:
1145
+ action = "Inclusion" if include else "Exclusion"
1146
+ if target == "remote":
1147
+ action = f"Remote {action.lower()}"
1148
+ if global_scope:
1149
+ identifiers = tuple(
1150
+ _format_rule_id(rule, global_scope=True) for rule in update.added
1151
+ )
1152
+ width = max(len(identifier) for identifier in identifiers)
1153
+ return "\n".join(
1154
+ (
1155
+ f"Recorded global {action.lower()} rules:",
1156
+ *(
1157
+ f" {identifier:>{width}} {rule.action:<7} "
1158
+ f"{_format_rule_expression(rule)}"
1159
+ for rule, identifier in zip(
1160
+ update.added, identifiers, strict=True
1161
+ )
1162
+ ),
1163
+ )
1164
+ )
1165
+ return _format_rules(
1166
+ name,
1167
+ update.added,
1168
+ heading=f"Recorded {action.lower()} rules",
1169
+ )
1170
+ if update.removed:
1171
+ result = "included" if include else "excluded"
1172
+ if target == "remote":
1173
+ result = f"remotely {result}"
1174
+ remaining_scope = (
1175
+ "global synchronization policy"
1176
+ if global_scope
1177
+ else f"policy for profile '{name}'"
1178
+ )
1179
+ lines = [
1180
+ f"Paths are {result} by the remaining {remaining_scope};",
1181
+ "removed the unnecessary rules:",
1182
+ ]
1183
+ identifiers = tuple(
1184
+ _format_rule_id(rule, global_scope=global_scope)
1185
+ for rule in update.removed
1186
+ )
1187
+ width = max(len(identifier) for identifier in identifiers)
1188
+ lines.extend(
1189
+ f" {identifier:>{width}} {rule.action:<7} "
1190
+ f"{_format_rule_expression(rule)}"
1191
+ for rule, identifier in zip(update.removed, identifiers, strict=True)
1192
+ )
1193
+ return "\n".join(lines)
1194
+ return f"Synchronization rules for {scope} are unchanged."
1195
+
1196
+
1197
+ def _manage_rules(
1198
+ arguments: argparse.Namespace,
1199
+ store: ConfigurationStore,
1200
+ ) -> str:
1201
+ if arguments.operation == "rm" and arguments.rule_id is None:
1202
+ raise ConfigurationError("rules rm requires a RULE_ID")
1203
+ if arguments.global_rules or (
1204
+ arguments.rule_id is not None and arguments.rule_id.startswith("g")
1205
+ ):
1206
+ global_store = _global_rule_store(store)
1207
+ global_configuration = global_store.load()
1208
+ if arguments.rule_id is None:
1209
+ formatted = _format_rule_overview(
1210
+ profile_name=None,
1211
+ global_rules=global_configuration.rules,
1212
+ )
1213
+ if _rules_need_precedence_note(global_configuration.rules):
1214
+ return f"{formatted}\nLater rules win."
1215
+ return formatted
1216
+ global_configuration, removed = global_configuration.without_rule(
1217
+ _parse_rule_id(arguments.rule_id, global_scope=True)
1218
+ )
1219
+ global_store.save(global_configuration)
1220
+ return (
1221
+ f"Removed global rule {_format_rule_id(removed, global_scope=True)}: "
1222
+ f"{removed.action} {_format_rule_expression(removed)}"
1223
+ )
1224
+ configuration, name, profile = _resolve_profile(arguments, store)
1225
+ if arguments.rule_id is None:
1226
+ global_configuration = _global_rule_store(store).load()
1227
+ overview = _format_rule_overview(
1228
+ profile_name=name,
1229
+ global_rules=global_configuration.rules,
1230
+ profile_rules=profile.rules,
1231
+ )
1232
+ return (
1233
+ f"{overview}\n\n"
1234
+ "Profile rules override global rules; later matching rules win."
1235
+ )
1236
+ removed = configuration.remove_rule(
1237
+ name,
1238
+ _parse_rule_id(arguments.rule_id, global_scope=False),
1239
+ )
1240
+ store.save(configuration)
1241
+ return (
1242
+ f"Removed rule {removed.id} from profile '{name}': "
1243
+ f"{removed.action} {_format_rule_expression(removed)}"
1244
+ )
1245
+
1246
+
1247
+ def _remove(arguments: argparse.Namespace, store: ConfigurationStore) -> str:
1248
+ configuration, name, _ = _resolve_profile(arguments, store)
1249
+ del configuration.profiles[name]
1250
+ store.save(configuration)
1251
+ return f"Removed profile '{name}'."
1252
+
1253
+
1254
+ def _list_profiles(store: ConfigurationStore) -> str:
1255
+ configuration = store.load()
1256
+ if not configuration.profiles:
1257
+ return "No profiles configured."
1258
+
1259
+ active = configuration.profile_for_path(Path.cwd().resolve(strict=True))
1260
+ active_name = active[0] if active is not None else None
1261
+ return "\n".join(
1262
+ f"{'*' if name == active_name else ' '} {name}"
1263
+ for name in sorted(configuration.profiles)
1264
+ )
1265
+
1266
+
1267
+ def _show_profile(
1268
+ arguments: argparse.Namespace,
1269
+ store: ConfigurationStore,
1270
+ ) -> str:
1271
+ implicit = (
1272
+ getattr(arguments, "profile_prefix", None) is None
1273
+ and arguments.profile_name is None
1274
+ )
1275
+ if implicit:
1276
+ configuration = store.load()
1277
+ active = configuration.profile_for_path(Path.cwd().resolve(strict=True))
1278
+ if active is None:
1279
+ return "No active profile. Use hlsync PROFILE COMMAND."
1280
+ name, profile = active
1281
+ else:
1282
+ _, name, profile = _resolve_profile(arguments, store)
1283
+ if not arguments.profile_details:
1284
+ return name
1285
+ return "\n".join(
1286
+ (
1287
+ f"Profile '{name}':",
1288
+ f" Protocol: {profile.type.upper()}",
1289
+ f" Host: {profile.host}:{profile.port}",
1290
+ f" Remote root: {profile.remote_root}",
1291
+ f" Local root: {profile.local_root or 'not mapped'}",
1292
+ f" Username env: {profile.username_env}",
1293
+ f" Password env: {profile.password_env}",
1294
+ f" Rules: {len(profile.rules)}",
1295
+ )
1296
+ )
1297
+
1298
+
1299
+ def _show_root(
1300
+ arguments: argparse.Namespace,
1301
+ store: ConfigurationStore,
1302
+ ) -> str:
1303
+ _, name, profile = _resolve_profile(arguments, store)
1304
+ return os.fspath(_require_local_root(name, profile))
1305
+
1306
+
1307
+ def _require_local_root(name: str, profile: ProfileConfiguration) -> Path:
1308
+ if profile.local_root is None:
1309
+ raise ConfigurationError(f"profile '{name}' has not been mapped")
1310
+ return Path(profile.local_root)
1311
+
1312
+
1313
+ def _format_tree_listing(
1314
+ arguments: argparse.Namespace,
1315
+ snapshot: TreeSnapshot,
1316
+ *,
1317
+ heading: str,
1318
+ empty_message: str,
1319
+ display_base: PurePosixPath,
1320
+ output: TextIO,
1321
+ status_for: Callable[
1322
+ [TreeEntry], tuple[str, str | None, bool, str | None]
1323
+ ],
1324
+ ) -> str:
1325
+ if not snapshot.entries:
1326
+ return empty_message
1327
+ lines = [heading]
1328
+ options = _active_options(arguments)
1329
+ if options is not None:
1330
+ lines.append(options)
1331
+ color = _use_color(output)
1332
+ emitted_directories: set[str] = set()
1333
+ for entry in _file_browser_order(
1334
+ snapshot.entries,
1335
+ path_of=lambda item: item.path,
1336
+ directory_of=lambda item: item.kind == "directory",
1337
+ ):
1338
+ profile_path = PurePosixPath(entry.path)
1339
+ try:
1340
+ relative = profile_path.relative_to(display_base)
1341
+ except ValueError as error:
1342
+ raise SelectionError(
1343
+ f"selected path '{entry.path}' is outside the display scope"
1344
+ ) from error
1345
+ for index, part in enumerate(relative.parts[:-1]):
1346
+ ancestor = display_base / PurePosixPath(*relative.parts[: index + 1])
1347
+ ancestor_path = ancestor.as_posix()
1348
+ if ancestor_path in emitted_directories:
1349
+ continue
1350
+ lines.append(
1351
+ _format_path_line(
1352
+ " ",
1353
+ directory=True,
1354
+ path=part,
1355
+ depth=index,
1356
+ color=color,
1357
+ marker_color=None,
1358
+ excluded=False,
1359
+ )
1360
+ )
1361
+ emitted_directories.add(ancestor_path)
1362
+ marker, marker_color, excluded, path_color = status_for(entry)
1363
+ lines.append(
1364
+ _format_path_line(
1365
+ marker,
1366
+ directory=entry.kind == "directory",
1367
+ path=relative.name,
1368
+ depth=max(len(relative.parts) - 1, 0),
1369
+ color=color,
1370
+ marker_color=marker_color,
1371
+ excluded=excluded,
1372
+ path_color=path_color,
1373
+ )
1374
+ )
1375
+ if entry.kind == "directory":
1376
+ emitted_directories.add(entry.path)
1377
+ return "\n".join(lines)
1378
+
1379
+
1380
+ def _list_local(
1381
+ arguments: argparse.Namespace,
1382
+ store: ConfigurationStore,
1383
+ output: TextIO,
1384
+ ) -> str:
1385
+ _, name, profile = _resolve_profile(arguments, store)
1386
+ root = _require_local_root(name, profile)
1387
+ selector = _list_selection(arguments, root)
1388
+ raw_snapshot = snapshot_local(
1389
+ root,
1390
+ _effective_rules(store, profile),
1391
+ selector,
1392
+ include_excluded=True,
1393
+ traverse_excluded=True,
1394
+ )
1395
+ if not raw_snapshot.entries:
1396
+ raise SelectionError(f"file selector '{selector.pattern}' matched no paths")
1397
+ snapshot = TreeSnapshot(
1398
+ tuple(
1399
+ entry
1400
+ for entry in raw_snapshot.entries
1401
+ if selector.includes_result(
1402
+ entry.path,
1403
+ directory=entry.kind == "directory",
1404
+ )
1405
+ and (not arguments.included_only or not entry.excluded)
1406
+ )
1407
+ )
1408
+ display_base = _profile_relative_current_directory(arguments, root)
1409
+ return _format_tree_listing(
1410
+ arguments,
1411
+ snapshot,
1412
+ heading=f"Local tree for profile '{name}':",
1413
+ empty_message=f"Local tree for profile '{name}' is empty.",
1414
+ display_base=display_base,
1415
+ output=output,
1416
+ status_for=lambda entry: (
1417
+ "x" if entry.excluded else " ",
1418
+ "\033[90m" if entry.excluded else None,
1419
+ entry.excluded,
1420
+ None,
1421
+ ),
1422
+ )
1423
+
1424
+
1425
+ def _list_remote(
1426
+ arguments: argparse.Namespace,
1427
+ store: ConfigurationStore,
1428
+ progress: TextIO,
1429
+ output: TextIO,
1430
+ ) -> str:
1431
+ _, name, profile = _resolve_profile(arguments, store)
1432
+ root = _require_local_root(name, profile)
1433
+ selector = _list_selection(arguments, root)
1434
+ rules = _effective_rules(store, profile)
1435
+ print(f"Listing remote files for profile '{name}'...", file=progress, flush=True)
1436
+ print("Connecting securely over FTPS...", file=progress, flush=True)
1437
+ with ExplicitFTPSTransport(profile) as transport:
1438
+ raw_snapshot = transport.snapshot(
1439
+ rules,
1440
+ selector,
1441
+ include_excluded=True,
1442
+ traverse_excluded=True,
1443
+ )
1444
+ if not raw_snapshot.entries:
1445
+ raise SelectionError(f"file selector '{selector.pattern}' matched no paths")
1446
+ snapshot = TreeSnapshot(
1447
+ tuple(
1448
+ entry
1449
+ for entry in raw_snapshot.entries
1450
+ if selector.includes_result(
1451
+ entry.path,
1452
+ directory=entry.kind == "directory",
1453
+ )
1454
+ and (
1455
+ not arguments.included_only
1456
+ or (not entry.excluded and not entry.remote_excluded)
1457
+ )
1458
+ )
1459
+ )
1460
+ display_base = _profile_relative_current_directory(arguments, root)
1461
+
1462
+ def remote_status(
1463
+ entry: TreeEntry,
1464
+ ) -> tuple[str, str | None, bool, str | None]:
1465
+ if entry.remote_excluded or entry.excluded:
1466
+ marker = "#" if entry.remote_excluded else "x"
1467
+ return marker, _DIFF_MARKER_COLORS[marker], True, None
1468
+ return " ", None, False, None
1469
+
1470
+ return _format_tree_listing(
1471
+ arguments,
1472
+ snapshot,
1473
+ heading=f"Remote tree for profile '{name}':",
1474
+ empty_message=f"Remote tree for profile '{name}' is empty.",
1475
+ display_base=display_base,
1476
+ output=output,
1477
+ status_for=remote_status,
1478
+ )
1479
+
1480
+
1481
+ def _comparison_kind(entry: ComparisonEntry) -> str:
1482
+ if (
1483
+ entry.local_kind is not None
1484
+ and entry.remote_kind is not None
1485
+ and entry.local_kind != entry.remote_kind
1486
+ ):
1487
+ return f"{entry.local_kind}->{entry.remote_kind}"
1488
+ return entry.local_kind or entry.remote_kind or "unknown"
1489
+
1490
+
1491
+ def _comparison_marker(entry: ComparisonEntry) -> str:
1492
+ side = (
1493
+ "r" if entry.local_kind is None
1494
+ else "l" if entry.remote_kind is None
1495
+ else " "
1496
+ )
1497
+ exclusion = (
1498
+ "#" if entry.state == "remote-excluded"
1499
+ else "x" if entry.state == "excluded"
1500
+ else " "
1501
+ )
1502
+ action = {
1503
+ "excluded": " ",
1504
+ "conflict": "?",
1505
+ "unchanged": "=",
1506
+ "untraversed": " ",
1507
+ "skip": " ",
1508
+ "create-remote": "+",
1509
+ "upload": "+",
1510
+ "replace-remote": "~",
1511
+ "replace-local": "~",
1512
+ "delete-remote": "-",
1513
+ }[entry.action]
1514
+ return f"{action}{exclusion} {side}"
1515
+
1516
+
1517
+ def _comparison_marker_color(marker: str) -> str | None:
1518
+ action, exclusion, side = marker[0], marker[1], marker[3]
1519
+ return (
1520
+ _DIFF_MARKER_COLORS.get(action)
1521
+ or _DIFF_MARKER_COLORS.get(exclusion)
1522
+ or _DIFF_MARKER_COLORS.get(side)
1523
+ )
1524
+
1525
+
1526
+ def _comparison_entry_kind(entry: ComparisonEntry, direction: str) -> str:
1527
+ selected = entry.local_kind if direction == "push" else entry.remote_kind
1528
+ return selected or entry.remote_kind or entry.local_kind or "unknown"
1529
+
1530
+
1531
+ def _use_color(output: TextIO) -> bool:
1532
+ if "NO_COLOR" in os.environ:
1533
+ return False
1534
+ is_terminal = getattr(output, "isatty", None)
1535
+ return bool(is_terminal and is_terminal())
1536
+
1537
+
1538
+ def _format_legend(output: TextIO) -> str:
1539
+ color = _use_color(output)
1540
+ entries = (
1541
+ ("+ l", "local-only; upload", _DIFF_MARKER_COLORS["+"]),
1542
+ ("~ ", "present on both sides; update", _DIFF_MARKER_COLORS["~"]),
1543
+ (" r", "remote-only; retain", _DIFF_MARKER_COLORS["r"]),
1544
+ ("- r", "remote-only; delete with --prune", _DIFF_MARKER_COLORS["-"]),
1545
+ ("-x ", "locally excluded; delete with --prune", _DIFF_MARKER_COLORS["-"]),
1546
+ ("l", "local only", _DIFF_MARKER_COLORS["l"]),
1547
+ ("r", "remote only", _DIFF_MARKER_COLORS["r"]),
1548
+ (" ", "blank side column: present on both sides", None),
1549
+ (
1550
+ "?",
1551
+ "conflict; destination not older, or type mismatch",
1552
+ _DIFF_MARKER_COLORS["?"],
1553
+ ),
1554
+ ("=", "unchanged file", None),
1555
+ ("x", "locally excluded", _DIFF_MARKER_COLORS["x"]),
1556
+ ("#", "remotely excluded; leave untouched", _DIFF_MARKER_COLORS["#"]),
1557
+ ("/", "directory", _DIRECTORY_COLOR),
1558
+ ("▸", "contents not inspected", _COLLAPSED_DIRECTORY_COLOR),
1559
+ )
1560
+ lines = ["Diff legend:"]
1561
+ for marker, meaning, style in entries:
1562
+ rendered = f"{style}{marker}{_RESET}" if color and style else marker
1563
+ lines.append(f" {rendered} {meaning}")
1564
+ return "\n".join(lines)
1565
+
1566
+
1567
+ def _format_path_line(
1568
+ marker: str,
1569
+ *,
1570
+ directory: bool,
1571
+ path: str,
1572
+ depth: int = 0,
1573
+ color: bool,
1574
+ marker_color: str | None,
1575
+ excluded: bool,
1576
+ collapsed: bool = False,
1577
+ path_color: str | None = None,
1578
+ ) -> str:
1579
+ indent = " " * depth
1580
+ label = f"{path}/" if directory else path
1581
+ traversal = " ▸" if collapsed else ""
1582
+ body = f"{marker} {indent}{label}{traversal}"
1583
+ if not color:
1584
+ return body
1585
+ if len(marker) == 4:
1586
+ action, exclusion, side = marker[0], marker[1], marker[3]
1587
+ side_color = _DIFF_MARKER_COLORS.get(side)
1588
+ exclusion_color = _DIFF_MARKER_COLORS.get(exclusion)
1589
+ rendered_side = (
1590
+ f"{side_color}{side}{_RESET}" if side_color and side != " " else side
1591
+ )
1592
+ rendered_exclusion = (
1593
+ f"{exclusion_color}{exclusion}{_RESET}"
1594
+ if exclusion_color and exclusion != " "
1595
+ else exclusion
1596
+ )
1597
+ rendered_action = (
1598
+ f"{marker_color}{action}{_RESET}"
1599
+ if marker_color and action != " "
1600
+ else action
1601
+ )
1602
+ rendered_status = f"{rendered_action}{rendered_exclusion} {rendered_side}"
1603
+ if directory:
1604
+ directory_color = (
1605
+ _COLLAPSED_DIRECTORY_COLOR
1606
+ if collapsed and not marker.strip()
1607
+ else path_color
1608
+ or (_EXCLUDED_DIRECTORY_COLOR if excluded else _DIRECTORY_COLOR)
1609
+ )
1610
+ suffix = " ▸" if collapsed else ""
1611
+ return (
1612
+ f"{rendered_status} {indent}"
1613
+ f"{directory_color}{path}/{suffix}{_RESET}"
1614
+ )
1615
+ path_style = marker_color or side_color
1616
+ rendered_path = (
1617
+ f"{path_style}{label}{traversal}{_RESET}"
1618
+ if path_style
1619
+ else f"{label}{traversal}"
1620
+ )
1621
+ return f"{rendered_status} {indent}{rendered_path}"
1622
+ if directory:
1623
+ if collapsed:
1624
+ colored_marker = (
1625
+ f"{marker_color}{marker}{_RESET}" if marker_color else marker
1626
+ )
1627
+ return (
1628
+ f"{colored_marker} {indent}"
1629
+ f"{_COLLAPSED_DIRECTORY_COLOR}{path}/ ▸{_RESET}"
1630
+ )
1631
+ directory_color = path_color or (
1632
+ _EXCLUDED_DIRECTORY_COLOR if excluded else _DIRECTORY_COLOR
1633
+ )
1634
+ colored_marker = (
1635
+ f"{marker_color}{marker}{_RESET}" if marker_color else marker
1636
+ )
1637
+ return f"{colored_marker} {indent}{directory_color}{path}/{_RESET}"
1638
+ if marker_color:
1639
+ return f"{marker_color}{body}{_RESET}"
1640
+ return body
1641
+
1642
+
1643
+ def _file_browser_order(
1644
+ entries: Sequence[_EntryT],
1645
+ *,
1646
+ path_of: Callable[[_EntryT], str],
1647
+ directory_of: Callable[[_EntryT], bool],
1648
+ ) -> tuple[_EntryT, ...]:
1649
+ directory_paths = {
1650
+ path_of(entry) for entry in entries if directory_of(entry)
1651
+ }
1652
+ for entry in entries:
1653
+ parts = PurePosixPath(path_of(entry)).parts
1654
+ directory_paths.update(
1655
+ PurePosixPath(*parts[:index]).as_posix()
1656
+ for index in range(1, len(parts))
1657
+ )
1658
+
1659
+ def order_key(entry: _EntryT) -> tuple[tuple[int, str, str], ...]:
1660
+ parts = PurePosixPath(path_of(entry)).parts
1661
+ return tuple(
1662
+ (
1663
+ 0
1664
+ if PurePosixPath(*parts[: index + 1]).as_posix()
1665
+ in directory_paths
1666
+ else 1,
1667
+ part.casefold(),
1668
+ part,
1669
+ )
1670
+ for index, part in enumerate(parts)
1671
+ )
1672
+
1673
+ return tuple(sorted(entries, key=order_key))
1674
+
1675
+
1676
+ def _format_comparison_entries(
1677
+ entries: Sequence[ComparisonEntry],
1678
+ direction: str,
1679
+ *,
1680
+ color: bool,
1681
+ collapsed_paths: frozenset[str] = frozenset(),
1682
+ display_path: Callable[[str], tuple[int, str]] | None = None,
1683
+ ) -> tuple[str, ...]:
1684
+ lines: list[str] = []
1685
+ for entry in _file_browser_order(
1686
+ entries,
1687
+ path_of=lambda item: item.path,
1688
+ directory_of=lambda item: _comparison_entry_kind(item, direction)
1689
+ == "directory",
1690
+ ):
1691
+ collapsed = entry.path in collapsed_paths
1692
+ directory = _comparison_entry_kind(entry, direction) == "directory"
1693
+ marker = (
1694
+ " "
1695
+ if directory and entry.action == "unchanged"
1696
+ else _comparison_marker(entry)
1697
+ )
1698
+ depth, path = display_path(entry.path) if display_path else (0, entry.path)
1699
+ lines.append(
1700
+ _format_path_line(
1701
+ marker,
1702
+ directory=directory,
1703
+ path=path,
1704
+ depth=depth,
1705
+ color=color,
1706
+ marker_color=_comparison_marker_color(marker),
1707
+ excluded=entry.action == "excluded",
1708
+ collapsed=collapsed,
1709
+ )
1710
+ )
1711
+ return tuple(lines)
1712
+
1713
+
1714
+ def _scoped_display_path(
1715
+ path: str,
1716
+ *,
1717
+ display_root: PurePosixPath | None,
1718
+ ) -> tuple[int, str]:
1719
+ profile_path = PurePosixPath(path)
1720
+ if display_root is None:
1721
+ return 0, profile_path.as_posix()
1722
+ if profile_path == display_root and display_root.parts:
1723
+ return 0, display_root.as_posix()
1724
+ relative = (
1725
+ profile_path.relative_to(display_root)
1726
+ if display_root.parts
1727
+ else profile_path
1728
+ )
1729
+ depth = len(relative.parts) if display_root.parts else max(
1730
+ len(relative.parts) - 1,
1731
+ 0,
1732
+ )
1733
+ return depth, relative.name
1734
+
1735
+
1736
+ def _comparison_ancestors(
1737
+ path: str,
1738
+ *,
1739
+ display_root: PurePosixPath | None,
1740
+ ) -> tuple[PurePosixPath, ...]:
1741
+ profile_path = PurePosixPath(path)
1742
+ start = len(display_root.parts) if display_root is not None else 1
1743
+ start = max(start, 1)
1744
+ return tuple(
1745
+ PurePosixPath(*profile_path.parts[:length])
1746
+ for length in range(start, len(profile_path.parts))
1747
+ if display_root is None
1748
+ or profile_path.parts[: len(display_root.parts)] == display_root.parts
1749
+ )
1750
+
1751
+
1752
+ def _format_compact_comparison_entry(
1753
+ entry: ComparisonEntry,
1754
+ direction: str,
1755
+ *,
1756
+ color: bool,
1757
+ collapsed: bool,
1758
+ display_root: PurePosixPath | None,
1759
+ emitted_directories: set[str],
1760
+ ) -> tuple[str, ...]:
1761
+ lines: list[str] = []
1762
+ for ancestor in _comparison_ancestors(
1763
+ entry.path,
1764
+ display_root=display_root,
1765
+ ):
1766
+ ancestor_path = ancestor.as_posix()
1767
+ if ancestor_path in emitted_directories:
1768
+ continue
1769
+ depth, label = _scoped_display_path(
1770
+ ancestor_path,
1771
+ display_root=display_root,
1772
+ )
1773
+ lines.append(
1774
+ _format_path_line(
1775
+ " ",
1776
+ directory=True,
1777
+ path=label,
1778
+ depth=depth,
1779
+ color=color,
1780
+ marker_color=None,
1781
+ excluded=False,
1782
+ )
1783
+ )
1784
+ emitted_directories.add(ancestor_path)
1785
+ lines.extend(
1786
+ _format_comparison_entries(
1787
+ (entry,),
1788
+ direction,
1789
+ color=color,
1790
+ collapsed_paths=frozenset((entry.path,)) if collapsed else frozenset(),
1791
+ display_path=lambda path: _scoped_display_path(
1792
+ path,
1793
+ display_root=display_root,
1794
+ ),
1795
+ )
1796
+ )
1797
+ if _comparison_entry_kind(entry, direction) == "directory":
1798
+ emitted_directories.add(entry.path)
1799
+ return tuple(lines)
1800
+
1801
+
1802
+ def _scope_header_lines(
1803
+ display_root: PurePosixPath | None,
1804
+ *,
1805
+ anchored: bool,
1806
+ color: bool,
1807
+ ) -> tuple[str, ...]:
1808
+ if display_root is None:
1809
+ return ()
1810
+ if anchored or not display_root.parts:
1811
+ return ()
1812
+ return (
1813
+ _format_path_line(
1814
+ " ",
1815
+ directory=True,
1816
+ path=display_root.as_posix(),
1817
+ color=color,
1818
+ marker_color=None,
1819
+ excluded=False,
1820
+ ),
1821
+ )
1822
+
1823
+
1824
+ def _file_selection(arguments: argparse.Namespace, root: Path) -> FileSelection:
1825
+ operands = list(normalize_pattern_operands(arguments))
1826
+ current_directory = _effective_current_directory(arguments, root)
1827
+ recursive = arguments.recursive or (
1828
+ arguments.command in {"diff", "push"} and not arguments.shallow
1829
+ )
1830
+ if not operands:
1831
+ return _current_directory_selection(
1832
+ root,
1833
+ current_directory=current_directory,
1834
+ recursive=recursive,
1835
+ )
1836
+ return _directory_contents_selection(
1837
+ operands,
1838
+ root,
1839
+ current_directory=current_directory,
1840
+ recursive=recursive,
1841
+ )
1842
+
1843
+
1844
+
1845
+ def _push_comparison_selection(
1846
+ arguments: argparse.Namespace,
1847
+ root: Path,
1848
+ local: TreeSnapshot,
1849
+ selection: DirectoryContentsSelection,
1850
+ ) -> FileSelection:
1851
+ """Keep explicitly selected remote-only directories as deletion targets."""
1852
+ local_entries = {entry.path: entry for entry in local.entries}
1853
+ for operand in normalize_pattern_operands(arguments):
1854
+ if operand == "." or any(character in operand for character in "*?["):
1855
+ continue
1856
+ path = FileSelector.from_argument(
1857
+ operand.rstrip("/"), profile_root=root,
1858
+ current_directory=_effective_current_directory(arguments, root),
1859
+ ).pattern
1860
+ entry = local_entries.get(path)
1861
+ if entry is None or (entry.excluded and entry.kind == "directory"):
1862
+ return selection.traversal
1863
+ return selection
1864
+
1865
+
1866
+ def _selection_from_values(
1867
+ values: Sequence[str],
1868
+ root: Path,
1869
+ current_directory: Path,
1870
+ ) -> FileSelection:
1871
+ selectors = tuple(
1872
+ FileSelector.from_argument(
1873
+ value,
1874
+ profile_root=root,
1875
+ current_directory=current_directory,
1876
+ )
1877
+ for value in values
1878
+ )
1879
+ if len(selectors) == 1:
1880
+ return selectors[0]
1881
+ return FileSelectorSet(selectors)
1882
+
1883
+
1884
+ def _list_selection(
1885
+ arguments: argparse.Namespace,
1886
+ root: Path,
1887
+ ) -> DirectoryContentsSelection:
1888
+ operands = list(normalize_pattern_operands(arguments))
1889
+ current_directory = _effective_current_directory(arguments, root)
1890
+ if not operands:
1891
+ return _current_directory_selection(
1892
+ root,
1893
+ current_directory=current_directory,
1894
+ recursive=arguments.recursive,
1895
+ )
1896
+ return _directory_contents_selection(
1897
+ operands,
1898
+ root,
1899
+ current_directory=current_directory,
1900
+ recursive=arguments.recursive,
1901
+ )
1902
+
1903
+
1904
+ def _current_directory_selection(
1905
+ root: Path,
1906
+ *,
1907
+ current_directory: Path,
1908
+ recursive: bool,
1909
+ ) -> DirectoryContentsSelection:
1910
+ pattern = "**" if recursive else "*"
1911
+ return DirectoryContentsSelection(
1912
+ _selection_from_values(
1913
+ (pattern,),
1914
+ root,
1915
+ current_directory,
1916
+ )
1917
+ )
1918
+
1919
+
1920
+ def _directory_contents_selection(
1921
+ operands: Sequence[str],
1922
+ root: Path,
1923
+ *,
1924
+ current_directory: Path,
1925
+ recursive: bool,
1926
+ ) -> DirectoryContentsSelection:
1927
+ traversal_patterns: list[str] = []
1928
+ container_patterns: list[str] = []
1929
+ for operand in operands:
1930
+ if operand == ".":
1931
+ traversal_patterns.append("**" if recursive else "*")
1932
+ continue
1933
+ normalized = operand.rstrip("/")
1934
+ container_patterns.append(normalized)
1935
+ traversal_patterns.extend(
1936
+ (
1937
+ normalized,
1938
+ f"{normalized}/{'**' if recursive else '*'}",
1939
+ )
1940
+ )
1941
+ traversal = _selection_from_values(
1942
+ traversal_patterns,
1943
+ root,
1944
+ current_directory,
1945
+ )
1946
+ containers = (
1947
+ _selection_from_values(container_patterns, root, current_directory)
1948
+ if container_patterns
1949
+ else None
1950
+ )
1951
+ return DirectoryContentsSelection(traversal, containers)
1952
+
1953
+
1954
+ def _filtered_listing(
1955
+ listing: TreeSnapshot,
1956
+ selector: FileSelection | None,
1957
+ ) -> TreeSnapshot:
1958
+ return TreeSnapshot(
1959
+ tuple(
1960
+ entry
1961
+ for entry in listing.entries
1962
+ if selector is None or selector.matches(entry.path)
1963
+ )
1964
+ )
1965
+
1966
+
1967
+ def _descendant_directories(
1968
+ local: TreeSnapshot,
1969
+ remote: TreeSnapshot,
1970
+ rules: RuleSet,
1971
+ selector: FileSelection | None,
1972
+ *,
1973
+ descend_remote_only: bool,
1974
+ ) -> tuple[tuple[PurePosixPath, bool, bool], ...]:
1975
+ local_directories = {
1976
+ entry.path: entry for entry in local.entries if entry.kind == "directory"
1977
+ }
1978
+ remote_directories = {
1979
+ entry.path: entry for entry in remote.entries if entry.kind == "directory"
1980
+ }
1981
+ paths = sorted(local_directories.keys() | remote_directories.keys())
1982
+ descendants: list[tuple[PurePosixPath, bool, bool]] = []
1983
+ for path in paths:
1984
+ local_entry = local_directories.get(path)
1985
+ remote_entry = remote_directories.get(path)
1986
+ if local_entry is None and not descend_remote_only:
1987
+ continue
1988
+ if selector is not None and not selector.may_match_descendant(path):
1989
+ continue
1990
+ if (
1991
+ any(
1992
+ entry is not None and entry.excluded
1993
+ for entry in (local_entry, remote_entry)
1994
+ )
1995
+ and not rules.may_include_descendant(path, target="local")
1996
+ ):
1997
+ continue
1998
+ if any(
1999
+ entry is not None and entry.remote_excluded
2000
+ for entry in (local_entry, remote_entry)
2001
+ ):
2002
+ continue
2003
+ descendants.append(
2004
+ (PurePosixPath(path), local_entry is not None, remote_entry is not None)
2005
+ )
2006
+ return tuple(descendants)
2007
+
2008
+
2009
+ def _profile_relative_current_directory(
2010
+ arguments: argparse.Namespace,
2011
+ root: Path,
2012
+ ) -> PurePosixPath:
2013
+ current = _effective_current_directory(arguments, root)
2014
+ try:
2015
+ relative = current.relative_to(root)
2016
+ except ValueError:
2017
+ return PurePosixPath()
2018
+ return PurePosixPath(*relative.parts)
2019
+
2020
+
2021
+ def _diff_traversal_roots(
2022
+ arguments: argparse.Namespace,
2023
+ root: Path,
2024
+ ) -> tuple[_DiffTraversalRoot, ...]:
2025
+ """Find the narrowest deterministic directories needed by the selection."""
2026
+ base = _profile_relative_current_directory(arguments, root)
2027
+ operands = normalize_pattern_operands(arguments)
2028
+ if not operands:
2029
+ return (_DiffTraversalRoot(base),)
2030
+
2031
+ candidates: list[_DiffTraversalRoot] = []
2032
+ for operand in operands:
2033
+ profile_path = base / PurePosixPath(operand)
2034
+ if operand == ".":
2035
+ candidates.append(_DiffTraversalRoot(base))
2036
+ continue
2037
+
2038
+ wildcard_index = next(
2039
+ (
2040
+ index
2041
+ for index, part in enumerate(profile_path.parts)
2042
+ if "*" in part
2043
+ ),
2044
+ None,
2045
+ )
2046
+ if wildcard_index is not None:
2047
+ fixed_parts = profile_path.parts[:wildcard_index]
2048
+ fixed = PurePosixPath(*fixed_parts) if fixed_parts else PurePosixPath()
2049
+ candidates.append(_DiffTraversalRoot(fixed))
2050
+ continue
2051
+
2052
+ local_path = root.joinpath(*profile_path.parts)
2053
+ if local_path.is_dir() and not local_path.is_symlink():
2054
+ candidates.append(_DiffTraversalRoot(profile_path, include_container=True))
2055
+ else:
2056
+ candidates.append(_DiffTraversalRoot(profile_path.parent))
2057
+
2058
+ ordered = sorted(
2059
+ candidates,
2060
+ key=lambda item: (item.path.parts, item.include_container),
2061
+ )
2062
+ roots: list[_DiffTraversalRoot] = []
2063
+ for candidate in ordered:
2064
+ covering = next(
2065
+ (
2066
+ existing
2067
+ for existing in roots
2068
+ if candidate.path.parts[: len(existing.path.parts)]
2069
+ == existing.path.parts
2070
+ ),
2071
+ None,
2072
+ )
2073
+ if covering is None:
2074
+ roots.append(candidate)
2075
+ elif covering.path == candidate.path and candidate.include_container:
2076
+ roots[roots.index(covering)] = candidate
2077
+ return tuple(roots)
2078
+
2079
+
2080
+ def _resume_directory(value: str | None) -> PurePosixPath | None:
2081
+ if value is None:
2082
+ return None
2083
+ path = PurePosixPath(value)
2084
+ if path.is_absolute() or not path.parts or ".." in path.parts:
2085
+ raise SelectionError(
2086
+ "resume directory must be a profile-relative directory"
2087
+ )
2088
+ if any(character in value for character in "*?["):
2089
+ raise SelectionError("resume directory cannot contain wildcards")
2090
+ return path
2091
+
2092
+
2093
+ def _resume_command(arguments: argparse.Namespace, directory: PurePosixPath) -> str:
2094
+ command = ["hlsync"]
2095
+ if arguments.profile_prefix is not None:
2096
+ command.append(arguments.profile_prefix)
2097
+ command.extend(("diff", *arguments.pattern_operands))
2098
+ if arguments.pull:
2099
+ command.append("--pull")
2100
+ if arguments.prune:
2101
+ command.append("--prune")
2102
+ if arguments.recursive:
2103
+ command.append("--recursive")
2104
+ if arguments.shallow:
2105
+ command.append("--shallow")
2106
+ if arguments.show_excluded:
2107
+ command.append("--excluded")
2108
+ if arguments.show_all:
2109
+ command.append("--all")
2110
+ command.extend(("--paged", "--resume", directory.as_posix()))
2111
+ return shlex.join(command)
2112
+
2113
+
2114
+ def _build_plan(
2115
+ arguments: argparse.Namespace,
2116
+ root: Path,
2117
+ transport: ExplicitFTPSTransport,
2118
+ rules: RuleSet,
2119
+ *,
2120
+ direction: str,
2121
+ progress: TextIO,
2122
+ include_excluded: bool = False,
2123
+ recover_artifacts: bool = True,
2124
+ ) -> tuple[TreeSnapshot, TreeSnapshot, ComparisonPlan]:
2125
+ selector = _file_selection(arguments, root)
2126
+ prune_remote = direction == "push" and getattr(arguments, "prune", False)
2127
+ print("Scanning local files...", file=progress, flush=True)
2128
+
2129
+ def report_local_directory(directory: PurePosixPath) -> None:
2130
+ print(f" Scanning {directory.as_posix()}", file=progress, flush=True)
2131
+
2132
+ local = snapshot_local(
2133
+ root,
2134
+ rules,
2135
+ selector,
2136
+ include_excluded=include_excluded,
2137
+ traverse_excluded=False,
2138
+ respect_remote_boundaries=True,
2139
+ directory_progress=report_local_directory,
2140
+ )
2141
+ print("Reading remote files over FTPS...", file=progress, flush=True)
2142
+
2143
+ with DirectoryReadProgress(progress) as reading:
2144
+ artifact_options: dict[str, Callable[[str], None]] = {}
2145
+ if direction == "push":
2146
+ option = "artifact_recovery" if recover_artifacts else "artifact_preview"
2147
+ artifact_options[option] = reading.message
2148
+ remote = transport.snapshot(
2149
+ rules,
2150
+ selector,
2151
+ include_excluded=include_excluded,
2152
+ traverse_excluded=False,
2153
+ directory_progress=reading.reading,
2154
+ directory_counts=reading.counts,
2155
+ **artifact_options,
2156
+ )
2157
+ print("Comparing local and remote files...", file=progress, flush=True)
2158
+ comparison_selection = (
2159
+ _push_comparison_selection(arguments, root, local, selector)
2160
+ if direction == "push" and prune_remote
2161
+ else selector
2162
+ )
2163
+ plan = build_comparison(
2164
+ local,
2165
+ remote,
2166
+ direction=direction,
2167
+ prune_remote=prune_remote,
2168
+ force=getattr(arguments, "force", False),
2169
+ selector=comparison_selection if arguments.pattern_operands else None,
2170
+ )
2171
+ untraversed_directories = frozenset(
2172
+ entry.path
2173
+ for entry in plan.entries
2174
+ if _comparison_entry_kind(entry, direction) == "directory"
2175
+ and not selector.may_match_descendant(entry.path)
2176
+ and entry.action != "excluded"
2177
+ )
2178
+ plan = mark_untraversed_directories(plan, untraversed_directories)
2179
+ return local, remote, plan
2180
+
2181
+
2182
+ def _diff(
2183
+ arguments: argparse.Namespace,
2184
+ store: ConfigurationStore,
2185
+ progress: TextIO,
2186
+ output: TextIO,
2187
+ ) -> None:
2188
+ _, name, profile = _resolve_profile(arguments, store)
2189
+ root = _require_local_root(name, profile)
2190
+ if arguments.resume is not None and not arguments.paged:
2191
+ raise ConfigurationError("--resume requires --paged")
2192
+ resume = _resume_directory(arguments.resume)
2193
+ selector = _file_selection(arguments, root)
2194
+ rules = _effective_rules(store, profile)
2195
+ direction = "pull" if arguments.pull else "push"
2196
+ prune_remote = direction == "push" and arguments.prune
2197
+ color = _use_color(output)
2198
+ print(f"Checking differences for profile '{name}'...", file=progress, flush=True)
2199
+ options = _active_options(arguments)
2200
+ if options is not None:
2201
+ print(options, file=progress, flush=True)
2202
+ print("Connecting securely over FTPS...", file=progress, flush=True)
2203
+ traversal_roots = _diff_traversal_roots(arguments, root)
2204
+ if resume is not None:
2205
+ root_index = next(
2206
+ (
2207
+ index
2208
+ for index, traversal_root in enumerate(traversal_roots)
2209
+ if resume.parts[: len(traversal_root.path.parts)]
2210
+ == traversal_root.path.parts
2211
+ ),
2212
+ None,
2213
+ )
2214
+ if root_index is None:
2215
+ raise SelectionError(f"resume directory '{resume}' is not reachable")
2216
+ traversal_roots = traversal_roots[root_index:]
2217
+ pending: list[_PendingDiffDirectory | _PendingDiffOutput] = [
2218
+ _PendingDiffDirectory(
2219
+ path=traversal_root.path,
2220
+ has_local=root.joinpath(*traversal_root.path.parts).is_dir(),
2221
+ has_remote=True,
2222
+ include_container=traversal_root.include_container,
2223
+ is_root=True,
2224
+ display_root=(
2225
+ traversal_root.path if len(traversal_roots) == 1 else None
2226
+ ),
2227
+ display_anchor=traversal_root.include_container,
2228
+ )
2229
+ for traversal_root in reversed(traversal_roots)
2230
+ ]
2231
+ seeking = resume is not None
2232
+ selected_count = 0
2233
+ displayed_count = 0
2234
+ hidden: dict[str, int] = {}
2235
+ emitted_directories: set[str] = set()
2236
+
2237
+ def format_entries(
2238
+ entries: Sequence[ComparisonEntry],
2239
+ collapsed_paths: frozenset[str],
2240
+ display_root: PurePosixPath | None,
2241
+ ) -> tuple[str, ...]:
2242
+ if not arguments.show_all:
2243
+ return tuple(
2244
+ line
2245
+ for entry in entries
2246
+ for line in _format_compact_comparison_entry(
2247
+ entry,
2248
+ direction,
2249
+ color=color,
2250
+ collapsed=entry.path in collapsed_paths,
2251
+ display_root=display_root,
2252
+ emitted_directories=emitted_directories,
2253
+ )
2254
+ )
2255
+ return _format_comparison_entries(
2256
+ entries,
2257
+ direction,
2258
+ color=color,
2259
+ collapsed_paths=collapsed_paths,
2260
+ display_path=lambda path: _scoped_display_path(
2261
+ path, display_root=display_root,
2262
+ ),
2263
+ )
2264
+
2265
+ with ExplicitFTPSTransport(profile) as transport:
2266
+ if pending and arguments.show_all:
2267
+ for line in _scope_header_lines(
2268
+ pending[-1].display_root,
2269
+ anchored=pending[-1].display_anchor,
2270
+ color=color,
2271
+ ):
2272
+ print(line, file=output, flush=True)
2273
+ displayed_count += 1
2274
+ while pending:
2275
+ current = pending.pop()
2276
+ if isinstance(current, _PendingDiffOutput):
2277
+ lines = format_entries(
2278
+ (current.entry,), current.collapsed_paths, current.display_root,
2279
+ )
2280
+ for line in lines:
2281
+ print(line, file=output, flush=True)
2282
+ displayed_count += len(lines)
2283
+ continue
2284
+ directory = current.path
2285
+ display_directory = directory.as_posix()
2286
+ local_listing = (
2287
+ list_local_directory(root, directory, rules)
2288
+ if current.has_local
2289
+ else TreeSnapshot()
2290
+ )
2291
+ remote_directory_exists = current.has_remote
2292
+ try:
2293
+ remote_listing = (
2294
+ transport.list_directory(directory, rules)
2295
+ if current.has_remote
2296
+ else TreeSnapshot()
2297
+ )
2298
+ except PathOperationError:
2299
+ if not current.is_root or not directory.parts:
2300
+ raise
2301
+ remote_directory_exists = False
2302
+ remote_listing = TreeSnapshot()
2303
+ descendants = _descendant_directories(
2304
+ local_listing,
2305
+ remote_listing,
2306
+ rules,
2307
+ selector,
2308
+ descend_remote_only=prune_remote,
2309
+ )
2310
+ pending_descendants = tuple(
2311
+ _PendingDiffDirectory(
2312
+ path=path,
2313
+ has_local=local_exists,
2314
+ has_remote=remote_exists,
2315
+ include_container=False,
2316
+ is_root=False,
2317
+ display_root=current.display_root,
2318
+ display_anchor=current.display_anchor,
2319
+ )
2320
+ for path, local_exists, remote_exists in descendants
2321
+ )
2322
+
2323
+ if seeking and directory != resume:
2324
+ assert resume is not None
2325
+ current_parts = directory.parts
2326
+ if resume.parts[: len(current_parts)] != current_parts:
2327
+ raise SelectionError(
2328
+ f"resume directory '{resume}' is not reachable"
2329
+ )
2330
+ branch_length = len(current_parts) + 1
2331
+ branch = PurePosixPath(*resume.parts[:branch_length])
2332
+ matching_branch = next(
2333
+ (
2334
+ candidate
2335
+ for candidate in pending_descendants
2336
+ if candidate.path == branch
2337
+ ),
2338
+ None,
2339
+ )
2340
+ if matching_branch is None:
2341
+ raise SelectionError(
2342
+ f"resume directory '{resume}' is not reachable"
2343
+ )
2344
+ later = tuple(
2345
+ candidate
2346
+ for candidate in pending_descendants
2347
+ if candidate.path > branch
2348
+ )
2349
+ pending.extend(reversed(later))
2350
+ pending.append(matching_branch)
2351
+ continue
2352
+
2353
+ seeking = False
2354
+ local = _filtered_listing(
2355
+ local_listing,
2356
+ selector,
2357
+ )
2358
+ remote = _filtered_listing(
2359
+ remote_listing,
2360
+ selector,
2361
+ )
2362
+ if current.include_container and directory.parts:
2363
+ container_path = directory.as_posix()
2364
+ excluded = rules.excludes(
2365
+ container_path, target="local", is_directory=True
2366
+ )
2367
+ remote_excluded = rules.excludes(
2368
+ container_path, target="remote", is_directory=True
2369
+ )
2370
+ if current.has_local:
2371
+ local = TreeSnapshot(
2372
+ local.entries
2373
+ + (
2374
+ TreeEntry(
2375
+ container_path,
2376
+ "directory",
2377
+ excluded=excluded,
2378
+ remote_excluded=remote_excluded,
2379
+ ),
2380
+ )
2381
+ )
2382
+ if remote_directory_exists:
2383
+ remote = TreeSnapshot(
2384
+ remote.entries
2385
+ + (
2386
+ TreeEntry(
2387
+ container_path,
2388
+ "directory",
2389
+ excluded=excluded,
2390
+ remote_excluded=remote_excluded,
2391
+ ),
2392
+ )
2393
+ )
2394
+ selected_count += len(local.entries) + len(remote.entries)
2395
+ plan = build_comparison(
2396
+ local,
2397
+ remote,
2398
+ direction=direction,
2399
+ prune_remote=prune_remote,
2400
+ )
2401
+ descended_paths = frozenset(
2402
+ path.as_posix() for path, _, _ in descendants
2403
+ )
2404
+ collapsed_paths = frozenset(
2405
+ entry.path
2406
+ for entry in plan.entries
2407
+ if _comparison_entry_kind(entry, direction) == "directory"
2408
+ and entry.path not in descended_paths
2409
+ and entry.path != directory.as_posix()
2410
+ )
2411
+ plan = mark_untraversed_directories(plan, collapsed_paths)
2412
+ shown = tuple(
2413
+ entry
2414
+ for entry in plan.entries
2415
+ if _diff_entry_visible(entry, direction, arguments)
2416
+ )
2417
+ for entry in plan.entries:
2418
+ if entry not in shown and entry.path != directory.as_posix():
2419
+ category = _hidden_diff_category(entry)
2420
+ if category is not None:
2421
+ hidden[category] = hidden.get(category, 0) + 1
2422
+
2423
+ if arguments.paged:
2424
+ pending.extend(reversed(pending_descendants))
2425
+ lines = format_entries(shown, collapsed_paths, current.display_root)
2426
+ for line in lines:
2427
+ print(line, file=output, flush=True)
2428
+ displayed_count += len(lines)
2429
+ if not lines:
2430
+ print(
2431
+ f" nothing to {direction} in {display_directory}",
2432
+ file=output,
2433
+ flush=True,
2434
+ )
2435
+ _print_hidden_summary(hidden, output)
2436
+ if pending:
2437
+ print(
2438
+ f"Resume: {_resume_command(arguments, pending[-1].path)}",
2439
+ file=output,
2440
+ flush=True,
2441
+ )
2442
+ return
2443
+
2444
+ descendants_by_path = {
2445
+ descendant.path.as_posix(): descendant
2446
+ for descendant in pending_descendants
2447
+ }
2448
+ events: list[_PendingDiffDirectory | _PendingDiffOutput] = []
2449
+ ordered_entries = _file_browser_order(
2450
+ plan.entries,
2451
+ path_of=lambda item: item.path,
2452
+ directory_of=lambda item: (
2453
+ _comparison_entry_kind(item, direction) == "directory"
2454
+ ),
2455
+ )
2456
+ directory_entries = tuple(
2457
+ entry
2458
+ for entry in ordered_entries
2459
+ if _comparison_entry_kind(entry, direction) == "directory"
2460
+ )
2461
+ file_entries = tuple(
2462
+ entry
2463
+ for entry in ordered_entries
2464
+ if _comparison_entry_kind(entry, direction) != "directory"
2465
+ and entry in shown
2466
+ )
2467
+ for entry in directory_entries:
2468
+ if entry in shown:
2469
+ events.append(_PendingDiffOutput(
2470
+ entry, collapsed_paths, current.display_root,
2471
+ ))
2472
+ descendant = descendants_by_path.get(entry.path)
2473
+ if descendant is not None:
2474
+ events.append(descendant)
2475
+ events.extend(
2476
+ _PendingDiffOutput(entry, collapsed_paths, current.display_root)
2477
+ for entry in file_entries
2478
+ )
2479
+ scheduled_paths = {
2480
+ event.path.as_posix()
2481
+ for event in events
2482
+ if isinstance(event, _PendingDiffDirectory)
2483
+ }
2484
+ events.extend(
2485
+ descendant
2486
+ for descendant in pending_descendants
2487
+ if descendant.path.as_posix() not in scheduled_paths
2488
+ )
2489
+ pending.extend(reversed(events))
2490
+
2491
+ if seeking:
2492
+ assert resume is not None
2493
+ raise SelectionError(f"resume directory '{resume}' is not reachable")
2494
+ if arguments.pattern_operands and selected_count == 0:
2495
+ raise SelectionError(f"file selector '{selector.pattern}' matched no paths")
2496
+ if displayed_count == 0:
2497
+ print(f" nothing to {direction}", file=output, flush=True)
2498
+ _print_hidden_summary(hidden, output)
2499
+
2500
+
2501
+ _HIDDEN_DIFF_HINTS = {
2502
+ "excluded": "-x",
2503
+ "remote-only": "-a",
2504
+ "local-only": "-a",
2505
+ }
2506
+
2507
+
2508
+ def _diff_entry_visible(
2509
+ entry: ComparisonEntry,
2510
+ direction: str,
2511
+ arguments: argparse.Namespace,
2512
+ ) -> bool:
2513
+ """Show what the transfer would do; -x adds exclusions, -a everything."""
2514
+ if arguments.show_all:
2515
+ return True
2516
+ if entry.action == "excluded":
2517
+ return arguments.show_excluded
2518
+ if entry.action == "untraversed":
2519
+ # An unentered folder matters only if it could still hold changes.
2520
+ if direction == "push":
2521
+ return entry.local_kind is not None
2522
+ return entry.local_kind is not None and entry.remote_kind is not None
2523
+ return entry.action not in {"unchanged", "skip"}
2524
+
2525
+
2526
+ def _hidden_diff_category(entry: ComparisonEntry) -> str | None:
2527
+ if entry.action == "excluded":
2528
+ return "excluded"
2529
+ if entry.action in {"skip", "untraversed"}:
2530
+ if entry.local_kind is None:
2531
+ return "remote-only"
2532
+ if entry.remote_kind is None:
2533
+ return "local-only"
2534
+ return None
2535
+
2536
+
2537
+ def _print_hidden_summary(hidden: dict[str, int], output: TextIO) -> None:
2538
+ if not hidden:
2539
+ return
2540
+ parts = ", ".join(
2541
+ f"{hidden[category]} {category} ({_HIDDEN_DIFF_HINTS[category]})"
2542
+ for category in _HIDDEN_DIFF_HINTS
2543
+ if category in hidden
2544
+ )
2545
+ print(f"Not shown: {parts}", file=output, flush=True)
2546
+
2547
+
2548
+ def _format_transfer(
2549
+ name: str,
2550
+ result: TransferResult,
2551
+ *,
2552
+ dry_run: bool = False,
2553
+ ) -> str:
2554
+ direction = result.plan.direction.capitalize()
2555
+ if not result.succeeded:
2556
+ lines = [
2557
+ f"{direction} finished with errors for profile '{name}': "
2558
+ f"{result.changed_count} completed, {result.failed_count} failed, "
2559
+ f"{result.skipped_count} skipped."
2560
+ ]
2561
+ return "\n".join(lines)
2562
+ count = result.changed_count
2563
+ if count == 0:
2564
+ nothing = f" Nothing to {result.plan.direction}"
2565
+ if result.plan.direction == "push" and result.unchanged_file_count:
2566
+ files = result.unchanged_file_count
2567
+ noun = "file is" if files == 1 else "files are"
2568
+ nothing += f"; {files} {noun} up to date in this scope"
2569
+ lines = [f"{nothing}."]
2570
+ else:
2571
+ changes = f"{count} change{'s' if count != 1 else ''}"
2572
+ lines = (
2573
+ [f"Dry push: {count} planned change{'s' if count != 1 else ''}."]
2574
+ if dry_run
2575
+ else [f"{direction} complete: {changes}."]
2576
+ )
2577
+ if result.plan.direction == "pull":
2578
+ skipped = [entry for entry in result.plan.entries if entry.action == "skip"]
2579
+ if skipped:
2580
+ lines.append("Remote-only paths not restored:")
2581
+ lines.extend(f" {entry.path}" for entry in skipped)
2582
+ return "\n".join(lines)
2583
+
2584
+
2585
+ def _report_transfer_operation(
2586
+ operation: TransferOperation | TransferIssue,
2587
+ progress: TextIO,
2588
+ ) -> None:
2589
+ if isinstance(operation, TransferIssue):
2590
+ line = f"{operation.status:<7} {operation.path}: {operation.reason}"
2591
+ if _use_color(progress):
2592
+ color = "\033[31m" if operation.status == "failed" else "\033[33m"
2593
+ line = f"{color}{line}{_RESET}"
2594
+ print(f" {line}", file=progress, flush=True)
2595
+ return
2596
+ marker = _TRANSFER_MARKERS[operation.action]
2597
+ print(
2598
+ " " + _format_path_line(
2599
+ marker,
2600
+ directory=operation.kind == "directory",
2601
+ path=operation.path,
2602
+ color=_use_color(progress),
2603
+ marker_color=_DIFF_MARKER_COLORS[marker],
2604
+ excluded=False,
2605
+ ),
2606
+ file=progress,
2607
+ flush=True,
2608
+ )
2609
+
2610
+
2611
+ def _byte_label(size: int) -> str:
2612
+ value = float(size)
2613
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
2614
+ if value < 1024 or unit == "TiB":
2615
+ return f"{value:.1f} {unit}" if unit != "B" else f"{size} B"
2616
+ value /= 1024
2617
+ raise AssertionError("unreachable byte unit")
2618
+
2619
+
2620
+ class _UploadProgress:
2621
+ def __init__(
2622
+ self, output: TextIO, uploads: dict[str, int], *, dry_run: bool,
2623
+ ) -> None:
2624
+ self.output = output
2625
+ self.uploads = uploads
2626
+ self.total_bytes = sum(uploads.values())
2627
+ self.sent = 0
2628
+ self.completed = 0
2629
+ self.issues = 0
2630
+ self.last_draw = 0.0
2631
+ self.visible = False
2632
+ self.terminal = bool(
2633
+ uploads and not dry_run and output.isatty()
2634
+ and os.environ.get("TERM") != "dumb"
2635
+ )
2636
+ if uploads:
2637
+ label = "Planned uploads" if dry_run else "Uploading"
2638
+ print(
2639
+ f"{label}: {len(uploads)} files · {_byte_label(self.total_bytes)}.",
2640
+ file=output, flush=True,
2641
+ )
2642
+
2643
+ def clear(self) -> None:
2644
+ if self.visible:
2645
+ print("\r\033[2K", end="", file=self.output, flush=True)
2646
+ self.visible = False
2647
+
2648
+ def draw(self, *, force: bool = False) -> None:
2649
+ if not self.terminal:
2650
+ return
2651
+ now = time.monotonic()
2652
+ if not force and now - self.last_draw < 0.1:
2653
+ return
2654
+ self.last_draw = now
2655
+ fraction = (
2656
+ self.sent / self.total_bytes if self.total_bytes
2657
+ else self.completed / len(self.uploads)
2658
+ )
2659
+ filled = min(16, int(fraction * 16))
2660
+ bar = "#" * filled + "-" * (16 - filled)
2661
+ line = (
2662
+ f" [{bar}] {self.completed}/{len(self.uploads)} installed · "
2663
+ f"{_byte_label(self.sent)}/{_byte_label(self.total_bytes)} sent"
2664
+ )
2665
+ if self.issues:
2666
+ line += f" · {self.issues} failed/skipped"
2667
+ width = max(1, shutil.get_terminal_size().columns - 1)
2668
+ print(f"\r\033[2K{line[:width]}", end="", file=self.output, flush=True)
2669
+ self.visible = True
2670
+
2671
+ def advance(self, count: int) -> None:
2672
+ self.sent += count
2673
+ self.draw()
2674
+
2675
+ def report(self, event: TransferOperation | TransferIssue) -> None:
2676
+ self.clear()
2677
+ if event.path in self.uploads:
2678
+ if isinstance(event, TransferIssue):
2679
+ self.issues += 1
2680
+ else:
2681
+ self.completed += 1
2682
+ _report_transfer_operation(event, self.output)
2683
+ self.draw(force=True)
2684
+
2685
+ def finish(self) -> None:
2686
+ if self.terminal:
2687
+ self.draw(force=True)
2688
+ print(file=self.output, flush=True)
2689
+ self.visible = False
2690
+
2691
+
2692
+ def _transfer(
2693
+ arguments: argparse.Namespace,
2694
+ store: ConfigurationStore,
2695
+ progress: TextIO,
2696
+ ) -> tuple[str, bool]:
2697
+ _, name, profile = _resolve_profile(arguments, store)
2698
+ root = _require_local_root(name, profile)
2699
+ print(
2700
+ f"Preparing {arguments.command} for profile '{name}'...",
2701
+ file=progress,
2702
+ flush=True,
2703
+ )
2704
+ options = _active_options(arguments)
2705
+ if options is not None:
2706
+ print(options, file=progress, flush=True)
2707
+ print("Connecting securely over FTPS...", file=progress, flush=True)
2708
+ with ExplicitFTPSTransport(profile) as transport:
2709
+ local, remote, plan = _build_plan(
2710
+ arguments,
2711
+ root,
2712
+ transport,
2713
+ _effective_rules(store, profile),
2714
+ direction=arguments.command,
2715
+ progress=progress,
2716
+ include_excluded=arguments.command == "push",
2717
+ recover_artifacts=not getattr(arguments, "dry", False),
2718
+ )
2719
+ dry_run = getattr(arguments, "dry", False)
2720
+ upload_paths = {
2721
+ entry.path for entry in plan.entries
2722
+ if entry.action in {"upload", "replace-remote"}
2723
+ }
2724
+ uploads = {
2725
+ entry.path: entry.size or 0
2726
+ for entry in local.entries
2727
+ if entry.path in upload_paths
2728
+ } if arguments.command == "push" else {}
2729
+ feedback = _UploadProgress(progress, uploads, dry_run=dry_run)
2730
+ executable_actions = {
2731
+ "create-remote",
2732
+ "upload",
2733
+ "replace-remote",
2734
+ "replace-local",
2735
+ "delete-remote",
2736
+ }
2737
+ if any(entry.action in executable_actions for entry in plan.entries):
2738
+ progress_message = (
2739
+ "Dry push..."
2740
+ if dry_run
2741
+ else (
2742
+ "Pushing changes..."
2743
+ if arguments.command == "push"
2744
+ else "Pulling changes..."
2745
+ )
2746
+ )
2747
+ print(progress_message, file=progress, flush=True)
2748
+ try:
2749
+ feedback.draw(force=True)
2750
+ result = execute_transfer(
2751
+ plan,
2752
+ local_root=root,
2753
+ local=local,
2754
+ remote=remote,
2755
+ transport=transport,
2756
+ progress=feedback.report,
2757
+ dry_run=dry_run,
2758
+ byte_progress=feedback.advance if uploads and not dry_run else None,
2759
+ )
2760
+ finally:
2761
+ feedback.finish()
2762
+ return (
2763
+ _format_transfer(name, result, dry_run=dry_run),
2764
+ result.succeeded,
2765
+ )
2766
+
2767
+
2768
+ def _show_help(
2769
+ parser: argparse.ArgumentParser,
2770
+ topic: str | None,
2771
+ stdin: TextIO,
2772
+ stdout: TextIO,
2773
+ ) -> str:
2774
+ if topic is None:
2775
+ return parser.format_help().rstrip()
2776
+ subparser = next(
2777
+ action
2778
+ for action in parser._actions
2779
+ if isinstance(action, argparse._SubParsersAction)
2780
+ )
2781
+ resolved = _resolve_command_name(topic, stdin=stdin, stdout=stdout)
2782
+ return subparser.choices[resolved].format_help().rstrip()
2783
+
2784
+
2785
+ def run(
2786
+ argv: Sequence[str] | None = None,
2787
+ *,
2788
+ store: ConfigurationStore | None = None,
2789
+ stdin: TextIO = sys.stdin,
2790
+ stdout: TextIO = sys.stdout,
2791
+ stderr: TextIO = sys.stderr,
2792
+ ) -> int:
2793
+ parser = build_parser()
2794
+ raw_arguments = list(sys.argv[1:] if argv is None else argv)
2795
+ try:
2796
+ raw_arguments, profile_prefix = _resolve_invocation(
2797
+ raw_arguments,
2798
+ stdin=stdin,
2799
+ stdout=stdout,
2800
+ )
2801
+ except ConfigurationError as error:
2802
+ parser.error(str(error))
2803
+ arguments = parser.parse_args(raw_arguments)
2804
+ arguments.profile_prefix = profile_prefix
2805
+ if arguments.legend:
2806
+ if arguments.command is not None:
2807
+ parser.error("--legend cannot be combined with a command")
2808
+ print(_format_legend(stdout), file=stdout)
2809
+ return 0
2810
+ if arguments.command is None:
2811
+ parser.error("a command is required unless --legend is supplied")
2812
+ configuration_store = store or ConfigurationStore()
2813
+ exit_status = 0
2814
+ try:
2815
+ _validate_profile_prefix(arguments)
2816
+ if arguments.command == "create":
2817
+ message = _create_profile(arguments, configuration_store, stdin, stdout)
2818
+ elif arguments.command == "connect":
2819
+ message = _connect(arguments, configuration_store)
2820
+ elif arguments.command == "map":
2821
+ message = _map(arguments, configuration_store, stdin, stdout)
2822
+ elif arguments.command == "rules":
2823
+ message = _manage_rules(arguments, configuration_store)
2824
+ elif arguments.command in {"exclude", "include"}:
2825
+ message = _change_rules(
2826
+ arguments,
2827
+ configuration_store,
2828
+ include=arguments.command == "include",
2829
+ )
2830
+ elif arguments.command == "remove":
2831
+ message = _remove(arguments, configuration_store)
2832
+ elif arguments.command == "root":
2833
+ message = _show_root(arguments, configuration_store)
2834
+ elif arguments.command == "profile":
2835
+ message = _show_profile(arguments, configuration_store)
2836
+ elif arguments.command == "profiles":
2837
+ message = _list_profiles(configuration_store)
2838
+ elif arguments.command == "list":
2839
+ message = (
2840
+ _list_remote(arguments, configuration_store, stderr, stdout)
2841
+ if arguments.remote
2842
+ else _list_local(arguments, configuration_store, stdout)
2843
+ )
2844
+ elif arguments.command == "diff":
2845
+ _diff(arguments, configuration_store, stderr, stdout)
2846
+ message = None
2847
+ elif arguments.command in {"push", "pull"}:
2848
+ message, succeeded = _transfer(
2849
+ arguments,
2850
+ configuration_store,
2851
+ stderr,
2852
+ )
2853
+ if not succeeded:
2854
+ exit_status = 1
2855
+ elif arguments.command == "help":
2856
+ message = _show_help(parser, arguments.topic, stdin, stdout)
2857
+ elif arguments.command == "version":
2858
+ message = __version__
2859
+ else:
2860
+ parser.error(f"unknown command: {arguments.command}")
2861
+ return 2
2862
+ except (
2863
+ ConfigurationError,
2864
+ RuleError,
2865
+ PatternOperandError,
2866
+ SelectionError,
2867
+ SnapshotError,
2868
+ TransportError,
2869
+ TransferError,
2870
+ ) as error:
2871
+ print(f"hlsync: error: {error}", file=stderr)
2872
+ return 1
2873
+ if message is not None:
2874
+ print(message, file=stdout)
2875
+ return exit_status
2876
+
2877
+
2878
+ def main(argv: Sequence[str] | None = None) -> int:
2879
+ return run(argv)