asanypath 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
asanypath/local.py ADDED
@@ -0,0 +1,817 @@
1
+ # Copyright (C) 2026 Axis Communications AB, Lund, Sweden
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ import os
11
+ import shutil
12
+ import typing
13
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
14
+ from pathlib import Path
15
+
16
+ from anyio import Path as AnyIOPath
17
+ from anyio import to_thread
18
+
19
+ from asanypath._transfer import (
20
+ _DEFAULT_CHUNK_SIZE,
21
+ _atomic_temp_path,
22
+ _commit_temp_sync,
23
+ _validate_chunk_size,
24
+ async_destination_state,
25
+ has_async_transfer_api,
26
+ has_write_api,
27
+ is_remote_destination,
28
+ local_copy_file_sync,
29
+ require_force,
30
+ run_sync_maybe,
31
+ run_transfer_async,
32
+ run_transfer_sync,
33
+ sync_destination_state,
34
+ )
35
+ from asanypath.common import CommonPurePathMixin
36
+ from asanypath.options import (
37
+ AccessAction,
38
+ AccessGrant,
39
+ AccessPolicy,
40
+ AccessPolicyPatch,
41
+ BackendOptions,
42
+ )
43
+
44
+ # Files smaller than this are read/written synchronously to avoid thread pool overhead.
45
+ # 1 MiB is well within the kernel page cache for typical workloads.
46
+ _SYNC_THRESHOLD = 1 * 1024 * 1024
47
+
48
+
49
+ # SyncPath/AsyncPath always store a pathlib.Path in _path (protocol="file"),
50
+ # unlike the cloud backends whose CommonPurePathMixin stores a yarl URL.
51
+ S: typing.TypeAlias = "SyncPath"
52
+ T: typing.TypeAlias = "AsyncPath"
53
+
54
+
55
+ async def _local_copy_file_async(
56
+ src_path: Path, dst_path: Path, *, chunk_size: int | None, atomic: bool, force: bool
57
+ ) -> int:
58
+ return await to_thread.run_sync(
59
+ lambda: local_copy_file_sync(
60
+ src_path, dst_path, chunk_size=chunk_size, atomic=atomic, force=force
61
+ )
62
+ )
63
+
64
+
65
+ def _looks_remote(dst) -> bool:
66
+ return is_remote_destination(dst)
67
+
68
+
69
+ def _resolve_sync_remote_dst(dst):
70
+ if not _looks_remote(dst):
71
+ return None
72
+ if has_write_api(dst):
73
+ return dst
74
+ from asanypath.sync import AsAnyPath
75
+
76
+ return AsAnyPath(str(dst))
77
+
78
+
79
+ def _resolve_async_remote_dst(dst):
80
+ if not _looks_remote(dst):
81
+ return None
82
+ if has_async_transfer_api(dst):
83
+ return dst
84
+ from asanypath import AsAnyPath
85
+
86
+ return AsAnyPath(str(dst))
87
+
88
+
89
+ def _dir_total_bytes(path: Path) -> int:
90
+ total = 0
91
+ for root, _, files in os.walk(path):
92
+ base = Path(root)
93
+ for name in files:
94
+ try:
95
+ total += (base / name).stat().st_size
96
+ except OSError:
97
+ pass
98
+ return total
99
+
100
+
101
+ def _follow_symlinks(backend_options: BackendOptions | None) -> bool:
102
+ if backend_options is None:
103
+ return True
104
+ follow_symlinks = backend_options.provider.get("follow_symlinks", True)
105
+ if not isinstance(follow_symlinks, bool):
106
+ raise TypeError("backend_options.provider['follow_symlinks'] must be a bool")
107
+ return follow_symlinks
108
+
109
+
110
+ def _access_policy(path: Path, *, follow_symlinks: bool) -> AccessPolicy:
111
+ stat = os.stat(path, follow_symlinks=follow_symlinks)
112
+ action_bits: tuple[tuple[AccessAction, int], ...] = (
113
+ ("read", 4),
114
+ ("write", 2),
115
+ ("execute", 1),
116
+ )
117
+
118
+ def actions(shift: int) -> frozenset[AccessAction]:
119
+ return frozenset(action for action, bit in action_bits if stat.st_mode >> shift & bit)
120
+
121
+ try:
122
+ import grp
123
+ import pwd
124
+
125
+ owner = pwd.getpwuid(stat.st_uid).pw_name
126
+ group = grp.getgrgid(stat.st_gid).gr_name
127
+ except (ImportError, KeyError):
128
+ owner = group = None
129
+
130
+ return AccessPolicy(
131
+ owner=owner,
132
+ group=group,
133
+ grants=(
134
+ AccessGrant("owner", actions(6)),
135
+ AccessGrant("group", actions(3)),
136
+ AccessGrant("everyone", actions(0)),
137
+ ),
138
+ )
139
+
140
+
141
+ def _update_access_policy(
142
+ path: Path, policy_patch: AccessPolicyPatch, *, follow_symlinks: bool
143
+ ) -> None:
144
+ if policy_patch.owner is not None or policy_patch.group is not None:
145
+ import grp
146
+ import pwd
147
+
148
+ uid = -1 if policy_patch.owner is None else pwd.getpwnam(policy_patch.owner).pw_uid
149
+ gid = -1 if policy_patch.group is None else grp.getgrnam(policy_patch.group).gr_gid
150
+ os.chown(path, uid, gid, follow_symlinks=follow_symlinks)
151
+
152
+ mode = os.stat(path, follow_symlinks=follow_symlinks).st_mode
153
+ shifts = {"owner": 6, "group": 3, "everyone": 0}
154
+ for grant in policy_patch.grants:
155
+ bits = sum({"read": 4, "write": 2, "execute": 1}[action] for action in grant.actions)
156
+ shift = shifts[grant.principal]
157
+ mode = mode & ~(0o7 << shift) | bits << shift
158
+ os.chmod(path, mode, follow_symlinks=follow_symlinks)
159
+
160
+
161
+ class SyncPath(CommonPurePathMixin):
162
+ """Sync path implementation for local filesystem — zero async overhead."""
163
+
164
+ protocol: str = "file" # type: ignore
165
+ _path: Path
166
+
167
+ @classmethod
168
+ def _cast(cls, path: Path) -> S:
169
+ """Fast internal constructor from existing pathlib.Path (no parsing)."""
170
+ obj = object.__new__(cls)
171
+ obj._path = path
172
+ return obj
173
+
174
+ def __fspath__(self) -> str:
175
+ return str(self._path)
176
+
177
+ def __getattr__(self, name):
178
+ return getattr(self._path, name)
179
+
180
+ def iterdir(self) -> Generator[S]:
181
+ for entry in self._path.iterdir():
182
+ yield self._cast(entry)
183
+
184
+ def glob(self, *args, **kwargs) -> Generator[S]:
185
+ for item in self._path.glob(*args, **kwargs):
186
+ yield self._cast(item)
187
+
188
+ def rglob(self, *args, **kwargs) -> Generator[S]:
189
+ for item in self._path.rglob(*args, **kwargs):
190
+ yield self._cast(item)
191
+
192
+ def walk(self, *args, **kwargs) -> Generator[tuple[S, list[str], list[str]]]:
193
+ for root, dirs, files in os.walk(self._path, *args, **kwargs):
194
+ yield self._cast(Path(root)), dirs, files
195
+
196
+ def expanduser(self) -> S:
197
+ return self._cast(self._path.expanduser())
198
+
199
+ def get_access_policy(self, *, backend_options: BackendOptions | None = None) -> AccessPolicy:
200
+ return _access_policy(self._path, follow_symlinks=_follow_symlinks(backend_options))
201
+
202
+ def update_access_policy(
203
+ self, policy_patch: AccessPolicyPatch, *, backend_options: BackendOptions | None = None
204
+ ) -> None:
205
+ _update_access_policy(
206
+ self._path, policy_patch, follow_symlinks=_follow_symlinks(backend_options)
207
+ )
208
+
209
+ def resolve(self, strict=False) -> S:
210
+ return self._cast(self._path.resolve(strict=strict))
211
+
212
+ def rename(self, target, *, force: bool = False, atomic: bool = False) -> S:
213
+ remote_target = _resolve_sync_remote_dst(target)
214
+ if remote_target is not None:
215
+ return self.copy(
216
+ remote_target,
217
+ recursive=self._path.is_dir(),
218
+ remove_src=True,
219
+ force=force,
220
+ atomic=atomic,
221
+ )
222
+ # Use target._path directly when available so the returned SyncPath
223
+ # stores the identical Path object — avoids Python 3.14+ differences
224
+ # in what Path.rename() returns via with_segments().
225
+ target_path = target._path if isinstance(target, SyncPath) else Path(str(target))
226
+ target_sync = self._cast(target_path)
227
+ needs_copy, same_path, destination_exists = sync_destination_state(self, target_sync)
228
+ if not needs_copy:
229
+ if same_path:
230
+ return target_sync
231
+ self._path.unlink()
232
+ return target_sync
233
+ if destination_exists and not force:
234
+ require_force(force, target_sync)
235
+ self._path.rename(target_path)
236
+ return target_sync
237
+
238
+ def move(self, target, *, force: bool = False, atomic: bool = False) -> S:
239
+ return self.rename(target, force=force, atomic=atomic)
240
+
241
+ def replace(self, target) -> S:
242
+ return self._cast(self._path.replace(target))
243
+
244
+ def readlink(self) -> S:
245
+ return self._cast(self._path.readlink())
246
+
247
+ def iter_bytes(self, chunk_size: int | None = None) -> Generator[bytes]:
248
+ """Yield file contents in byte chunks (``chunk_size=None`` uses a default)."""
249
+ if chunk_size is not None and chunk_size <= 0:
250
+ raise ValueError("chunk_size must be None or a positive integer")
251
+ if chunk_size is None:
252
+ chunk_size = _DEFAULT_CHUNK_SIZE
253
+ with self._path.open("rb") as f:
254
+ while chunk := f.read(chunk_size):
255
+ yield chunk
256
+
257
+ def checksums(self) -> dict[str, str]:
258
+ """Compute MD5, SHA-1 and SHA-256 checksums of a local file."""
259
+ from hashlib import md5, sha1, sha256
260
+
261
+ data = self._path.read_bytes()
262
+ return {
263
+ "md5": md5(data).hexdigest(),
264
+ "sha1": sha1(data).hexdigest(),
265
+ "sha256": sha256(data).hexdigest(),
266
+ }
267
+
268
+ def copy(
269
+ self,
270
+ dst,
271
+ *,
272
+ recursive: bool = False,
273
+ remove_src: bool = False,
274
+ force: bool = False,
275
+ atomic: bool = False,
276
+ chunk_size: int | None = None,
277
+ on_progress: Callable[[int, int, SyncPath, SyncPath], None] | None = None,
278
+ on_done: Callable[[SyncPath, SyncPath, int, bool, BaseException | None], None]
279
+ | None = None,
280
+ ) -> S:
281
+ _validate_chunk_size(chunk_size)
282
+ remote_dst = _resolve_sync_remote_dst(dst)
283
+ if remote_dst is not None:
284
+ if atomic:
285
+ raise NotImplementedError("atomic=True is only supported for local destinations")
286
+ # chunk_size (a local streaming hint) is ignored for cloud destinations.
287
+
288
+ def _copy_remote_file() -> int:
289
+ needs_copy, same_path, destination_exists = sync_destination_state(self, remote_dst)
290
+ if not needs_copy:
291
+ return 0
292
+ if destination_exists and not force:
293
+ require_force(force, remote_dst)
294
+ run_sync_maybe(remote_dst.parent.mkdir(parents=True, exist_ok=True))
295
+ data = self._path.read_bytes()
296
+ written = run_sync_maybe(remote_dst.write_bytes(data))
297
+ return written if isinstance(written, int) else len(data)
298
+
299
+ def _copy_remote_tree() -> int:
300
+ total = 0
301
+ for root, _, files in os.walk(self._path):
302
+ relative_root = Path(root).relative_to(self._path)
303
+ for name in files:
304
+ source_file = self._cast(Path(root) / name)
305
+ relative = relative_root / name
306
+ destination_file = remote_dst / relative.as_posix()
307
+ needs_copy, _, destination_exists = sync_destination_state(
308
+ source_file, destination_file
309
+ )
310
+ if not needs_copy:
311
+ continue
312
+ if destination_exists and not force:
313
+ require_force(force, destination_file)
314
+ run_sync_maybe(destination_file.parent.mkdir(parents=True, exist_ok=True))
315
+ data = source_file._path.read_bytes()
316
+ written = run_sync_maybe(destination_file.write_bytes(data))
317
+ total += written if isinstance(written, int) else len(data)
318
+ return total
319
+
320
+ if self._path.is_dir():
321
+ if not recursive:
322
+ raise IsADirectoryError(21, f"Is a directory: '{self}' (use recursive=True)")
323
+ return run_transfer_sync(
324
+ src=self,
325
+ dst=remote_dst,
326
+ copy_fn=_copy_remote_tree,
327
+ remove_fn=lambda: shutil.rmtree(self._path),
328
+ remove_src=remove_src,
329
+ on_progress=on_progress,
330
+ on_done=on_done,
331
+ )
332
+
333
+ return run_transfer_sync(
334
+ src=self,
335
+ dst=remote_dst,
336
+ copy_fn=_copy_remote_file,
337
+ remove_fn=self._path.unlink,
338
+ remove_src=remove_src,
339
+ on_progress=on_progress,
340
+ on_done=on_done,
341
+ )
342
+
343
+ dst_path = Path(str(dst))
344
+ dst_sync = self._cast(dst_path)
345
+
346
+ if recursive and self._path.is_dir():
347
+ same_path = str(self) == str(dst_sync)
348
+
349
+ def _copy_tree() -> int:
350
+ for root, dirs, files in os.walk(self._path):
351
+ relative = Path(root).relative_to(self._path)
352
+ destination_root = dst_path / relative
353
+ destination_root.mkdir(parents=True, exist_ok=True)
354
+ for directory in dirs:
355
+ (destination_root / directory).mkdir(exist_ok=True)
356
+ for name in files:
357
+ source_file = self._cast(Path(root) / name)
358
+ destination_file = self._cast(destination_root / name)
359
+ needs_copy, same_path, destination_exists = sync_destination_state(
360
+ source_file, destination_file
361
+ )
362
+ if not needs_copy:
363
+ continue
364
+ if destination_exists and not force:
365
+ require_force(force, destination_file)
366
+ local_copy_file_sync(
367
+ source_file._path,
368
+ destination_file._path,
369
+ chunk_size=chunk_size,
370
+ atomic=atomic,
371
+ force=force,
372
+ )
373
+ return _dir_total_bytes(self._path)
374
+
375
+ return run_transfer_sync(
376
+ src=self,
377
+ dst=dst_sync,
378
+ copy_fn=_copy_tree,
379
+ remove_fn=lambda: shutil.rmtree(self._path),
380
+ remove_src=remove_src and not same_path,
381
+ on_progress=on_progress,
382
+ on_done=on_done,
383
+ )
384
+
385
+ if self._path.is_dir():
386
+ raise IsADirectoryError(21, f"Is a directory: '{self}' (use recursive=True)")
387
+
388
+ def _copy_file() -> int:
389
+ needs_copy, same_path, destination_exists = sync_destination_state(self, dst_sync)
390
+ if not needs_copy:
391
+ return 0
392
+ if destination_exists and not force:
393
+ require_force(force, dst_sync)
394
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
395
+ return local_copy_file_sync(
396
+ self._path, dst_path, chunk_size=chunk_size, atomic=atomic, force=force
397
+ )
398
+
399
+ return run_transfer_sync(
400
+ src=self,
401
+ dst=dst_sync,
402
+ copy_fn=_copy_file,
403
+ remove_fn=self._path.unlink,
404
+ remove_src=remove_src and str(self) != str(dst_sync),
405
+ on_progress=on_progress,
406
+ on_done=on_done,
407
+ )
408
+
409
+ def rmdir(self, *, recursive: bool = False) -> None:
410
+ if recursive:
411
+ shutil.rmtree(self._path)
412
+ else:
413
+ self._path.rmdir()
414
+
415
+
416
+ class AsyncPath(CommonPurePathMixin, AnyIOPath):
417
+ """Async path implementation for local filesystem."""
418
+
419
+ protocol: str = "file" # type: ignore
420
+ _path: Path # type: ignore[assignment]
421
+
422
+ def __init__(self, *parts, limiter=None) -> None:
423
+ # AnyIOPath constructors and methods (cwd/home/absolute/...) pass
424
+ # limiter=... through to child path objects and later dispatch work via
425
+ # to_thread.run_sync(..., limiter=self._limiter).
426
+ self._limiter = limiter
427
+ super().__init__(*parts)
428
+
429
+ @classmethod
430
+ def _cast(cls, path: Path) -> T:
431
+ """Fast internal constructor from existing pathlib.Path (no parsing)."""
432
+ obj = object.__new__(cls)
433
+ obj._path = path
434
+ # _cast() bypasses __init__, so keep _limiter in sync with AnyIOPath's
435
+ # expected instance contract.
436
+ obj._limiter = None
437
+ return obj
438
+
439
+ async def expanduser(self) -> T:
440
+ return self._cast(self._path.expanduser())
441
+
442
+ async def get_access_policy(
443
+ self, *, backend_options: BackendOptions | None = None
444
+ ) -> AccessPolicy:
445
+ return await to_thread.run_sync(
446
+ lambda: _access_policy(self._path, follow_symlinks=_follow_symlinks(backend_options))
447
+ )
448
+
449
+ async def update_access_policy(
450
+ self, policy_patch: AccessPolicyPatch, *, backend_options: BackendOptions | None = None
451
+ ) -> None:
452
+ await to_thread.run_sync(
453
+ lambda: _update_access_policy(
454
+ self._path, policy_patch, follow_symlinks=_follow_symlinks(backend_options)
455
+ )
456
+ )
457
+
458
+ async def resolve(self) -> T:
459
+ return self._cast(self._path.resolve())
460
+
461
+ async def rename(self, target, *, force: bool = False, atomic: bool = False) -> T:
462
+ remote_target = _resolve_async_remote_dst(target)
463
+ if remote_target is not None:
464
+ return await self.copy(
465
+ remote_target,
466
+ recursive=self._path.is_dir(),
467
+ remove_src=True,
468
+ force=force,
469
+ atomic=atomic,
470
+ )
471
+ target_obj = self._cast(Path(str(target)))
472
+ needs_copy, same_path, destination_exists = await async_destination_state(self, target_obj)
473
+ if not needs_copy:
474
+ if same_path:
475
+ return target_obj
476
+ self._path.unlink()
477
+ return target_obj
478
+ if destination_exists and not force:
479
+ require_force(force, target_obj)
480
+ return self._cast(self._path.rename(target_obj._path))
481
+
482
+ async def move(self, target, *, force: bool = False, atomic: bool = False) -> T:
483
+ return await self.rename(target, force=force, atomic=atomic)
484
+
485
+ async def replace(self, *args, **kwargs) -> T:
486
+ return self._cast(self._path.replace(*args, **kwargs))
487
+
488
+ async def readlink(self) -> T:
489
+ return self._cast(self._path.readlink())
490
+
491
+ async def exists(self) -> bool:
492
+ return self._path.exists()
493
+
494
+ async def stat(self, *args, **kwargs):
495
+ return self._path.stat(*args, **kwargs)
496
+
497
+ async def is_file(self) -> bool:
498
+ return self._path.is_file()
499
+
500
+ async def is_dir(self) -> bool:
501
+ return self._path.is_dir()
502
+
503
+ async def read_bytes(self) -> bytes:
504
+ if os.path.getsize(self._path) < _SYNC_THRESHOLD:
505
+ return self._path.read_bytes()
506
+ return await to_thread.run_sync(self._path.read_bytes)
507
+
508
+ async def iter_bytes(self, chunk_size: int | None = None) -> AsyncGenerator[bytes]:
509
+ """Yield file contents in byte chunks (``chunk_size=None`` uses a default)."""
510
+ if chunk_size is not None and chunk_size <= 0:
511
+ raise ValueError("chunk_size must be None or a positive integer")
512
+ if chunk_size is None:
513
+ chunk_size = _DEFAULT_CHUNK_SIZE
514
+ async with await self.open("rb") as f:
515
+ while chunk := await f.read(chunk_size):
516
+ yield chunk
517
+
518
+ async def read_text(self, *args, **kwargs) -> str:
519
+ if os.path.getsize(self._path) < _SYNC_THRESHOLD:
520
+ return self._path.read_text(*args, **kwargs)
521
+ return await to_thread.run_sync(lambda: self._path.read_text(*args, **kwargs))
522
+
523
+ async def write_bytes(self, data: bytes) -> int:
524
+ if len(data) < _SYNC_THRESHOLD:
525
+ return self._path.write_bytes(data)
526
+ return await to_thread.run_sync(lambda: self._path.write_bytes(data))
527
+
528
+ async def write_text(self, data: str, *args, **kwargs) -> int:
529
+ if len(data) < _SYNC_THRESHOLD:
530
+ return self._path.write_text(data, *args, **kwargs)
531
+ return await to_thread.run_sync(lambda: self._path.write_text(data, *args, **kwargs))
532
+
533
+ async def iterdir(self) -> AsyncGenerator[T]:
534
+ for entry in self._path.iterdir():
535
+ yield self._cast(entry)
536
+
537
+ async def glob(self, *args, **kwargs) -> AsyncGenerator[T]:
538
+ for item in self._path.glob(*args, **kwargs):
539
+ yield self._cast(item)
540
+
541
+ async def rglob(self, *args, **kwargs) -> AsyncGenerator[T]:
542
+ for item in self._path.rglob(*args, **kwargs):
543
+ yield self._cast(item)
544
+
545
+ async def walk(self, *args, **kwargs) -> AsyncGenerator[tuple[T, list[str], list[str]]]:
546
+ for root, dirs, files in os.walk(self._path, *args, **kwargs):
547
+ yield self._cast(Path(root)), dirs, files
548
+
549
+ async def checksums(self) -> dict[str, str]:
550
+ """Compute MD5, SHA-1 and SHA-256 checksums of a local file."""
551
+ from hashlib import md5, sha1, sha256
552
+
553
+ data = await self.read_bytes() # uses size-dispatch internally
554
+ return {
555
+ "md5": md5(data).hexdigest(),
556
+ "sha1": sha1(data).hexdigest(),
557
+ "sha256": sha256(data).hexdigest(),
558
+ }
559
+
560
+ async def copy(
561
+ self,
562
+ dst,
563
+ *,
564
+ recursive: bool = False,
565
+ remove_src: bool = False,
566
+ force: bool = False,
567
+ atomic: bool = False,
568
+ chunk_size: int | None = None,
569
+ on_progress: Callable[[int, int, AsyncPath, AsyncPath], None | Awaitable[None]]
570
+ | None = None,
571
+ on_done: Callable[
572
+ [AsyncPath, AsyncPath, int, bool, BaseException | None],
573
+ None | Awaitable[None],
574
+ ]
575
+ | None = None,
576
+ ) -> T:
577
+ _validate_chunk_size(chunk_size)
578
+ remote_dst = _resolve_async_remote_dst(dst)
579
+ if remote_dst is not None:
580
+ if atomic:
581
+ raise NotImplementedError("atomic=True is only supported for local destinations")
582
+ # chunk_size (a local streaming hint) is ignored for cloud destinations.
583
+
584
+ async def _copy_remote_file() -> int:
585
+ needs_copy, same_path, destination_exists = await async_destination_state(
586
+ self, remote_dst
587
+ )
588
+ if not needs_copy:
589
+ return 0
590
+ if destination_exists and not force:
591
+ require_force(force, remote_dst)
592
+ maybe_mkdir = remote_dst.parent.mkdir(parents=True, exist_ok=True)
593
+ if inspect.isawaitable(maybe_mkdir):
594
+ await maybe_mkdir
595
+ data = await self.read_bytes()
596
+ written = remote_dst.write_bytes(data)
597
+ if inspect.isawaitable(written):
598
+ written = await written
599
+ return written if isinstance(written, int) else len(data)
600
+
601
+ async def _copy_remote_tree() -> int:
602
+ total = 0
603
+ for root, _, files in os.walk(self._path):
604
+ relative_root = Path(root).relative_to(self._path)
605
+ for name in files:
606
+ source_file = self._cast(Path(root) / name)
607
+ relative = relative_root / name
608
+ destination_file = remote_dst / relative.as_posix()
609
+ needs_copy, _, destination_exists = await async_destination_state(
610
+ source_file, destination_file
611
+ )
612
+ if not needs_copy:
613
+ continue
614
+ if destination_exists and not force:
615
+ require_force(force, destination_file)
616
+ maybe_mkdir = destination_file.parent.mkdir(parents=True, exist_ok=True)
617
+ if inspect.isawaitable(maybe_mkdir):
618
+ await maybe_mkdir
619
+ data = await source_file.read_bytes()
620
+ written = destination_file.write_bytes(data)
621
+ if inspect.isawaitable(written):
622
+ written = await written
623
+ total += written if isinstance(written, int) else len(data)
624
+ return total
625
+
626
+ async def _remove_tree() -> None:
627
+ await to_thread.run_sync(lambda: shutil.rmtree(self._path))
628
+
629
+ async def _remove_file() -> None:
630
+ self._path.unlink()
631
+
632
+ if self._path.is_dir():
633
+ if not recursive:
634
+ raise IsADirectoryError(21, f"Is a directory: '{self}' (use recursive=True)")
635
+ return await run_transfer_async(
636
+ src=self,
637
+ dst=remote_dst,
638
+ copy_fn=_copy_remote_tree,
639
+ remove_fn=_remove_tree,
640
+ remove_src=remove_src,
641
+ on_progress=on_progress,
642
+ on_done=on_done,
643
+ )
644
+
645
+ return await run_transfer_async(
646
+ src=self,
647
+ dst=remote_dst,
648
+ copy_fn=_copy_remote_file,
649
+ remove_fn=_remove_file,
650
+ remove_src=remove_src,
651
+ on_progress=on_progress,
652
+ on_done=on_done,
653
+ )
654
+
655
+ dst_path = Path(str(dst))
656
+ dst_async = self._cast(dst_path)
657
+
658
+ if recursive and self._path.is_dir():
659
+ same_path = str(self) == str(dst_async)
660
+
661
+ async def _copy_tree() -> int:
662
+ for root, dirs, files in os.walk(self._path):
663
+ relative = Path(root).relative_to(self._path)
664
+ destination_root = dst_path / relative
665
+ await to_thread.run_sync(
666
+ lambda: destination_root.mkdir(parents=True, exist_ok=True)
667
+ )
668
+ for directory in dirs:
669
+ await to_thread.run_sync(
670
+ lambda: (destination_root / directory).mkdir(exist_ok=True)
671
+ )
672
+ for name in files:
673
+ source_file = self._cast(Path(root) / name)
674
+ destination_file = self._cast(destination_root / name)
675
+ needs_copy, same_path, destination_exists = await async_destination_state(
676
+ source_file, destination_file
677
+ )
678
+ if not needs_copy:
679
+ continue
680
+ if destination_exists and not force:
681
+ require_force(force, destination_file)
682
+ await _local_copy_file_async(
683
+ source_file._path,
684
+ destination_file._path,
685
+ chunk_size=chunk_size,
686
+ atomic=atomic,
687
+ force=force,
688
+ )
689
+ return await to_thread.run_sync(lambda: _dir_total_bytes(self._path))
690
+
691
+ async def _remove_tree() -> None:
692
+ await to_thread.run_sync(lambda: shutil.rmtree(self._path))
693
+
694
+ return await run_transfer_async(
695
+ src=self,
696
+ dst=dst_async,
697
+ copy_fn=_copy_tree,
698
+ remove_fn=_remove_tree,
699
+ remove_src=remove_src and not same_path,
700
+ on_progress=on_progress,
701
+ on_done=on_done,
702
+ )
703
+
704
+ if self._path.is_dir():
705
+ raise IsADirectoryError(21, f"Is a directory: '{self}' (use recursive=True)")
706
+
707
+ async def _copy_file() -> int:
708
+ needs_copy, same_path, destination_exists = await async_destination_state(
709
+ self, dst_async
710
+ )
711
+ if not needs_copy:
712
+ return 0
713
+ if destination_exists and not force:
714
+ require_force(force, dst_async)
715
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
716
+ return await _local_copy_file_async(
717
+ self._path, dst_path, chunk_size=chunk_size, atomic=atomic, force=force
718
+ )
719
+
720
+ async def _remove_file() -> None:
721
+ self._path.unlink()
722
+
723
+ return await run_transfer_async(
724
+ src=self,
725
+ dst=dst_async,
726
+ copy_fn=_copy_file,
727
+ remove_fn=_remove_file,
728
+ remove_src=remove_src and str(self) != str(dst_async),
729
+ on_progress=on_progress,
730
+ on_done=on_done,
731
+ )
732
+
733
+ async def copy_from(
734
+ self,
735
+ src,
736
+ *,
737
+ force: bool = False,
738
+ atomic: bool = False,
739
+ chunk_size: int | None = None,
740
+ on_progress: Callable[[int, int, object, AsyncPath], None | Awaitable[None]] | None = None,
741
+ on_done: Callable[
742
+ [object, AsyncPath, int, bool, BaseException | None], None | Awaitable[None]
743
+ ]
744
+ | None = None,
745
+ ) -> T:
746
+ _validate_chunk_size(chunk_size)
747
+ transferred = 0
748
+ error: BaseException | None = None
749
+
750
+ async def _emit_progress(delta: int) -> None:
751
+ nonlocal transferred
752
+ if on_progress is None or delta <= 0:
753
+ return
754
+ transferred += delta
755
+ maybe = on_progress(delta, transferred, src, self)
756
+ if inspect.isawaitable(maybe):
757
+ await maybe
758
+
759
+ async def _emit_done() -> None:
760
+ if on_done is None:
761
+ return
762
+ maybe = on_done(src, self, transferred, error is None, error)
763
+ if inspect.isawaitable(maybe):
764
+ await maybe
765
+
766
+ async def _copy_file() -> int:
767
+ needs_copy, same_path, destination_exists = await async_destination_state(src, self)
768
+ if not needs_copy:
769
+ return 0
770
+ if destination_exists and not force:
771
+ require_force(force, self)
772
+ self._path.parent.mkdir(parents=True, exist_ok=True)
773
+ target_path = _atomic_temp_path(self._path) if atomic else self._path
774
+ total = 0
775
+ try:
776
+ with target_path.open("wb") as dst_file:
777
+ if chunk_size and getattr(src, "_supports_range_read", False):
778
+ async for chunk in src.iter_bytes(chunk_size):
779
+ dst_file.write(chunk)
780
+ total += len(chunk)
781
+ await _emit_progress(len(chunk))
782
+ elif chunk_size:
783
+ raise NotImplementedError("chunk_size is not supported for this source")
784
+ else:
785
+ data = await src.read_bytes()
786
+ dst_file.write(data)
787
+ total = len(data)
788
+ if atomic:
789
+ await to_thread.run_sync(
790
+ lambda: _commit_temp_sync(target_path, self._path, force=force)
791
+ )
792
+ return total
793
+ except BaseException:
794
+ if atomic:
795
+ target_path.unlink(missing_ok=True)
796
+ raise
797
+
798
+ try:
799
+ await run_transfer_async(
800
+ src=src,
801
+ dst=self,
802
+ copy_fn=_copy_file,
803
+ on_progress=None,
804
+ on_done=None,
805
+ )
806
+ except BaseException as exc:
807
+ error = exc
808
+ raise
809
+ finally:
810
+ await _emit_done()
811
+ return self
812
+
813
+ async def rmdir(self, *, recursive: bool = False) -> None:
814
+ if recursive:
815
+ await to_thread.run_sync(lambda: shutil.rmtree(self._path))
816
+ else:
817
+ self._path.rmdir()