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/__init__.py +81 -0
- asanypath/__init__.pyi +62 -0
- asanypath/_credstore.py +102 -0
- asanypath/_transfer.py +216 -0
- asanypath/_version.py +24 -0
- asanypath/artifactory.py +366 -0
- asanypath/asanypath.py +556 -0
- asanypath/asanypath.pyi +225 -0
- asanypath/azure.py +274 -0
- asanypath/batcher.py +206 -0
- asanypath/cli.py +2566 -0
- asanypath/cloud.py +1042 -0
- asanypath/common.py +355 -0
- asanypath/exceptions.py +43 -0
- asanypath/ftp.py +543 -0
- asanypath/gcs.py +253 -0
- asanypath/http.py +516 -0
- asanypath/local.py +817 -0
- asanypath/options.py +57 -0
- asanypath/s3.py +277 -0
- asanypath/ssh.py +776 -0
- asanypath/sync.py +220 -0
- asanypath/unsupported.py +202 -0
- asanypath-2.0.0.dist-info/METADATA +591 -0
- asanypath-2.0.0.dist-info/RECORD +28 -0
- asanypath-2.0.0.dist-info/WHEEL +4 -0
- asanypath-2.0.0.dist-info/entry_points.txt +2 -0
- asanypath-2.0.0.dist-info/licenses/LICENSE +21 -0
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()
|