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/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
|