hlsync 1.16.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/__init__.py +5 -0
- hlsync/__main__.py +4 -0
- hlsync/_version.py +2 -0
- hlsync/cli.py +2879 -0
- hlsync/comparison.py +278 -0
- hlsync/config.py +576 -0
- hlsync/gitignore.py +59 -0
- hlsync/pattern_operands.py +33 -0
- hlsync/progress.py +57 -0
- hlsync/rules.py +276 -0
- hlsync/selection.py +159 -0
- hlsync/snapshot.py +193 -0
- hlsync/storage.py +29 -0
- hlsync/transfer.py +465 -0
- hlsync/transport.py +761 -0
- hlsync-1.16.2.dist-info/METADATA +664 -0
- hlsync-1.16.2.dist-info/RECORD +21 -0
- hlsync-1.16.2.dist-info/WHEEL +5 -0
- hlsync-1.16.2.dist-info/entry_points.txt +2 -0
- hlsync-1.16.2.dist-info/licenses/LICENSE +133 -0
- hlsync-1.16.2.dist-info/top_level.txt +1 -0
hlsync/storage.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write_json_atomic(path: Path, document: dict[str, Any]) -> None:
|
|
11
|
+
"""Atomically replace a JSON configuration document."""
|
|
12
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
13
|
+
payload = json.dumps(document, indent=2, sort_keys=True) + "\n"
|
|
14
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
15
|
+
dir=path.parent,
|
|
16
|
+
prefix=f".{path.name}.",
|
|
17
|
+
text=True,
|
|
18
|
+
)
|
|
19
|
+
temporary_path = Path(temporary_name)
|
|
20
|
+
try:
|
|
21
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
22
|
+
os.chmod(temporary_path, 0o600)
|
|
23
|
+
handle.write(payload)
|
|
24
|
+
handle.flush()
|
|
25
|
+
os.fsync(handle.fileno())
|
|
26
|
+
temporary_path.replace(path)
|
|
27
|
+
except BaseException:
|
|
28
|
+
temporary_path.unlink(missing_ok=True)
|
|
29
|
+
raise
|
hlsync/transfer.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from hlsync.comparison import ComparisonEntry, ComparisonPlan
|
|
12
|
+
from hlsync.snapshot import EntryKind, TreeEntry, TreeSnapshot
|
|
13
|
+
from hlsync.transport import PathOperationError, RemoteTransport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TransferError(RuntimeError):
|
|
17
|
+
"""Raised when a transfer plan cannot be executed safely."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class TransferIssue:
|
|
22
|
+
path: str
|
|
23
|
+
status: str
|
|
24
|
+
reason: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class TransferOperation:
|
|
29
|
+
action: Literal["add", "update", "delete", "create"]
|
|
30
|
+
path: str
|
|
31
|
+
kind: EntryKind
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
TransferProgress = Callable[[TransferOperation | TransferIssue], None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _record_issue(
|
|
38
|
+
issues: list[TransferIssue],
|
|
39
|
+
issue: TransferIssue,
|
|
40
|
+
progress: TransferProgress | None,
|
|
41
|
+
) -> None:
|
|
42
|
+
issues.append(issue)
|
|
43
|
+
if progress is not None:
|
|
44
|
+
progress(issue)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class TransferResult:
|
|
49
|
+
plan: ComparisonPlan
|
|
50
|
+
completed_paths: frozenset[str]
|
|
51
|
+
issues: tuple[TransferIssue, ...] = ()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def changed_count(self) -> int:
|
|
55
|
+
return len(self.completed_paths)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def unchanged_file_count(self) -> int:
|
|
59
|
+
return sum(
|
|
60
|
+
entry.action == "unchanged" and entry.local_kind == "file"
|
|
61
|
+
for entry in self.plan.entries
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def failed_count(self) -> int:
|
|
66
|
+
return sum(issue.status == "failed" for issue in self.issues)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def skipped_count(self) -> int:
|
|
70
|
+
return sum(issue.status == "skipped" for issue in self.issues)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def succeeded(self) -> bool:
|
|
74
|
+
return not self.issues
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _entry_map(snapshot: TreeSnapshot) -> dict[str, TreeEntry]:
|
|
78
|
+
return {entry.path: entry for entry in snapshot.entries}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _preflight(plan: ComparisonPlan, local_root: Path, local: TreeSnapshot) -> None:
|
|
82
|
+
conflicts = [entry for entry in plan.entries if entry.action == "conflict"]
|
|
83
|
+
if conflicts:
|
|
84
|
+
message = "transfer has unresolved conflict(s): " + ", ".join(
|
|
85
|
+
entry.path for entry in conflicts
|
|
86
|
+
)
|
|
87
|
+
if any(entry.state == "destination-newer" for entry in conflicts):
|
|
88
|
+
message += (
|
|
89
|
+
"; destination files not older than their source are kept"
|
|
90
|
+
" unless --force is given"
|
|
91
|
+
)
|
|
92
|
+
raise TransferError(message)
|
|
93
|
+
local_entries = _entry_map(local)
|
|
94
|
+
for entry in plan.entries:
|
|
95
|
+
if entry.action not in {"upload", "replace-remote", "replace-local"}:
|
|
96
|
+
continue
|
|
97
|
+
expected = local_entries[entry.path]
|
|
98
|
+
path = local_root / Path(*PurePosixPath(entry.path).parts)
|
|
99
|
+
try:
|
|
100
|
+
current = path.stat(follow_symlinks=False)
|
|
101
|
+
except OSError as error:
|
|
102
|
+
raise TransferError(
|
|
103
|
+
f"could not validate local source '{path}': {error}"
|
|
104
|
+
) from error
|
|
105
|
+
if not stat.S_ISREG(current.st_mode):
|
|
106
|
+
raise TransferError(f"local source is no longer a file: {path}")
|
|
107
|
+
if (
|
|
108
|
+
current.st_size != expected.size
|
|
109
|
+
or current.st_mtime_ns != expected.modified_ns
|
|
110
|
+
):
|
|
111
|
+
raise TransferError(f"local source changed after snapshot: {path}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _remote_directories_to_create(
|
|
115
|
+
plan: ComparisonPlan,
|
|
116
|
+
remote: TreeSnapshot,
|
|
117
|
+
) -> tuple[str, ...]:
|
|
118
|
+
existing = {
|
|
119
|
+
entry.path for entry in remote.entries if entry.kind == "directory"
|
|
120
|
+
}
|
|
121
|
+
required = {
|
|
122
|
+
entry.path
|
|
123
|
+
for entry in plan.entries
|
|
124
|
+
if entry.action == "create-remote"
|
|
125
|
+
}
|
|
126
|
+
for entry in plan.entries:
|
|
127
|
+
if entry.action not in {"upload", "replace-remote"}:
|
|
128
|
+
continue
|
|
129
|
+
parent = PurePosixPath(entry.path).parent
|
|
130
|
+
while parent.parts:
|
|
131
|
+
if parent.as_posix() not in existing:
|
|
132
|
+
required.add(parent.as_posix())
|
|
133
|
+
parent = parent.parent
|
|
134
|
+
return tuple(
|
|
135
|
+
sorted(
|
|
136
|
+
required,
|
|
137
|
+
key=lambda path: (len(PurePosixPath(path).parts), path),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def planned_transfer_operations(
|
|
143
|
+
plan: ComparisonPlan,
|
|
144
|
+
remote: TreeSnapshot,
|
|
145
|
+
) -> tuple[TransferOperation, ...]:
|
|
146
|
+
"""Return successful-path operations in their execution order."""
|
|
147
|
+
if plan.direction == "pull":
|
|
148
|
+
return tuple(
|
|
149
|
+
TransferOperation("update", entry.path, "file")
|
|
150
|
+
for entry in plan.entries
|
|
151
|
+
if entry.action == "replace-local"
|
|
152
|
+
)
|
|
153
|
+
creations = tuple(
|
|
154
|
+
TransferOperation("create", path, "directory")
|
|
155
|
+
for path in _remote_directories_to_create(plan, remote)
|
|
156
|
+
)
|
|
157
|
+
uploads = tuple(
|
|
158
|
+
TransferOperation(
|
|
159
|
+
"add" if entry.action == "upload" else "update",
|
|
160
|
+
entry.path,
|
|
161
|
+
"file",
|
|
162
|
+
)
|
|
163
|
+
for entry in plan.entries
|
|
164
|
+
if entry.action in {"upload", "replace-remote"}
|
|
165
|
+
)
|
|
166
|
+
deletions = tuple(
|
|
167
|
+
entry for entry in plan.entries if entry.action == "delete-remote"
|
|
168
|
+
)
|
|
169
|
+
files = sorted(
|
|
170
|
+
(entry for entry in deletions if entry.remote_kind != "directory"),
|
|
171
|
+
key=lambda entry: entry.path,
|
|
172
|
+
)
|
|
173
|
+
directories = sorted(
|
|
174
|
+
(entry for entry in deletions if entry.remote_kind == "directory"),
|
|
175
|
+
key=lambda entry: (-len(PurePosixPath(entry.path).parts), entry.path),
|
|
176
|
+
)
|
|
177
|
+
deletes = tuple(
|
|
178
|
+
TransferOperation("delete", entry.path, entry.remote_kind or "file")
|
|
179
|
+
for entry in (*files, *directories)
|
|
180
|
+
)
|
|
181
|
+
return creations + uploads + deletes
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _push_files(
|
|
185
|
+
plan: ComparisonPlan,
|
|
186
|
+
local_root: Path,
|
|
187
|
+
local: TreeSnapshot,
|
|
188
|
+
transport: RemoteTransport,
|
|
189
|
+
progress: TransferProgress | None,
|
|
190
|
+
operations: tuple[TransferOperation, ...],
|
|
191
|
+
dry_run: bool,
|
|
192
|
+
byte_progress: Callable[[int], None] | None,
|
|
193
|
+
) -> tuple[set[str], list[TransferIssue]]:
|
|
194
|
+
local_entries = _entry_map(local)
|
|
195
|
+
completed: set[str] = set()
|
|
196
|
+
issues: list[TransferIssue] = []
|
|
197
|
+
unavailable_directories: set[str] = set()
|
|
198
|
+
planned_creations = {
|
|
199
|
+
entry.path
|
|
200
|
+
for entry in plan.entries
|
|
201
|
+
if entry.action == "create-remote"
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
def unavailable_parent(path: str) -> str | None:
|
|
205
|
+
candidate = PurePosixPath(path)
|
|
206
|
+
return next(
|
|
207
|
+
(
|
|
208
|
+
directory
|
|
209
|
+
for directory in sorted(unavailable_directories)
|
|
210
|
+
if PurePosixPath(directory) == candidate
|
|
211
|
+
or PurePosixPath(directory) in candidate.parents
|
|
212
|
+
),
|
|
213
|
+
None,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
for operation in (item for item in operations if item.action == "create"):
|
|
217
|
+
directory = operation.path
|
|
218
|
+
blocked_by = unavailable_parent(directory)
|
|
219
|
+
if blocked_by is not None:
|
|
220
|
+
unavailable_directories.add(directory)
|
|
221
|
+
if directory in planned_creations:
|
|
222
|
+
_record_issue(
|
|
223
|
+
issues,
|
|
224
|
+
TransferIssue(
|
|
225
|
+
directory,
|
|
226
|
+
"skipped",
|
|
227
|
+
f"parent directory '{blocked_by}' could not be created",
|
|
228
|
+
),
|
|
229
|
+
progress,
|
|
230
|
+
)
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
if not dry_run:
|
|
234
|
+
transport.make_directory(directory)
|
|
235
|
+
except PathOperationError as error:
|
|
236
|
+
unavailable_directories.add(directory)
|
|
237
|
+
_record_issue(
|
|
238
|
+
issues, TransferIssue(directory, "failed", str(error)), progress
|
|
239
|
+
)
|
|
240
|
+
continue
|
|
241
|
+
if progress is not None:
|
|
242
|
+
progress(operation)
|
|
243
|
+
if directory in planned_creations:
|
|
244
|
+
completed.add(directory)
|
|
245
|
+
for operation in (
|
|
246
|
+
item for item in operations if item.action in {"add", "update"}
|
|
247
|
+
):
|
|
248
|
+
blocked_by = unavailable_parent(operation.path)
|
|
249
|
+
if blocked_by is not None:
|
|
250
|
+
_record_issue(
|
|
251
|
+
issues,
|
|
252
|
+
TransferIssue(
|
|
253
|
+
operation.path,
|
|
254
|
+
"skipped",
|
|
255
|
+
f"parent directory '{blocked_by}' is unavailable",
|
|
256
|
+
),
|
|
257
|
+
progress,
|
|
258
|
+
)
|
|
259
|
+
continue
|
|
260
|
+
metadata = local_entries[operation.path]
|
|
261
|
+
if metadata.size is None or metadata.modified_ns is None:
|
|
262
|
+
raise TransferError(
|
|
263
|
+
f"local file metadata is incomplete: {operation.path}"
|
|
264
|
+
)
|
|
265
|
+
path = local_root / Path(*PurePosixPath(operation.path).parts)
|
|
266
|
+
try:
|
|
267
|
+
if not dry_run:
|
|
268
|
+
with path.open("rb") as source:
|
|
269
|
+
transport.upload_file(
|
|
270
|
+
source,
|
|
271
|
+
operation.path,
|
|
272
|
+
size=metadata.size,
|
|
273
|
+
modified_ns=metadata.modified_ns,
|
|
274
|
+
replace=operation.action == "update",
|
|
275
|
+
byte_progress=byte_progress,
|
|
276
|
+
)
|
|
277
|
+
except PathOperationError as error:
|
|
278
|
+
_record_issue(
|
|
279
|
+
issues, TransferIssue(operation.path, "failed", str(error)), progress
|
|
280
|
+
)
|
|
281
|
+
continue
|
|
282
|
+
except OSError as error:
|
|
283
|
+
_record_issue(
|
|
284
|
+
issues,
|
|
285
|
+
TransferIssue(
|
|
286
|
+
operation.path,
|
|
287
|
+
"failed",
|
|
288
|
+
f"could not read local file '{path}': {error}",
|
|
289
|
+
),
|
|
290
|
+
progress,
|
|
291
|
+
)
|
|
292
|
+
continue
|
|
293
|
+
completed.add(operation.path)
|
|
294
|
+
if progress is not None:
|
|
295
|
+
progress(operation)
|
|
296
|
+
return completed, issues
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _replace_local_file(
|
|
300
|
+
root: Path,
|
|
301
|
+
comparison: ComparisonEntry,
|
|
302
|
+
local_entry: TreeEntry,
|
|
303
|
+
remote_entry: TreeEntry,
|
|
304
|
+
transport: RemoteTransport,
|
|
305
|
+
) -> None:
|
|
306
|
+
destination = root / Path(*PurePosixPath(comparison.path).parts)
|
|
307
|
+
try:
|
|
308
|
+
mode = stat.S_IMODE(destination.stat(follow_symlinks=False).st_mode)
|
|
309
|
+
except OSError as error:
|
|
310
|
+
raise TransferError(
|
|
311
|
+
f"could not inspect local destination '{destination}': {error}"
|
|
312
|
+
) from error
|
|
313
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
314
|
+
dir=destination.parent,
|
|
315
|
+
prefix=f".{destination.name}.hlsync-pull-",
|
|
316
|
+
)
|
|
317
|
+
temporary = Path(temporary_name)
|
|
318
|
+
try:
|
|
319
|
+
with os.fdopen(descriptor, "wb") as target:
|
|
320
|
+
transport.download_file(comparison.path, target)
|
|
321
|
+
target.flush()
|
|
322
|
+
os.fsync(target.fileno())
|
|
323
|
+
actual_size = temporary.stat().st_size
|
|
324
|
+
if actual_size != remote_entry.size:
|
|
325
|
+
raise TransferError(
|
|
326
|
+
f"download size mismatch for '{comparison.path}': expected "
|
|
327
|
+
f"{remote_entry.size}, got {actual_size}"
|
|
328
|
+
)
|
|
329
|
+
os.chmod(temporary, mode)
|
|
330
|
+
if remote_entry.modified_ns is None:
|
|
331
|
+
raise TransferError(
|
|
332
|
+
f"remote file timestamp is missing: {comparison.path}"
|
|
333
|
+
)
|
|
334
|
+
os.utime(
|
|
335
|
+
temporary,
|
|
336
|
+
ns=(remote_entry.modified_ns, remote_entry.modified_ns),
|
|
337
|
+
)
|
|
338
|
+
current = destination.stat(follow_symlinks=False)
|
|
339
|
+
if (
|
|
340
|
+
not stat.S_ISREG(current.st_mode)
|
|
341
|
+
or current.st_size != local_entry.size
|
|
342
|
+
or current.st_mtime_ns != local_entry.modified_ns
|
|
343
|
+
):
|
|
344
|
+
raise TransferError(
|
|
345
|
+
f"local destination changed during download: {destination}"
|
|
346
|
+
)
|
|
347
|
+
temporary.replace(destination)
|
|
348
|
+
except BaseException:
|
|
349
|
+
temporary.unlink(missing_ok=True)
|
|
350
|
+
raise
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _pull_files(
|
|
354
|
+
plan: ComparisonPlan,
|
|
355
|
+
local_root: Path,
|
|
356
|
+
local: TreeSnapshot,
|
|
357
|
+
remote: TreeSnapshot,
|
|
358
|
+
transport: RemoteTransport,
|
|
359
|
+
progress: TransferProgress | None,
|
|
360
|
+
operations: tuple[TransferOperation, ...],
|
|
361
|
+
) -> set[str]:
|
|
362
|
+
completed: set[str] = set()
|
|
363
|
+
local_entries = _entry_map(local)
|
|
364
|
+
remote_entries = _entry_map(remote)
|
|
365
|
+
entries = {entry.path: entry for entry in plan.entries}
|
|
366
|
+
for operation in operations:
|
|
367
|
+
entry = entries[operation.path]
|
|
368
|
+
_replace_local_file(
|
|
369
|
+
local_root,
|
|
370
|
+
entry,
|
|
371
|
+
local_entries[operation.path],
|
|
372
|
+
remote_entries[operation.path],
|
|
373
|
+
transport,
|
|
374
|
+
)
|
|
375
|
+
completed.add(operation.path)
|
|
376
|
+
if progress is not None:
|
|
377
|
+
progress(operation)
|
|
378
|
+
return completed
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _delete_remote(
|
|
382
|
+
transport: RemoteTransport,
|
|
383
|
+
progress: TransferProgress | None,
|
|
384
|
+
operations: tuple[TransferOperation, ...],
|
|
385
|
+
dry_run: bool,
|
|
386
|
+
) -> tuple[set[str], list[TransferIssue]]:
|
|
387
|
+
completed: set[str] = set()
|
|
388
|
+
issues: list[TransferIssue] = []
|
|
389
|
+
for operation in (item for item in operations if item.action == "delete"):
|
|
390
|
+
try:
|
|
391
|
+
if not dry_run:
|
|
392
|
+
transport.delete_path(
|
|
393
|
+
operation.path,
|
|
394
|
+
is_directory=operation.kind == "directory",
|
|
395
|
+
)
|
|
396
|
+
except PathOperationError as error:
|
|
397
|
+
_record_issue(
|
|
398
|
+
issues, TransferIssue(operation.path, "failed", str(error)), progress
|
|
399
|
+
)
|
|
400
|
+
continue
|
|
401
|
+
completed.add(operation.path)
|
|
402
|
+
if progress is not None:
|
|
403
|
+
progress(operation)
|
|
404
|
+
return completed, issues
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def execute_transfer(
|
|
408
|
+
plan: ComparisonPlan,
|
|
409
|
+
*,
|
|
410
|
+
local_root: Path,
|
|
411
|
+
local: TreeSnapshot,
|
|
412
|
+
remote: TreeSnapshot,
|
|
413
|
+
transport: RemoteTransport,
|
|
414
|
+
progress: TransferProgress | None = None,
|
|
415
|
+
dry_run: bool = False,
|
|
416
|
+
byte_progress: Callable[[int], None] | None = None,
|
|
417
|
+
) -> TransferResult:
|
|
418
|
+
if dry_run and plan.direction != "push":
|
|
419
|
+
raise TransferError("dry run is supported only for push")
|
|
420
|
+
_preflight(plan, local_root, local)
|
|
421
|
+
operations = planned_transfer_operations(plan, remote)
|
|
422
|
+
if plan.direction == "push":
|
|
423
|
+
completed, issues = _push_files(
|
|
424
|
+
plan,
|
|
425
|
+
local_root,
|
|
426
|
+
local,
|
|
427
|
+
transport,
|
|
428
|
+
progress,
|
|
429
|
+
operations,
|
|
430
|
+
dry_run,
|
|
431
|
+
byte_progress,
|
|
432
|
+
)
|
|
433
|
+
else:
|
|
434
|
+
completed = _pull_files(
|
|
435
|
+
plan,
|
|
436
|
+
local_root,
|
|
437
|
+
local,
|
|
438
|
+
remote,
|
|
439
|
+
transport,
|
|
440
|
+
progress,
|
|
441
|
+
operations,
|
|
442
|
+
)
|
|
443
|
+
issues = []
|
|
444
|
+
if issues:
|
|
445
|
+
for entry in plan.entries:
|
|
446
|
+
if entry.action == "delete-remote":
|
|
447
|
+
_record_issue(
|
|
448
|
+
issues,
|
|
449
|
+
TransferIssue(
|
|
450
|
+
entry.path,
|
|
451
|
+
"skipped",
|
|
452
|
+
"remote pruning was suppressed after another transfer failed",
|
|
453
|
+
),
|
|
454
|
+
progress,
|
|
455
|
+
)
|
|
456
|
+
else:
|
|
457
|
+
deleted, deletion_issues = _delete_remote(
|
|
458
|
+
transport,
|
|
459
|
+
progress,
|
|
460
|
+
operations,
|
|
461
|
+
dry_run,
|
|
462
|
+
)
|
|
463
|
+
completed.update(deleted)
|
|
464
|
+
issues.extend(deletion_issues)
|
|
465
|
+
return TransferResult(plan, frozenset(completed), tuple(issues))
|