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