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/cloud.py ADDED
@@ -0,0 +1,1042 @@
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 fnmatch
10
+ import inspect
11
+ import io
12
+ import time
13
+ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
14
+ from os import PathLike
15
+ from os.path import expanduser
16
+ from types import SimpleNamespace
17
+ from typing import TYPE_CHECKING, Any, Literal
18
+
19
+ import msgspec
20
+
21
+ if TYPE_CHECKING:
22
+ from typing import Self
23
+
24
+ from os import linesep
25
+
26
+ from asanypath._transfer import (
27
+ async_destination_state,
28
+ has_async_transfer_api,
29
+ require_force,
30
+ run_transfer_async,
31
+ )
32
+ from asanypath.batcher import H2_BATCH_THRESHOLD, MicroBatcher, make_batcher
33
+ from asanypath.common import SCHEME_SEP, CommonPurePathMixin
34
+ from asanypath.options import AccessPolicy, AccessPolicyPatch, BackendOptions
35
+
36
+
37
+ class CloudPathMixin(CommonPurePathMixin):
38
+ """Base class for cloud path implementations.
39
+
40
+ Subclasses must provide:
41
+
42
+ - ``_item_path`` — property returning the backend-specific object key
43
+ (e.g. the S3 key, GCS object path, Azure blob path).
44
+ - ``_native_kwargs`` — property returning a dict of auth / endpoint kwargs
45
+ that are forwarded to every batcher call and native function.
46
+ - ``_batcher_key_fn`` — staticmethod producing a cache key string from
47
+ ``**_native_kwargs``.
48
+ - ``_batcher_ops`` — dict describing batch operations (passed to
49
+ :func:`make_batcher`).
50
+
51
+ Optionally, set the class attribute ``_is_dir_batch_fn`` to a native
52
+ batch-is-dir coroutine (e.g. ``s3_is_dir_batch``) for an efficient
53
+ ``walk()`` implementation. If not set, ``walk()`` falls back to
54
+ per-entry ``is_dir()`` calls via ``asyncio.gather``.
55
+ """
56
+
57
+ protocol: str = "cloud" # Default protocol, should be overridden by subclasses
58
+
59
+ # TTL-based listing cache: {cache_key: (timestamp, [uri, ...])}
60
+ _listing_cache: dict[str, tuple[float, list[str]]] = {}
61
+ listing_cache_ttl: float = 60.0 # seconds; set to 0 to disable
62
+
63
+ # Subclasses may set this to a native batch-is-dir function.
64
+ _is_dir_batch_fn = None
65
+
66
+ # Declarative batcher configuration — override in subclasses.
67
+ _batcher_key_fn: staticmethod = None # type: ignore[assignment]
68
+ _batcher_ops: dict | None = None
69
+
70
+ # Shared batcher instance cache: {cache_key: MicroBatcher}
71
+ _batcher_instances: dict[str, MicroBatcher] = {}
72
+
73
+ # Per-class cached env config — subclasses override _create_env_config().
74
+ _env_config: SimpleNamespace | None = None
75
+
76
+ # Subclasses set True once they implement _range_read with native support.
77
+ _supports_range_read: bool = False
78
+
79
+ # Subclasses set this to a native server-side copy batch fn (e.g. s3_copy_batch).
80
+ _copy_batch_fn = None
81
+
82
+ def _can_server_side_copy(self, dst: Any) -> bool:
83
+ """True when *dst* is the same backend + credentials, so a native
84
+ server-side copy (no client round-trip) can be used."""
85
+ return (
86
+ type(self)._copy_batch_fn is not None
87
+ and type(self) is type(dst)
88
+ and self._native_kwargs == dst._native_kwargs
89
+ )
90
+
91
+ async def _server_side_copy(
92
+ self,
93
+ dst: Any,
94
+ *,
95
+ want_size: bool = False,
96
+ destination_backend_options: BackendOptions | None = None,
97
+ ) -> int:
98
+ """Copy this object to *dst* via the backend's native copy API.
99
+
100
+ Returns the object size when *want_size* (for progress reporting),
101
+ else 0 — no bytes transit the client either way.
102
+ """
103
+ kwargs = {
104
+ "pairs": [(self._item_path, dst._item_path)],
105
+ "options_json": (
106
+ msgspec.json.encode(destination_backend_options).decode()
107
+ if destination_backend_options is not None
108
+ else None
109
+ ),
110
+ "use_h2": False,
111
+ **self._native_kwargs,
112
+ }
113
+ await type(self)._copy_batch_fn(**kwargs)
114
+ if not want_size:
115
+ return 0
116
+ try:
117
+ st = await dst.stat()
118
+ except Exception: # noqa: BLE001
119
+ return 0
120
+ return int(getattr(st, "st_size", 0) or 0)
121
+
122
+ def __bytes__(self) -> bytes:
123
+ return str(self).encode()
124
+
125
+ def __fspath__(self) -> str:
126
+ raise TypeError(f"{type(self).__name__} is not a local filesystem path")
127
+
128
+ # ------------------------------------------------------------------
129
+ # Subclass hooks
130
+ # ------------------------------------------------------------------
131
+
132
+ @property
133
+ def _item_path(self) -> str:
134
+ """The backend-specific object / blob / key path. Override in subclasses."""
135
+ raise NotImplementedError(f"{type(self).__name__} must define _item_path")
136
+
137
+ @property
138
+ def _native_kwargs(self) -> dict:
139
+ """Auth / endpoint kwargs forwarded to batcher and native calls."""
140
+ raise NotImplementedError(f"{type(self).__name__} must define _native_kwargs")
141
+
142
+ async def is_dir(self) -> bool:
143
+ """Check if path is a directory prefix. Override in subclasses."""
144
+ raise NotImplementedError(f"{type(self).__name__} must define is_dir()")
145
+
146
+ def _get_batcher(self) -> MicroBatcher:
147
+ """Return a cached :class:`MicroBatcher` built from class attrs."""
148
+ key_fn = type(self)._batcher_key_fn
149
+ ops = type(self)._batcher_ops
150
+ if key_fn is None or ops is None:
151
+ raise NotImplementedError(
152
+ f"{type(self).__name__} must define _batcher_key_fn and _batcher_ops"
153
+ )
154
+ cache_key = key_fn(**self._native_kwargs)
155
+ batcher = self._batcher_instances.get(cache_key)
156
+ if batcher is None:
157
+ batcher = make_batcher(key_fn=key_fn, ops=ops)
158
+ self._batcher_instances[cache_key] = batcher
159
+ return batcher
160
+
161
+ @property
162
+ def env_config(self) -> SimpleNamespace:
163
+ """Lazy per-class env config. Subclasses override _create_env_config()."""
164
+ if self._env_config is None:
165
+ type(self)._env_config = self._create_env_config()
166
+ return self._env_config # type: ignore[return-value]
167
+
168
+ @env_config.setter
169
+ def env_config(self, value: SimpleNamespace | None) -> None:
170
+ type(self)._env_config = value
171
+
172
+ @classmethod
173
+ def _create_env_config(cls) -> SimpleNamespace:
174
+ """Build env config from environment variables. Override in subclasses."""
175
+ raise NotImplementedError(f"{cls.__name__} must define _create_env_config()")
176
+
177
+ # ------------------------------------------------------------------
178
+ # HTTP request plumbing (delegated to Rust native via subclasses)
179
+ # ------------------------------------------------------------------
180
+
181
+ async def request(
182
+ self,
183
+ method: Literal["GET", "PUT", "POST", "HEAD", "DELETE", "OPTIONS", "PATCH"],
184
+ url: str,
185
+ **kwargs: Any,
186
+ ) -> AsyncGenerator[bytes, None]:
187
+ """Low-level request implementation. Override in subclasses."""
188
+ raise NotImplementedError(f"{type(self).__name__} does not implement request()")
189
+ yield # pragma: no cover — makes this an async generator
190
+
191
+ async def _request(
192
+ self,
193
+ method: Literal["GET", "PUT", "POST", "HEAD", "DELETE", "OPTIONS", "PATCH"],
194
+ url: str = "",
195
+ **kwargs: Any,
196
+ ) -> list[bytes]:
197
+ """Buffer the full response from request(). Retries handled by Rust/reqwest."""
198
+ return [chunk async for chunk in self.request(method, url, **kwargs)]
199
+
200
+ # ------------------------------------------------------------------
201
+ # Generic batcher-backed I/O (shared across all cloud backends)
202
+ # ------------------------------------------------------------------
203
+
204
+ async def exists(self) -> bool:
205
+ """Check if object exists."""
206
+ return await self._get_batcher().exists(
207
+ item=self._item_path,
208
+ **self._native_kwargs,
209
+ )
210
+
211
+ async def is_file(self) -> bool:
212
+ """True if the path points to an object (not a directory prefix)."""
213
+ if not await self.exists():
214
+ return False
215
+ return not await self.is_dir()
216
+
217
+ async def read_bytes(self) -> bytes:
218
+ """Read object content as bytes."""
219
+ return await self._get_batcher().get(
220
+ item=self._item_path,
221
+ **self._native_kwargs,
222
+ )
223
+
224
+ async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
225
+ """Yield object bytes in chunks without blocking the event loop."""
226
+ if chunk_size is not None and chunk_size <= 0:
227
+ raise ValueError("chunk_size must be None or a positive integer")
228
+ if chunk_size is None:
229
+ chunk_size = _CloudFile._DEFAULT_CHUNK
230
+ if not self._supports_range_read:
231
+ data = await self.read_bytes()
232
+ for offset in range(0, len(data), chunk_size):
233
+ yield data[offset : offset + chunk_size]
234
+ return
235
+ # Stream sequential windows until the object is exhausted. A short read
236
+ # (or an empty read past end-of-object) signals EOF — no size probe needed.
237
+ pos = 0
238
+ while True:
239
+ chunk = await self._range_read(pos, pos + chunk_size - 1)
240
+ if not chunk:
241
+ break
242
+ yield chunk
243
+ if len(chunk) < chunk_size:
244
+ break
245
+ pos += len(chunk)
246
+
247
+ async def _range_read(self, start: int, end: int) -> bytes:
248
+ """Read bytes [start, end] from the object via range request."""
249
+ from asanypath_native import range_read
250
+
251
+ return bytes(
252
+ await range_read(path=self._item_path, start=start, end=end, **self._native_kwargs)
253
+ )
254
+
255
+ async def read_text(
256
+ self,
257
+ encoding: str | None = None,
258
+ errors: str | None = None,
259
+ newline: str | None = None,
260
+ ) -> str:
261
+ """Read object content as text."""
262
+ text = (await self.read_bytes()).decode(
263
+ encoding=encoding or "utf-8", errors=errors or "strict"
264
+ )
265
+ if newline is None:
266
+ return text.replace("\r\n", "\n").replace("\r", "\n")
267
+ if newline == "":
268
+ return text
269
+ return text.replace(newline, "\n")
270
+
271
+ async def write_bytes(
272
+ self, data: bytes, *, backend_options: BackendOptions | None = None
273
+ ) -> int:
274
+ """Write bytes to object."""
275
+ options_json = (
276
+ msgspec.json.encode(backend_options).decode() if backend_options is not None else "{}"
277
+ )
278
+ await self._get_batcher().put(
279
+ item=(
280
+ self._item_path,
281
+ data,
282
+ options_json,
283
+ ),
284
+ **self._native_kwargs,
285
+ )
286
+ return len(data)
287
+
288
+ async def write_text(
289
+ self,
290
+ data: str,
291
+ encoding: str | None = None,
292
+ errors: str | None = None,
293
+ newline: str | None = None,
294
+ *,
295
+ backend_options: BackendOptions | None = None,
296
+ ) -> int:
297
+ """Write text to object."""
298
+ if newline is None:
299
+ newline = linesep
300
+ if newline not in ("", "\n"):
301
+ data = data.replace("\n", newline)
302
+ encoded = data.encode(encoding or "utf-8", errors or "strict")
303
+ if backend_options is None:
304
+ return await self.write_bytes(encoded)
305
+ return await self.write_bytes(encoded, backend_options=backend_options)
306
+
307
+ async def unlink(self, missing_ok: bool = False) -> None:
308
+ """Delete object."""
309
+ try:
310
+ await self._get_batcher().delete(
311
+ item=self._item_path,
312
+ **self._native_kwargs,
313
+ )
314
+ except FileNotFoundError: # pragma: no cover
315
+ if not missing_ok: # pragma: no cover
316
+ raise
317
+
318
+ async def _fetch_listing(self) -> list[str]:
319
+ """Fetch directory listing and return URIs."""
320
+ containers = await self._list_containers()
321
+ if containers is not None:
322
+ return containers
323
+ try:
324
+ return await self._get_batcher().list(
325
+ item=self._item_path,
326
+ **self._native_kwargs,
327
+ )
328
+ except FileNotFoundError:
329
+ raise NotADirectoryError(20, f"Not a directory: '{self}'")
330
+
331
+ async def _list_containers(self) -> list[str] | None:
332
+ """Return bucket/container/repo URIs when this path is the service root.
333
+
334
+ Returns ``None`` (the default) when a bucket/container/repo is already
335
+ selected, so callers fall through to normal object listing. Backends
336
+ with a listable root (S3, GCS, Azure, Artifactory) override this to
337
+ power both ``ls s3://`` and top-level shell completion. The empty-root
338
+ check is a cheap in-memory test, so normal listings pay no extra cost.
339
+ """
340
+ return None
341
+
342
+ # ------------------------------------------------------------------
343
+ # Cached directory listing
344
+ # ------------------------------------------------------------------
345
+
346
+ def _listing_cache_key(self) -> str:
347
+ """Cache key for this path's directory listing."""
348
+ return str(self)
349
+
350
+ async def iterdir(self, *, fresh: bool = False) -> AsyncIterator[Self]:
351
+ """Iterate over directory contents with TTL caching.
352
+
353
+ Results are cached for ``listing_cache_ttl`` seconds.
354
+ Pass ``fresh=True`` to bypass the cache and force a network fetch.
355
+ """
356
+ cache_key = self._listing_cache_key()
357
+ cache = type(self)._listing_cache
358
+
359
+ if not fresh and self.listing_cache_ttl > 0:
360
+ entry = cache.get(cache_key)
361
+ if entry is not None:
362
+ ts, uris = entry
363
+ if (time.monotonic() - ts) < self.listing_cache_ttl:
364
+ for uri in uris:
365
+ yield type(self)(uri)
366
+ return
367
+
368
+ uris = await self._fetch_listing()
369
+
370
+ if self.listing_cache_ttl > 0:
371
+ cache[cache_key] = (time.monotonic(), uris)
372
+
373
+ for uri in uris:
374
+ yield type(self)(uri)
375
+
376
+ def chmod(self, mode: int, *, follow_symlinks: bool = True) -> None:
377
+ raise NotImplementedError(f"{type(self).__name__} does not support chmod")
378
+
379
+ @classmethod
380
+ def cwd(cls) -> Self:
381
+ raise NotImplementedError(f"{cls.__name__} does not support cwd")
382
+
383
+ def expanduser(self) -> Self:
384
+ return self
385
+
386
+ async def get_access_policy(
387
+ self, *, backend_options: BackendOptions | None = None
388
+ ) -> AccessPolicy:
389
+ raise NotImplementedError(f"{type(self).__name__} does not support access policies")
390
+
391
+ def group(self) -> str:
392
+ raise NotImplementedError(f"{type(self).__name__} does not support group")
393
+
394
+ def hardlink_to(self, target: str | bytes | PathLike[str] | PathLike[bytes]) -> None:
395
+ raise NotImplementedError(f"{type(self).__name__} does not support hardlink_to")
396
+
397
+ @classmethod
398
+ def home(cls) -> Self:
399
+ return cls(cls.protocol + SCHEME_SEP, expanduser("~"))
400
+
401
+ def is_absolute(self) -> bool:
402
+ return True
403
+
404
+ def is_block_device(self) -> bool:
405
+ return False
406
+
407
+ def is_char_device(self) -> bool:
408
+ return False
409
+
410
+ def is_fifo(self) -> bool:
411
+ return False
412
+
413
+ def is_junction(self) -> bool:
414
+ return False
415
+
416
+ def is_mount(self) -> bool:
417
+ return False
418
+
419
+ def is_reserved(self) -> bool:
420
+ return False
421
+
422
+ def is_socket(self) -> bool:
423
+ return False
424
+
425
+ def is_symlink(self) -> bool:
426
+ return False
427
+
428
+ def lchmod(self, mode: int) -> None:
429
+ raise NotImplementedError(f"{type(self).__name__} does not support lchmod")
430
+
431
+ def lstat(self):
432
+ raise NotImplementedError(f"{type(self).__name__} does not support lstat")
433
+
434
+ def owner(self) -> str:
435
+ raise NotImplementedError(f"{type(self).__name__} does not support owner")
436
+
437
+ def readlink(self) -> Self:
438
+ raise NotImplementedError(f"{type(self).__name__} does not support readlink")
439
+
440
+ def resolve(self, strict: bool = False) -> Self:
441
+ return self
442
+
443
+ def samefile(self, other_path: str | PathLike[str]) -> bool:
444
+ return str(self.resolve()) == str(type(self)(other_path).resolve())
445
+
446
+ def symlink_to(
447
+ self,
448
+ target: str | bytes | PathLike[str] | PathLike[bytes],
449
+ target_is_directory: bool = False,
450
+ ) -> None:
451
+ raise NotImplementedError(f"{type(self).__name__} does not support symlink_to")
452
+
453
+ # ------------------------------------------------------------------
454
+ # Default implementations (copy+delete rename, glob via iterdir, etc.)
455
+ # Backends may override for better performance.
456
+ # ------------------------------------------------------------------
457
+
458
+ async def replace(
459
+ self,
460
+ target: str,
461
+ *,
462
+ destination_backend_options: BackendOptions | None = None,
463
+ on_progress: Callable[[int, int, Self, Self], None | Awaitable[None]] | None = None,
464
+ on_done: Callable[[Self, Self, int, bool, BaseException | None], None | Awaitable[None]]
465
+ | None = None,
466
+ ) -> Self:
467
+ """Replace object at target."""
468
+ return await self.rename(
469
+ target,
470
+ force=True,
471
+ destination_backend_options=destination_backend_options,
472
+ on_progress=on_progress,
473
+ on_done=on_done,
474
+ )
475
+
476
+ async def rename(
477
+ self,
478
+ target: str,
479
+ *,
480
+ force: bool = False,
481
+ atomic: bool = False,
482
+ chunk_size: int | None = None,
483
+ destination_backend_options: BackendOptions | None = None,
484
+ on_progress: Callable[[int, int, Self, Self], None | Awaitable[None]] | None = None,
485
+ on_done: Callable[[Self, Self, int, bool, BaseException | None], None | Awaitable[None]]
486
+ | None = None,
487
+ ) -> Self:
488
+ """Rename/move object via copy + delete."""
489
+ dst = type(self)(target)
490
+
491
+ async def _copy_file() -> int:
492
+ if not needs_copy:
493
+ return 0
494
+ if self._can_server_side_copy(dst):
495
+ return await self._server_side_copy(
496
+ dst,
497
+ want_size=on_progress is not None,
498
+ destination_backend_options=destination_backend_options,
499
+ )
500
+ data = await self.read_bytes()
501
+ if destination_backend_options is None:
502
+ await dst.write_bytes(data)
503
+ else:
504
+ await dst.write_bytes(data, backend_options=destination_backend_options)
505
+ return len(data)
506
+
507
+ needs_copy, same_path, destination_exists = await async_destination_state(self, dst)
508
+ if needs_copy and destination_exists and not force:
509
+ require_force(force, dst)
510
+
511
+ await run_transfer_async(
512
+ src=self,
513
+ dst=dst,
514
+ copy_fn=_copy_file,
515
+ remove_fn=self.unlink,
516
+ remove_src=not same_path,
517
+ on_progress=on_progress,
518
+ on_done=on_done,
519
+ )
520
+ return dst
521
+
522
+ async def move(
523
+ self,
524
+ target: str,
525
+ *,
526
+ force: bool = False,
527
+ atomic: bool = False,
528
+ chunk_size: int | None = None,
529
+ destination_backend_options: BackendOptions | None = None,
530
+ on_progress: Callable[[int, int, Self, Self], None | Awaitable[None]] | None = None,
531
+ on_done: Callable[[Self, Self, int, bool, BaseException | None], None | Awaitable[None]]
532
+ | None = None,
533
+ ) -> Self:
534
+ return await self.rename(
535
+ target,
536
+ force=force,
537
+ atomic=atomic,
538
+ chunk_size=chunk_size,
539
+ destination_backend_options=destination_backend_options,
540
+ on_progress=on_progress,
541
+ on_done=on_done,
542
+ )
543
+
544
+ async def touch(self, mode: int = 0o666, exist_ok: bool = True) -> None:
545
+ """Create empty object or update timestamp."""
546
+ if not exist_ok and await self.exists():
547
+ raise FileExistsError(17, f"File exists: '{self}'")
548
+ await self.write_bytes(b"")
549
+
550
+ async def update_access_policy(
551
+ self, policy_patch: AccessPolicyPatch, *, backend_options: BackendOptions | None = None
552
+ ) -> None:
553
+ raise NotImplementedError(f"{type(self).__name__} does not support access policies")
554
+
555
+ async def mkdir(self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None:
556
+ """No-op for cloud backends (directories are virtual prefixes)."""
557
+
558
+ async def rmdir(self, *, recursive: bool = False) -> None:
559
+ """Remove directory prefix.
560
+
561
+ With ``recursive=True``, deletes all objects under this prefix.
562
+ Without it, only succeeds if the prefix is empty (no children).
563
+ """
564
+ if not recursive:
565
+ children = [c async for c in self.iterdir()]
566
+ if children:
567
+ raise OSError(39, f"Directory not empty: '{self}'")
568
+ return
569
+ # Recursive: collect all files, then unlink
570
+ files = []
571
+ async for child in self.iterdir():
572
+ if await child.is_dir():
573
+ await child.rmdir(recursive=True)
574
+ else:
575
+ files.append(child)
576
+ for f in files:
577
+ await f.unlink()
578
+
579
+ async def copy(
580
+ self,
581
+ dst,
582
+ *,
583
+ recursive: bool = False,
584
+ remove_src: bool = False,
585
+ force: bool = False,
586
+ atomic: bool = False,
587
+ chunk_size: int | None = None,
588
+ destination_backend_options: BackendOptions | None = None,
589
+ on_progress: Callable[[int, int, Self, Self], None | Awaitable[None]] | None = None,
590
+ on_done: Callable[[Self, Self, int, bool, BaseException | None], None | Awaitable[None]]
591
+ | None = None,
592
+ ):
593
+ """Copy this path to *dst*.
594
+
595
+ *dst* can be any path type (cross-backend supported).
596
+ With ``recursive=True``, copies entire directory tree.
597
+ With ``remove_src=True``, deletes source after copy (move semantics).
598
+ Returns the destination path.
599
+ """
600
+ from asanypath import AsAnyPath
601
+
602
+ dst = dst if has_async_transfer_api(dst) else AsAnyPath(str(dst))
603
+ same_native = self._can_server_side_copy(dst)
604
+ # chunk_size is a local streaming hint; cloud writes are single PUTs, so
605
+ # it is ignored (not an error) for non-local destinations.
606
+ if atomic and getattr(dst, "protocol", None) != "file" and not same_native:
607
+ raise NotImplementedError(
608
+ "atomic=True is only supported for local or same-backend destinations"
609
+ )
610
+ if getattr(dst, "protocol", None) == "file" and destination_backend_options is not None:
611
+ raise ValueError("destination_backend_options require a cloud destination")
612
+ if getattr(dst, "protocol", None) == "file" and (atomic or chunk_size):
613
+ return await dst.copy_from(
614
+ self,
615
+ force=force,
616
+ atomic=atomic,
617
+ chunk_size=chunk_size,
618
+ on_progress=on_progress,
619
+ on_done=on_done,
620
+ )
621
+ transferred = 0
622
+ error: BaseException | None = None
623
+
624
+ async def _emit_progress(delta: int) -> None:
625
+ nonlocal transferred
626
+ if on_progress is None or delta <= 0: # pragma: no cover
627
+ return
628
+ transferred += delta
629
+ maybe = on_progress(delta, transferred, self, dst)
630
+ if inspect.isawaitable(maybe): # pragma: no cover
631
+ await maybe
632
+
633
+ async def _emit_done() -> None:
634
+ if on_done is None: # pragma: no cover
635
+ return
636
+ maybe = on_done(self, dst, transferred, error is None, error)
637
+ if inspect.isawaitable(maybe): # pragma: no cover
638
+ await maybe
639
+
640
+ async def _copy_recursive(src_node: Self, dst_node: Self) -> None:
641
+ if await src_node.is_dir():
642
+ await dst_node.mkdir(parents=True, exist_ok=True)
643
+ async for child in src_node.iterdir():
644
+ await _copy_recursive(child, dst_node / child.name)
645
+ else:
646
+
647
+ async def _copy_leaf() -> int:
648
+ if not needs_copy:
649
+ return 0
650
+ if src_node._can_server_side_copy(dst_node):
651
+ return await src_node._server_side_copy(
652
+ dst_node,
653
+ want_size=on_progress is not None,
654
+ destination_backend_options=destination_backend_options,
655
+ )
656
+ await dst_node.parent.mkdir(parents=True, exist_ok=True)
657
+ data = await src_node.read_bytes()
658
+ if destination_backend_options is None:
659
+ await dst_node.write_bytes(data)
660
+ else:
661
+ await dst_node.write_bytes(
662
+ data, backend_options=destination_backend_options
663
+ )
664
+ return len(data)
665
+
666
+ needs_copy, same_path, destination_exists = await async_destination_state(
667
+ src_node, dst_node
668
+ )
669
+ if not needs_copy:
670
+ if same_path:
671
+ return
672
+ elif destination_exists and not force:
673
+ require_force(force, dst_node)
674
+
675
+ await run_transfer_async(
676
+ src=src_node,
677
+ dst=dst_node,
678
+ copy_fn=_copy_leaf,
679
+ remove_fn=src_node.unlink,
680
+ remove_src=remove_src and not same_path,
681
+ on_progress=lambda delta, transferred, _src, _dst: _emit_progress(delta),
682
+ on_done=None,
683
+ )
684
+
685
+ try:
686
+ if recursive and await self.is_dir():
687
+ same_path = str(self) == str(dst)
688
+ await _copy_recursive(self, dst)
689
+ if remove_src and not same_path:
690
+ await self.rmdir()
691
+ elif await self.is_dir():
692
+ raise IsADirectoryError(21, f"Is a directory: '{self}' (use recursive=True)")
693
+ else:
694
+
695
+ async def _copy_file() -> int:
696
+ if not needs_copy:
697
+ return 0
698
+ if self._can_server_side_copy(dst):
699
+ return await self._server_side_copy(
700
+ dst,
701
+ want_size=on_progress is not None,
702
+ destination_backend_options=destination_backend_options,
703
+ )
704
+ await dst.parent.mkdir(parents=True, exist_ok=True)
705
+ data = await self.read_bytes()
706
+ if destination_backend_options is None:
707
+ await dst.write_bytes(data)
708
+ else:
709
+ await dst.write_bytes(data, backend_options=destination_backend_options)
710
+ return len(data)
711
+
712
+ needs_copy, same_path, destination_exists = await async_destination_state(self, dst)
713
+ if not needs_copy:
714
+ if same_path:
715
+ return 0
716
+ elif destination_exists and not force:
717
+ require_force(force, dst)
718
+
719
+ await run_transfer_async(
720
+ src=self,
721
+ dst=dst,
722
+ copy_fn=_copy_file,
723
+ remove_fn=self.unlink,
724
+ remove_src=remove_src and not same_path,
725
+ on_progress=lambda delta, transferred, _src, _dst: _emit_progress(delta),
726
+ on_done=None,
727
+ )
728
+ except BaseException as exc: # noqa: BLE001
729
+ error = exc
730
+ raise
731
+ finally:
732
+ await _emit_done()
733
+ return dst
734
+
735
+ def open(
736
+ self,
737
+ mode="r",
738
+ buffering=-1,
739
+ encoding=None,
740
+ errors=None,
741
+ newline=None,
742
+ *,
743
+ backend_options: BackendOptions | None = None,
744
+ ):
745
+ """Return a file-like object backed by cloud read/write.
746
+
747
+ Supports both sync (``with``) and async (``async with``) context managers.
748
+ Read mode fetches the full object on first read; write mode buffers and
749
+ uploads on close.
750
+
751
+ The *buffering* parameter controls how much data is fetched per
752
+ range-read request (analogous to local file buffer size):
753
+
754
+ - ``-1`` (default): 8 MiB chunks (optimal for cloud latency).
755
+ - ``0``: unbuffered — each ``read(n)`` issues one range request.
756
+ - ``N > 0``: fetch in *N*-byte chunks.
757
+ """
758
+ if backend_options is not None and "r" in mode:
759
+ raise ValueError("backend_options are only supported for write modes")
760
+ return _CloudFile(self, mode, buffering, encoding, errors, newline, backend_options)
761
+
762
+ async def glob(
763
+ self, pattern: str, *, case_sensitive: bool | None = None
764
+ ) -> AsyncIterator[Self]:
765
+ """Glob pattern matching via iterdir."""
766
+ async for child in self.iterdir():
767
+ if fnmatch.fnmatch(child.name, pattern):
768
+ yield child
769
+
770
+ async def rglob(
771
+ self, pattern: str, *, case_sensitive: bool | None = None
772
+ ) -> AsyncIterator[Self]:
773
+ """Recursive glob pattern matching."""
774
+ async for child in self.iterdir():
775
+ if fnmatch.fnmatch(child.name, pattern):
776
+ yield child
777
+ if await child.is_dir():
778
+ async for grandchild in child.rglob(pattern, case_sensitive=case_sensitive):
779
+ yield grandchild
780
+
781
+ async def walk(
782
+ self,
783
+ top_down: bool = True,
784
+ on_error: Callable[[OSError], object] | None = None,
785
+ follow_symlinks: bool = False,
786
+ ) -> AsyncIterator[tuple[Self, list[str], list[str]]]:
787
+ """Recursively walk directory tree.
788
+
789
+ Uses ``_is_dir_batch_fn`` for an efficient batch check when available,
790
+ otherwise falls back to per-entry ``is_dir()`` via ``asyncio.gather``.
791
+ ``follow_symlinks`` is accepted for API parity; cloud objects have no symlinks.
792
+ """
793
+ try:
794
+ entries = [entry async for entry in self.iterdir()]
795
+ except OSError as e:
796
+ if on_error is not None:
797
+ on_error(e)
798
+ return
799
+ raise
800
+ batch_fn = type(self)._is_dir_batch_fn
801
+ if entries and batch_fn is not None:
802
+ prefixes = [e._item_path for e in entries]
803
+ is_dir_results = await batch_fn(
804
+ prefixes=prefixes,
805
+ use_h2=len(prefixes) >= H2_BATCH_THRESHOLD,
806
+ **self._native_kwargs,
807
+ )
808
+ elif entries:
809
+ import asyncio
810
+
811
+ is_dir_results = await asyncio.gather(*(e.is_dir() for e in entries))
812
+ else:
813
+ is_dir_results = []
814
+ dirs = [e for e, d in zip(entries, is_dir_results) if d]
815
+ files = [e for e, d in zip(entries, is_dir_results) if not d]
816
+ if top_down:
817
+ yield self, [d.name for d in dirs], [f.name for f in files]
818
+ for d in dirs:
819
+ async for result in d.walk(
820
+ top_down=top_down, on_error=on_error, follow_symlinks=follow_symlinks
821
+ ):
822
+ yield result
823
+ if not top_down:
824
+ yield self, [d.name for d in dirs], [f.name for f in files]
825
+
826
+
827
+ # ---------------------------------------------------------------------------
828
+ # Cloud file handle
829
+ # ---------------------------------------------------------------------------
830
+
831
+
832
+ class _CloudRawIO(io.RawIOBase):
833
+ """Raw I/O stream backed by cloud range reads.
834
+
835
+ Each ``readinto()`` call issues a single range request for the requested
836
+ number of bytes. Wrap in ``io.BufferedReader`` to get chunked prefetch.
837
+ """
838
+
839
+ def __init__(self, path, size: int):
840
+ super().__init__()
841
+ self._path = path
842
+ self._size = size
843
+ self._pos = 0
844
+
845
+ def readable(self):
846
+ return True
847
+
848
+ def seekable(self):
849
+ return True
850
+
851
+ def readinto(self, b):
852
+ if self._pos >= self._size:
853
+ return 0
854
+ n_want = min(len(b), self._size - self._pos)
855
+ end = self._pos + n_want - 1
856
+ from asanypath.sync import _SyncRunner
857
+
858
+ data = _SyncRunner.get().run(self._path._range_read(self._pos, end))
859
+ n = len(data)
860
+ b[:n] = data
861
+ self._pos += n
862
+ return n
863
+
864
+ def seek(self, offset, whence=0):
865
+ if whence == 0:
866
+ self._pos = offset
867
+ elif whence == 1:
868
+ self._pos += offset
869
+ elif whence == 2:
870
+ self._pos = self._size + offset
871
+ self._pos = max(0, min(self._pos, self._size))
872
+ return self._pos
873
+
874
+ def tell(self):
875
+ return self._pos
876
+
877
+
878
+ class _CloudFile:
879
+ """File handle backed by cloud read/write with lazy range-read support.
880
+
881
+ Supports both sync (``with``) and async (``async with``) context managers.
882
+
883
+ Read mode behaviour depends on file size and ``buffering``:
884
+
885
+ - **Small files** (size <= chunk_size) or no range-read support:
886
+ fetch everything on enter (single GET).
887
+ - **Large files** with range-read support: return a
888
+ ``BufferedReader(_CloudRawIO)`` that fetches chunks lazily.
889
+
890
+ Write mode buffers all writes and uploads on close.
891
+ """
892
+
893
+ _DEFAULT_CHUNK = 8 * 1024 * 1024 # 8 MiB — good default for cloud latency
894
+
895
+ def __init__(self, path, mode, buffering, encoding, errors, newline, backend_options=None):
896
+ self._path = path
897
+ self._mode = mode
898
+ self._buffering = buffering
899
+ self._encoding = encoding
900
+ self._errors = errors
901
+ self._newline = newline
902
+ self._backend_options = backend_options
903
+ self._buf: io.BytesIO | None = None
904
+ self._text_wrapper: io.TextIOWrapper | None = None
905
+ self._reader: io.BufferedReader | None = None
906
+ self._closed = False
907
+ self._sync = False
908
+
909
+ @property
910
+ def _chunk_size(self) -> int:
911
+ """Effective chunk size for range reads."""
912
+ if self._buffering <= 0:
913
+ return self._DEFAULT_CHUNK
914
+ return self._buffering
915
+
916
+ def _make_buffer(self, data: bytes | None = None) -> io.IOBase:
917
+ """Create an in-memory buffer (for small files or write mode)."""
918
+ if "r" in self._mode:
919
+ self._buf = io.BytesIO(data or b"")
920
+ else:
921
+ self._buf = io.BytesIO()
922
+ if "b" not in self._mode:
923
+ self._text_wrapper = io.TextIOWrapper(
924
+ self._buf,
925
+ encoding=self._encoding or "utf-8",
926
+ errors=self._errors or "strict",
927
+ newline=self._newline,
928
+ )
929
+ return self._text_wrapper
930
+ return self._buf
931
+
932
+ def _make_reader(self, size: int) -> io.IOBase:
933
+ """Create a lazily-filled reader backed by range reads."""
934
+ raw = _CloudRawIO(self._path, size)
935
+ if self._buffering == 0:
936
+ # Unbuffered: return raw directly (binary only)
937
+ if "b" not in self._mode:
938
+ raise ValueError("can't have unbuffered text I/O")
939
+ return raw
940
+ reader = io.BufferedReader(raw, buffer_size=self._chunk_size)
941
+ self._reader = reader
942
+ if "b" not in self._mode:
943
+ return io.TextIOWrapper(
944
+ reader,
945
+ encoding=self._encoding or "utf-8",
946
+ errors=self._errors or "strict",
947
+ newline=self._newline,
948
+ )
949
+ return reader
950
+
951
+ def _flush_write(self) -> bytes:
952
+ """Extract buffered write data."""
953
+ if self._text_wrapper is not None:
954
+ self._text_wrapper.flush()
955
+ return self._buf.getvalue()
956
+
957
+ def _get_size(self, headers: dict) -> int | None:
958
+ """Extract content-length from HEAD response headers."""
959
+ for k, v in headers.items():
960
+ if k.lower() == "content-length":
961
+ try:
962
+ return int(v)
963
+ except (ValueError, TypeError):
964
+ return None
965
+ return None
966
+
967
+ # ------------------------------------------------------------------
968
+ # Sync context manager
969
+ # ------------------------------------------------------------------
970
+
971
+ def __enter__(self):
972
+ from asanypath.sync import _SyncRunner
973
+
974
+ self._sync = True
975
+ runner = _SyncRunner.get()
976
+ if "r" in self._mode:
977
+ if self._path._supports_range_read:
978
+ try:
979
+ headers = runner.run(
980
+ self._path._get_batcher().head(
981
+ item=self._path._item_path, **self._path._native_kwargs
982
+ )
983
+ )
984
+ size = self._get_size(headers)
985
+ if size is not None and size > self._chunk_size:
986
+ return self._make_reader(size)
987
+ except Exception: # pragma: no cover
988
+ pass
989
+ # Small file or no range-read: fetch everything
990
+ data = runner.run(self._path.read_bytes())
991
+ return self._make_buffer(data)
992
+ return self._make_buffer()
993
+
994
+ def __exit__(self, exc_type, exc_val, exc_tb):
995
+ from asanypath.sync import _SyncRunner
996
+
997
+ if ("w" in self._mode or "a" in self._mode) and exc_type is None:
998
+ data = self._flush_write()
999
+ if self._backend_options is None:
1000
+ _SyncRunner.get().run(self._path.write_bytes(data))
1001
+ else:
1002
+ _SyncRunner.get().run(
1003
+ self._path.write_bytes(data, backend_options=self._backend_options)
1004
+ )
1005
+ if self._reader is not None:
1006
+ self._reader.close()
1007
+ self._closed = True
1008
+ return False # pragma: no cover
1009
+
1010
+ # ------------------------------------------------------------------
1011
+ # Async context manager
1012
+ # ------------------------------------------------------------------
1013
+
1014
+ async def __aenter__(self):
1015
+ self._sync = False
1016
+ if "r" in self._mode:
1017
+ if self._path._supports_range_read:
1018
+ try:
1019
+ headers = await self._path._get_batcher().head(
1020
+ item=self._path._item_path, **self._path._native_kwargs
1021
+ )
1022
+ size = self._get_size(headers)
1023
+ if size is not None and size > self._chunk_size:
1024
+ return self._make_reader(size)
1025
+ except Exception: # pragma: no cover
1026
+ pass
1027
+ # Small file or no range-read: fetch everything
1028
+ data = await self._path.read_bytes()
1029
+ return self._make_buffer(data)
1030
+ return self._make_buffer()
1031
+
1032
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
1033
+ if ("w" in self._mode or "a" in self._mode) and exc_type is None:
1034
+ data = self._flush_write()
1035
+ if self._backend_options is None:
1036
+ await self._path.write_bytes(data)
1037
+ else:
1038
+ await self._path.write_bytes(data, backend_options=self._backend_options)
1039
+ if self._reader is not None:
1040
+ self._reader.close()
1041
+ self._closed = True
1042
+ return False # pragma: no cover