asanypath 2.1.1__tar.gz → 2.2.0__tar.gz
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-2.1.1 → asanypath-2.2.0}/.github/workflows/unittests.yml +2 -5
- {asanypath-2.1.1 → asanypath-2.2.0}/.pre-commit-config.yaml +7 -1
- {asanypath-2.1.1 → asanypath-2.2.0}/PKG-INFO +131 -6
- {asanypath-2.1.1 → asanypath-2.2.0}/README.md +125 -4
- {asanypath-2.1.1 → asanypath-2.2.0}/pyproject.toml +23 -6
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/__init__.py +2 -0
- asanypath-2.2.0/src/asanypath/_resume.py +43 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/_version.py +2 -2
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/artifactory.py +52 -1
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/azure.py +52 -1
- asanypath-2.2.0/src/asanypath/cache.py +175 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/cloud.py +199 -30
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/ftp.py +44 -8
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/gcs.py +41 -1
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/http.py +4 -2
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/local.py +22 -16
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/options.py +30 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/s3.py +59 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/ssh.py +48 -7
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_artifactory.py +37 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_azure.py +42 -0
- asanypath-2.2.0/tests/test_cache.py +242 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_cloudpathmixin.py +21 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_ftp.py +19 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_gcs.py +69 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_local.py +6 -0
- asanypath-2.2.0/tests/test_options.py +32 -0
- asanypath-2.2.0/tests/test_resume.py +198 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_s3.py +166 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_ssh.py +25 -0
- asanypath-2.1.1/uv.lock +0 -2831
- {asanypath-2.1.1 → asanypath-2.2.0}/.copilot/skills/async-python-lib/SKILL.md +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/.copilot/skills/gitguro/SKILL.md +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/.github/CODEOWNERS +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/.github/dependabot.yml +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/.github/workflows/publish.yml +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/.gitignore +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/CODE_OF_CONDUCT.md +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/CONTRIBUTING.md +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/LICENSE +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/SECURITY.md +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/asanypath.png +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/compose.yml +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/hatch_build.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/scripts/bench.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/__init__.pyi +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/_credstore.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/_transfer.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/asanypath.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/asanypath.pyi +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/batcher.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/cli.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/common.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/exceptions.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/py.typed +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/sync.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/src/asanypath/unsupported.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/__init__.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/conftest.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_asanypath.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_batcher.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_cli.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_commonpathmixin.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_credstore.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_http.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_http_auth.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_py_typed.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_sync.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_syncpath.py +0 -0
- {asanypath-2.1.1 → asanypath-2.2.0}/tests/test_unsupported_path.py +0 -0
|
@@ -49,11 +49,8 @@ jobs:
|
|
|
49
49
|
- name: Ruff checks
|
|
50
50
|
run: uvx ruff check src tests && uvx ruff format --check src tests
|
|
51
51
|
|
|
52
|
-
- name:
|
|
53
|
-
run: uv
|
|
54
|
-
|
|
55
|
-
- name: Install project and native extension
|
|
56
|
-
run: uv pip install -e ".[dev]"
|
|
52
|
+
- name: Install project and native extension from uv.lock
|
|
53
|
+
run: uv sync --locked --extra dev --python "${{ matrix.python-version }}"
|
|
57
54
|
|
|
58
55
|
- name: Test with pytest and coverage
|
|
59
56
|
env:
|
|
@@ -22,4 +22,10 @@ repos:
|
|
|
22
22
|
entry: cargo check --manifest-path rust/Cargo.toml --locked
|
|
23
23
|
language: system
|
|
24
24
|
files: ^rust/.*\.(rs|toml|lock)$
|
|
25
|
-
pass_filenames: false
|
|
25
|
+
pass_filenames: false
|
|
26
|
+
# uv.lock must only reference the public index (a mirror URL leaks internal hosts).
|
|
27
|
+
- id: uv-lock-public-index
|
|
28
|
+
name: uv.lock uses only public index URLs
|
|
29
|
+
language: pygrep
|
|
30
|
+
entry: '(registry|url) = "https?://(?!pypi\.org/|files\.pythonhosted\.org/)'
|
|
31
|
+
files: ^uv\.lock$
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: asanypath
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Async pathlib for local and cloud storage
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -19,11 +19,12 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Requires-Dist: anyio>=4
|
|
22
|
-
Requires-Dist: asanypath-native~=2.
|
|
22
|
+
Requires-Dist: asanypath-native~=2.2.0
|
|
23
23
|
Requires-Dist: msgspec>=0.22.0
|
|
24
24
|
Requires-Dist: yarl>=1.2
|
|
25
25
|
Provides-Extra: all
|
|
26
26
|
Requires-Dist: aioftp>=0.21; extra == 'all'
|
|
27
|
+
Requires-Dist: diskcache>=5.6; extra == 'all'
|
|
27
28
|
Requires-Dist: keyring>=24; extra == 'all'
|
|
28
29
|
Provides-Extra: bench
|
|
29
30
|
Requires-Dist: adlfs; extra == 'bench'
|
|
@@ -37,12 +38,15 @@ Requires-Dist: gcloud-aio-storage; extra == 'bench'
|
|
|
37
38
|
Requires-Dist: gcsfs; extra == 'bench'
|
|
38
39
|
Requires-Dist: requests; extra == 'bench'
|
|
39
40
|
Requires-Dist: s3fs; extra == 'bench'
|
|
41
|
+
Provides-Extra: cache
|
|
42
|
+
Requires-Dist: diskcache>=5.6; extra == 'cache'
|
|
40
43
|
Provides-Extra: cli
|
|
41
44
|
Requires-Dist: rich-click>=1.9.7; extra == 'cli'
|
|
42
45
|
Provides-Extra: credstore
|
|
43
46
|
Requires-Dist: keyring>=24; extra == 'credstore'
|
|
44
47
|
Provides-Extra: dev
|
|
45
48
|
Requires-Dist: aioftp>=0.21; extra == 'dev'
|
|
49
|
+
Requires-Dist: diskcache>=5.6; extra == 'dev'
|
|
46
50
|
Requires-Dist: keyring>=24; extra == 'dev'
|
|
47
51
|
Requires-Dist: pre-commit>=4; extra == 'dev'
|
|
48
52
|
Requires-Dist: pytest-asyncio>=1.3; extra == 'dev'
|
|
@@ -161,11 +165,131 @@ for child in p.parent.iterdir(): # yields synchronously
|
|
|
161
165
|
Local paths (`file://` or bare `/path`) bypass the proxy entirely and use
|
|
162
166
|
`pathlib.Path` directly — zero threading overhead.
|
|
163
167
|
|
|
168
|
+
### Large uploads stream automatically
|
|
169
|
+
|
|
170
|
+
Writing or copying a large object never buffers the whole thing in memory — it
|
|
171
|
+
streams in bounded-size parts using each backend's native mechanism (S3
|
|
172
|
+
multipart, Azure block blobs, GCS resumable, SSH chunked writes, FTP data
|
|
173
|
+
connection, Artifactory streamed `PUT`). This is the **default** behaviour — no
|
|
174
|
+
flag, no API change:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
# open("wb") spills to disk past 16 MiB and uploads in parts on close
|
|
178
|
+
async with AsAnyPath("s3://bucket/huge.bin").open("wb") as f:
|
|
179
|
+
f.write(data)
|
|
180
|
+
|
|
181
|
+
# local -> cloud copy streams straight from the source file (async and sync)
|
|
182
|
+
await AsAnyPath("/data/movie.mp4").copy("s3://bucket/movie.mp4")
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Peak memory per upload is one part — **8 MiB by default**. Objects smaller than
|
|
186
|
+
one part upload in a single request. (`write_bytes(data)` / `write_text(...)`
|
|
187
|
+
take the whole payload in memory by definition — use `open("wb")` or `copy()`
|
|
188
|
+
for large data.)
|
|
189
|
+
|
|
190
|
+
**Tuning the memory footprint.** The part size *is* the max RAM held per
|
|
191
|
+
in-flight chunk (parts upload sequentially, so there is no concurrency
|
|
192
|
+
multiplier). Set it globally via env, or per call:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
export ASANYPATH_UPLOAD_CHUNK_SIZE=33554432 # 32 MiB parts (bytes)
|
|
196
|
+
export ASANYPATH_SPOOL_MAX_SIZE=67108864 # 64 MiB before open("wb") spills to disk
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
# per call: copy(chunk_size=...) or, for writes, open(..., buffering=...)
|
|
201
|
+
await src.copy("s3://bucket/huge.bin", chunk_size=32 * 1024 * 1024)
|
|
202
|
+
async with dst.open("wb", buffering=32 * 1024 * 1024) as f:
|
|
203
|
+
f.write(data)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Bigger parts mean fewer requests and a larger single-object ceiling (S3 allows
|
|
207
|
+
10,000 parts, so 8 MiB parts cap an object at ~80 GB). Backend minimums are
|
|
208
|
+
applied automatically: **S3** clamps to ≥ 5 MiB parts, **GCS** rounds down to a
|
|
209
|
+
256 KiB multiple.
|
|
210
|
+
|
|
164
211
|
Unknown URI schemes now construct as `UnsupportedProtocolPath` placeholders.
|
|
165
212
|
Pure path operations (e.g. `.name`, `.parent`, joins) still work, while backend
|
|
166
213
|
operations (e.g. `.exists()`, `.read_bytes()`, `.open()`) raise
|
|
167
214
|
`UnsupportedProtocolError` on use.
|
|
168
215
|
|
|
216
|
+
### Download caching (opt-in)
|
|
217
|
+
|
|
218
|
+
Reading the same object or URL twice always re-downloads it — there is **no
|
|
219
|
+
cache by default**, and the read path is unchanged unless you opt in, so
|
|
220
|
+
non-caching callers pay zero overhead.
|
|
221
|
+
|
|
222
|
+
Enable it with the optional `cache` extra (`pip install asanypath[cache]`,
|
|
223
|
+
backed by [`diskcache`](https://pypi.org/project/diskcache/) — a pure-Python,
|
|
224
|
+
zero-dependency store). Opt in per read with `use_cache=True`:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from asanypath import AsAnyPath
|
|
228
|
+
|
|
229
|
+
p = AsAnyPath("s3://bucket/model.bin")
|
|
230
|
+
data = await p.read_bytes(use_cache=True) # first call fetches + stores
|
|
231
|
+
data = await p.read_bytes(use_cache=True) # subsequent calls served from disk
|
|
232
|
+
text = await p.read_text(use_cache=True)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Configure the location and limits once (location defaults to
|
|
236
|
+
`~/.cache/asanypath`, honoring `$XDG_CACHE_HOME` and `$ASANYPATH_CACHE_DIR`):
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from asanypath import configure_cache
|
|
240
|
+
|
|
241
|
+
configure_cache(
|
|
242
|
+
directory="/var/cache/asanypath", # default: ~/.cache/asanypath
|
|
243
|
+
size_limit=2 * 1024**3, # total on-disk cap in bytes (default 1 GiB, LRU)
|
|
244
|
+
ttl=3600, # per-entry expiry in seconds (default: none)
|
|
245
|
+
enabled=True, # make use_cache default to True process-wide
|
|
246
|
+
)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Or via environment: `ASANYPATH_CACHE_DIR`, `ASANYPATH_CACHE_SIZE_LIMIT` (bytes),
|
|
250
|
+
`ASANYPATH_CACHE_TTL` (seconds).
|
|
251
|
+
|
|
252
|
+
Behaviour and trade-offs:
|
|
253
|
+
|
|
254
|
+
- **Off by default, opt-in per call.** With `configure_cache(enabled=True)` the
|
|
255
|
+
default flips process-wide; an explicit `use_cache=False` always bypasses.
|
|
256
|
+
- **Works for every remote backend** (S3, GCS, Azure, Artifactory, HTTP, SSH,
|
|
257
|
+
FTP). Local paths are never cached.
|
|
258
|
+
- **Total-size bounded, LRU-evicted;** disk I/O runs in a worker thread so the
|
|
259
|
+
event loop stays responsive. Concurrent reads of the same key collapse to a
|
|
260
|
+
single fetch.
|
|
261
|
+
- **Staleness is on you.** Entries are keyed by URL with no revalidation — a
|
|
262
|
+
cached object that changes upstream is served stale until it expires (`ttl`)
|
|
263
|
+
or is evicted. Prefer caching immutable/versioned objects, or set a `ttl`.
|
|
264
|
+
|
|
265
|
+
### Resumable downloads
|
|
266
|
+
|
|
267
|
+
`download(dst)` streams an object to a local file and **resumes an interrupted
|
|
268
|
+
transfer** instead of starting over:
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
|
|
272
|
+
# process killed mid-download... run the same call again — it continues
|
|
273
|
+
await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Bytes stream into `<dst>.part` with a `<dst>.part.meta` sidecar recording the
|
|
277
|
+
object's ETag (or `Last-Modified`) and total size. On resume the identity is
|
|
278
|
+
re-checked: if the upstream object is unchanged the download continues from the
|
|
279
|
+
saved offset; if it changed, the partial is discarded and it restarts cleanly.
|
|
280
|
+
On completion `<dst>.part` is atomically renamed to `dst`.
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
# tune the range-request size; disable resume to always start fresh
|
|
284
|
+
await path.download("/data/huge.bin", chunk_size=32 * 1024 * 1024, resume=False)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Resume needs range reads **and** a HEAD probe, so it's available on the object
|
|
288
|
+
stores (S3/GCS/Azure) and Artifactory/HTTP where the server supports it.
|
|
289
|
+
Backends without those (e.g. FTP) download in a single pass automatically. This
|
|
290
|
+
needs no extra dependency — resume state is plain sidecar files, independent of
|
|
291
|
+
the optional download cache.
|
|
292
|
+
|
|
169
293
|
## Installation
|
|
170
294
|
|
|
171
295
|
Most cloud backends (S3, GCS, Azure, Artifactory, HTTP) are included in the base
|
|
@@ -438,10 +562,11 @@ Factory that returns the appropriate path instance based on the URI scheme.
|
|
|
438
562
|
|
|
439
563
|
All path implementations support:
|
|
440
564
|
|
|
441
|
-
- `open(mode, buffering)` — file-like streaming with lazy range reads
|
|
442
|
-
- `copy(dst, recursive)` — copy file or tree (cross-backend)
|
|
443
|
-
- `
|
|
444
|
-
- `
|
|
565
|
+
- `open(mode, buffering)` — file-like streaming with lazy range reads; for writes, `buffering` sets the upload part size
|
|
566
|
+
- `copy(dst, recursive, chunk_size)` — copy file or tree (cross-backend); `chunk_size` tunes the upload part size
|
|
567
|
+
- `download(dst, resume, chunk_size)` — stream an object to a local file, resuming an interrupted transfer
|
|
568
|
+
- `read_bytes()` / `write_bytes(data)` — `read_bytes(use_cache=True)` serves from / stores in the opt-in [download cache](#download-caching-opt-in)
|
|
569
|
+
- `read_text(encoding)` / `write_text(data, encoding)` — `read_text` also accepts `use_cache`
|
|
445
570
|
- `exists()` / `is_file()` / `is_dir()`
|
|
446
571
|
- `stat()` / `checksums()` (cloud backends)
|
|
447
572
|
- `mkdir(parents, exist_ok)` / `touch(exist_ok)`
|
|
@@ -106,11 +106,131 @@ for child in p.parent.iterdir(): # yields synchronously
|
|
|
106
106
|
Local paths (`file://` or bare `/path`) bypass the proxy entirely and use
|
|
107
107
|
`pathlib.Path` directly — zero threading overhead.
|
|
108
108
|
|
|
109
|
+
### Large uploads stream automatically
|
|
110
|
+
|
|
111
|
+
Writing or copying a large object never buffers the whole thing in memory — it
|
|
112
|
+
streams in bounded-size parts using each backend's native mechanism (S3
|
|
113
|
+
multipart, Azure block blobs, GCS resumable, SSH chunked writes, FTP data
|
|
114
|
+
connection, Artifactory streamed `PUT`). This is the **default** behaviour — no
|
|
115
|
+
flag, no API change:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# open("wb") spills to disk past 16 MiB and uploads in parts on close
|
|
119
|
+
async with AsAnyPath("s3://bucket/huge.bin").open("wb") as f:
|
|
120
|
+
f.write(data)
|
|
121
|
+
|
|
122
|
+
# local -> cloud copy streams straight from the source file (async and sync)
|
|
123
|
+
await AsAnyPath("/data/movie.mp4").copy("s3://bucket/movie.mp4")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Peak memory per upload is one part — **8 MiB by default**. Objects smaller than
|
|
127
|
+
one part upload in a single request. (`write_bytes(data)` / `write_text(...)`
|
|
128
|
+
take the whole payload in memory by definition — use `open("wb")` or `copy()`
|
|
129
|
+
for large data.)
|
|
130
|
+
|
|
131
|
+
**Tuning the memory footprint.** The part size *is* the max RAM held per
|
|
132
|
+
in-flight chunk (parts upload sequentially, so there is no concurrency
|
|
133
|
+
multiplier). Set it globally via env, or per call:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
export ASANYPATH_UPLOAD_CHUNK_SIZE=33554432 # 32 MiB parts (bytes)
|
|
137
|
+
export ASANYPATH_SPOOL_MAX_SIZE=67108864 # 64 MiB before open("wb") spills to disk
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# per call: copy(chunk_size=...) or, for writes, open(..., buffering=...)
|
|
142
|
+
await src.copy("s3://bucket/huge.bin", chunk_size=32 * 1024 * 1024)
|
|
143
|
+
async with dst.open("wb", buffering=32 * 1024 * 1024) as f:
|
|
144
|
+
f.write(data)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Bigger parts mean fewer requests and a larger single-object ceiling (S3 allows
|
|
148
|
+
10,000 parts, so 8 MiB parts cap an object at ~80 GB). Backend minimums are
|
|
149
|
+
applied automatically: **S3** clamps to ≥ 5 MiB parts, **GCS** rounds down to a
|
|
150
|
+
256 KiB multiple.
|
|
151
|
+
|
|
109
152
|
Unknown URI schemes now construct as `UnsupportedProtocolPath` placeholders.
|
|
110
153
|
Pure path operations (e.g. `.name`, `.parent`, joins) still work, while backend
|
|
111
154
|
operations (e.g. `.exists()`, `.read_bytes()`, `.open()`) raise
|
|
112
155
|
`UnsupportedProtocolError` on use.
|
|
113
156
|
|
|
157
|
+
### Download caching (opt-in)
|
|
158
|
+
|
|
159
|
+
Reading the same object or URL twice always re-downloads it — there is **no
|
|
160
|
+
cache by default**, and the read path is unchanged unless you opt in, so
|
|
161
|
+
non-caching callers pay zero overhead.
|
|
162
|
+
|
|
163
|
+
Enable it with the optional `cache` extra (`pip install asanypath[cache]`,
|
|
164
|
+
backed by [`diskcache`](https://pypi.org/project/diskcache/) — a pure-Python,
|
|
165
|
+
zero-dependency store). Opt in per read with `use_cache=True`:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from asanypath import AsAnyPath
|
|
169
|
+
|
|
170
|
+
p = AsAnyPath("s3://bucket/model.bin")
|
|
171
|
+
data = await p.read_bytes(use_cache=True) # first call fetches + stores
|
|
172
|
+
data = await p.read_bytes(use_cache=True) # subsequent calls served from disk
|
|
173
|
+
text = await p.read_text(use_cache=True)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Configure the location and limits once (location defaults to
|
|
177
|
+
`~/.cache/asanypath`, honoring `$XDG_CACHE_HOME` and `$ASANYPATH_CACHE_DIR`):
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from asanypath import configure_cache
|
|
181
|
+
|
|
182
|
+
configure_cache(
|
|
183
|
+
directory="/var/cache/asanypath", # default: ~/.cache/asanypath
|
|
184
|
+
size_limit=2 * 1024**3, # total on-disk cap in bytes (default 1 GiB, LRU)
|
|
185
|
+
ttl=3600, # per-entry expiry in seconds (default: none)
|
|
186
|
+
enabled=True, # make use_cache default to True process-wide
|
|
187
|
+
)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Or via environment: `ASANYPATH_CACHE_DIR`, `ASANYPATH_CACHE_SIZE_LIMIT` (bytes),
|
|
191
|
+
`ASANYPATH_CACHE_TTL` (seconds).
|
|
192
|
+
|
|
193
|
+
Behaviour and trade-offs:
|
|
194
|
+
|
|
195
|
+
- **Off by default, opt-in per call.** With `configure_cache(enabled=True)` the
|
|
196
|
+
default flips process-wide; an explicit `use_cache=False` always bypasses.
|
|
197
|
+
- **Works for every remote backend** (S3, GCS, Azure, Artifactory, HTTP, SSH,
|
|
198
|
+
FTP). Local paths are never cached.
|
|
199
|
+
- **Total-size bounded, LRU-evicted;** disk I/O runs in a worker thread so the
|
|
200
|
+
event loop stays responsive. Concurrent reads of the same key collapse to a
|
|
201
|
+
single fetch.
|
|
202
|
+
- **Staleness is on you.** Entries are keyed by URL with no revalidation — a
|
|
203
|
+
cached object that changes upstream is served stale until it expires (`ttl`)
|
|
204
|
+
or is evicted. Prefer caching immutable/versioned objects, or set a `ttl`.
|
|
205
|
+
|
|
206
|
+
### Resumable downloads
|
|
207
|
+
|
|
208
|
+
`download(dst)` streams an object to a local file and **resumes an interrupted
|
|
209
|
+
transfer** instead of starting over:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
|
|
213
|
+
# process killed mid-download... run the same call again — it continues
|
|
214
|
+
await AsAnyPath("s3://bucket/huge.bin").download("/data/huge.bin")
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Bytes stream into `<dst>.part` with a `<dst>.part.meta` sidecar recording the
|
|
218
|
+
object's ETag (or `Last-Modified`) and total size. On resume the identity is
|
|
219
|
+
re-checked: if the upstream object is unchanged the download continues from the
|
|
220
|
+
saved offset; if it changed, the partial is discarded and it restarts cleanly.
|
|
221
|
+
On completion `<dst>.part` is atomically renamed to `dst`.
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
# tune the range-request size; disable resume to always start fresh
|
|
225
|
+
await path.download("/data/huge.bin", chunk_size=32 * 1024 * 1024, resume=False)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Resume needs range reads **and** a HEAD probe, so it's available on the object
|
|
229
|
+
stores (S3/GCS/Azure) and Artifactory/HTTP where the server supports it.
|
|
230
|
+
Backends without those (e.g. FTP) download in a single pass automatically. This
|
|
231
|
+
needs no extra dependency — resume state is plain sidecar files, independent of
|
|
232
|
+
the optional download cache.
|
|
233
|
+
|
|
114
234
|
## Installation
|
|
115
235
|
|
|
116
236
|
Most cloud backends (S3, GCS, Azure, Artifactory, HTTP) are included in the base
|
|
@@ -383,10 +503,11 @@ Factory that returns the appropriate path instance based on the URI scheme.
|
|
|
383
503
|
|
|
384
504
|
All path implementations support:
|
|
385
505
|
|
|
386
|
-
- `open(mode, buffering)` — file-like streaming with lazy range reads
|
|
387
|
-
- `copy(dst, recursive)` — copy file or tree (cross-backend)
|
|
388
|
-
- `
|
|
389
|
-
- `
|
|
506
|
+
- `open(mode, buffering)` — file-like streaming with lazy range reads; for writes, `buffering` sets the upload part size
|
|
507
|
+
- `copy(dst, recursive, chunk_size)` — copy file or tree (cross-backend); `chunk_size` tunes the upload part size
|
|
508
|
+
- `download(dst, resume, chunk_size)` — stream an object to a local file, resuming an interrupted transfer
|
|
509
|
+
- `read_bytes()` / `write_bytes(data)` — `read_bytes(use_cache=True)` serves from / stores in the opt-in [download cache](#download-caching-opt-in)
|
|
510
|
+
- `read_text(encoding)` / `write_text(data, encoding)` — `read_text` also accepts `use_cache`
|
|
390
511
|
- `exists()` / `is_file()` / `is_dir()`
|
|
391
512
|
- `stat()` / `checksums()` (cloud backends)
|
|
392
513
|
- `mkdir(parents, exist_ok)` / `touch(exist_ok)`
|
|
@@ -20,7 +20,7 @@ asanypath-native = { path = "rust" }
|
|
|
20
20
|
root = "src"
|
|
21
21
|
|
|
22
22
|
[tool.hatch.build.targets.sdist]
|
|
23
|
-
exclude = ["rust"]
|
|
23
|
+
exclude = ["rust", "uv.lock"]
|
|
24
24
|
|
|
25
25
|
[tool.hatch.metadata.hooks.custom]
|
|
26
26
|
|
|
@@ -58,6 +58,7 @@ dev = [
|
|
|
58
58
|
cli = ["rich-click>=1.9.7"]
|
|
59
59
|
ftp = ["aioftp>=0.21"]
|
|
60
60
|
credstore = ["keyring>=24"]
|
|
61
|
+
cache = ["diskcache>=5.6"]
|
|
61
62
|
bench = [
|
|
62
63
|
"asyncssh>=2.14",
|
|
63
64
|
"fsspec",
|
|
@@ -71,7 +72,7 @@ bench = [
|
|
|
71
72
|
"aiofiles",
|
|
72
73
|
"requests",
|
|
73
74
|
]
|
|
74
|
-
all = ["asanypath[credstore,ftp]"]
|
|
75
|
+
all = ["asanypath[credstore,ftp,cache]"]
|
|
75
76
|
[project.scripts]
|
|
76
77
|
asanypath = "asanypath.cli:main"
|
|
77
78
|
|
|
@@ -102,7 +103,7 @@ disable_error_code = ["attr-defined", "arg-type", "assignment"]
|
|
|
102
103
|
module = "asanypath.local"
|
|
103
104
|
disallow_untyped_defs = false
|
|
104
105
|
warn_return_any = false
|
|
105
|
-
disable_error_code = ["type-var", "misc", "override", "return-value"]
|
|
106
|
+
disable_error_code = ["type-var", "misc", "override", "return-value", "assignment", "no-redef"]
|
|
106
107
|
|
|
107
108
|
[[tool.mypy.overrides]]
|
|
108
109
|
module = "asanypath.common"
|
|
@@ -120,24 +121,40 @@ disable_error_code = ["override", "type-arg", "return-value", "assignment", "att
|
|
|
120
121
|
module = ["asanypath.s3", "asanypath.gcs", "asanypath.azure", "asanypath.artifactory"]
|
|
121
122
|
disallow_untyped_defs = false
|
|
122
123
|
warn_return_any = false
|
|
123
|
-
disable_error_code = ["no-any-return", "attr-defined", "assignment", "override"]
|
|
124
|
+
disable_error_code = ["no-any-return", "attr-defined", "assignment", "override", "arg-type", "misc"]
|
|
125
|
+
|
|
126
|
+
[[tool.mypy.overrides]]
|
|
127
|
+
module = ["asanypath.ssh", "asanypath.ftp"]
|
|
128
|
+
disallow_untyped_defs = false
|
|
129
|
+
warn_return_any = false
|
|
130
|
+
disable_error_code = ["attr-defined", "override", "arg-type", "misc", "no-any-return", "assignment"]
|
|
131
|
+
|
|
132
|
+
[[tool.mypy.overrides]]
|
|
133
|
+
module = "asanypath.sync"
|
|
134
|
+
disallow_untyped_defs = false
|
|
135
|
+
warn_return_any = false
|
|
136
|
+
disable_error_code = ["no-any-return", "misc", "attr-defined", "arg-type"]
|
|
124
137
|
|
|
125
138
|
[[tool.mypy.overrides]]
|
|
126
139
|
module = "asanypath.cloud"
|
|
127
140
|
disallow_untyped_defs = false
|
|
128
141
|
warn_return_any = false
|
|
129
|
-
disable_error_code = ["no-any-return", "arg-type"]
|
|
142
|
+
disable_error_code = ["no-any-return", "arg-type", "attr-defined", "misc", "assignment"]
|
|
130
143
|
|
|
131
144
|
[[tool.mypy.overrides]]
|
|
132
145
|
module = "asanypath.asanypath"
|
|
133
146
|
disallow_untyped_defs = false
|
|
134
147
|
warn_return_any = false
|
|
135
|
-
disable_error_code = ["no-any-return", "misc", "union-attr", "type-var"]
|
|
148
|
+
disable_error_code = ["no-any-return", "misc", "union-attr", "type-var", "attr-defined"]
|
|
136
149
|
|
|
137
150
|
[[tool.mypy.overrides]]
|
|
138
151
|
module = "asanypath.batcher"
|
|
139
152
|
disallow_untyped_defs = false
|
|
140
153
|
|
|
154
|
+
[[tool.mypy.overrides]]
|
|
155
|
+
module = "diskcache.*"
|
|
156
|
+
ignore_missing_imports = true
|
|
157
|
+
|
|
141
158
|
[tool.pytest.ini_options]
|
|
142
159
|
asyncio_mode = "auto"
|
|
143
160
|
testpaths = ["tests"]
|
|
@@ -13,6 +13,7 @@ from asanypath.asanypath import (
|
|
|
13
13
|
register_protocol,
|
|
14
14
|
)
|
|
15
15
|
from asanypath.azure import AzurePath
|
|
16
|
+
from asanypath.cache import configure_cache
|
|
16
17
|
from asanypath.cloud import CloudPathMixin
|
|
17
18
|
from asanypath.common import CommonPurePathMixin
|
|
18
19
|
from asanypath.exceptions import (
|
|
@@ -62,6 +63,7 @@ __all__ = [
|
|
|
62
63
|
"GCSPath",
|
|
63
64
|
"AzurePath",
|
|
64
65
|
"ArtifactoryPath",
|
|
66
|
+
"configure_cache",
|
|
65
67
|
"register_protocol",
|
|
66
68
|
"list_protocols",
|
|
67
69
|
"AsAnyPathError",
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
"""Sidecar state for resumable downloads.
|
|
8
|
+
|
|
9
|
+
A partial download lives in ``<dst>.part`` with a ``<dst>.part.meta`` sidecar
|
|
10
|
+
recording the remote object's identity (ETag/Last-Modified) and total size. On
|
|
11
|
+
resume the identity is re-checked so a changed upstream object restarts cleanly
|
|
12
|
+
rather than stitching mismatched bytes. State is plain files — no dependency on
|
|
13
|
+
the optional download cache.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import msgspec
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResumeMeta(msgspec.Struct, frozen=True):
|
|
24
|
+
"""Identity and size of the object a ``.part`` file is downloading."""
|
|
25
|
+
|
|
26
|
+
identity: str | None
|
|
27
|
+
size: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_encoder = msgspec.json.Encoder()
|
|
31
|
+
_decoder = msgspec.json.Decoder(ResumeMeta)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_meta(path: Path) -> ResumeMeta | None:
|
|
35
|
+
"""Return the sidecar meta at *path*, or ``None`` if missing/corrupt."""
|
|
36
|
+
try:
|
|
37
|
+
return _decoder.decode(path.read_bytes())
|
|
38
|
+
except (OSError, msgspec.DecodeError):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_meta(path: Path, meta: ResumeMeta) -> None:
|
|
43
|
+
path.write_bytes(_encoder.encode(meta))
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 2, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import tempfile
|
|
12
15
|
from collections.abc import AsyncIterator, Mapping
|
|
13
16
|
from datetime import datetime, timezone
|
|
14
17
|
from os import getenv
|
|
@@ -31,11 +34,18 @@ from asanypath_native import (
|
|
|
31
34
|
art_list_repos,
|
|
32
35
|
art_put_batch,
|
|
33
36
|
art_put_permission_target,
|
|
37
|
+
art_put_stream,
|
|
34
38
|
art_storage_info,
|
|
35
39
|
)
|
|
36
40
|
|
|
37
41
|
from asanypath.cloud import CloudPathMixin
|
|
38
|
-
from asanypath.options import
|
|
42
|
+
from asanypath.options import (
|
|
43
|
+
UPLOAD_CHUNK_SIZE,
|
|
44
|
+
AccessGrant,
|
|
45
|
+
AccessPolicy,
|
|
46
|
+
AccessPolicyPatch,
|
|
47
|
+
BackendOptions,
|
|
48
|
+
)
|
|
39
49
|
|
|
40
50
|
|
|
41
51
|
def _jfrog_conf_token(base_url: str) -> str | None:
|
|
@@ -95,6 +105,9 @@ class ArtifactoryPath(CloudPathMixin):
|
|
|
95
105
|
|
|
96
106
|
protocol: str = "art"
|
|
97
107
|
_supports_range_read: bool = True
|
|
108
|
+
# Artifactory has no multipart API: objects at/above this size stream a single
|
|
109
|
+
# PUT body from disk (bounded memory) instead of buffering the whole object.
|
|
110
|
+
_STREAM_THRESHOLD = UPLOAD_CHUNK_SIZE
|
|
98
111
|
_copy_batch_fn = staticmethod(art_copy_batch)
|
|
99
112
|
|
|
100
113
|
@staticmethod
|
|
@@ -194,6 +207,44 @@ class ArtifactoryPath(CloudPathMixin):
|
|
|
194
207
|
# Backend-specific operations
|
|
195
208
|
# ------------------------------------------------------------------
|
|
196
209
|
|
|
210
|
+
async def _upload_buffer(
|
|
211
|
+
self, fileobj, size: int, *, backend_options=None, chunk_size: int | None = None
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Upload a spooled write buffer; large objects stream a single PUT from disk."""
|
|
214
|
+
if size < (chunk_size or self._STREAM_THRESHOLD):
|
|
215
|
+
data = fileobj.read()
|
|
216
|
+
if backend_options is None:
|
|
217
|
+
await self.write_bytes(data)
|
|
218
|
+
else:
|
|
219
|
+
await self.write_bytes(data, backend_options=backend_options)
|
|
220
|
+
return
|
|
221
|
+
# Stream from a real file path when we have one (e.g. a local copy
|
|
222
|
+
# source); otherwise spill the anonymous buffer to a named temp file.
|
|
223
|
+
name = getattr(fileobj, "name", None)
|
|
224
|
+
if isinstance(name, str) and os.path.exists(name):
|
|
225
|
+
await self._stream_upload(name, size, backend_options=backend_options)
|
|
226
|
+
return
|
|
227
|
+
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
|
228
|
+
shutil.copyfileobj(fileobj, tmp, 1024 * 1024)
|
|
229
|
+
tmp_path = tmp.name
|
|
230
|
+
try:
|
|
231
|
+
await self._stream_upload(tmp_path, size, backend_options=backend_options)
|
|
232
|
+
finally:
|
|
233
|
+
os.unlink(tmp_path)
|
|
234
|
+
|
|
235
|
+
async def _stream_upload(self, file_path: str, size: int, *, backend_options=None) -> None:
|
|
236
|
+
options_json = (
|
|
237
|
+
msgspec.json.encode(backend_options).decode() if backend_options is not None else "{}"
|
|
238
|
+
)
|
|
239
|
+
await art_put_stream(
|
|
240
|
+
key=self._item_path,
|
|
241
|
+
file_path=file_path,
|
|
242
|
+
size=size,
|
|
243
|
+
options_json=options_json,
|
|
244
|
+
**self._native_kwargs,
|
|
245
|
+
)
|
|
246
|
+
type(self)._listing_cache.pop(self._listing_cache_key(), None)
|
|
247
|
+
|
|
197
248
|
async def _storage_info(self) -> dict:
|
|
198
249
|
"""Fetch item metadata from the Artifactory storage API (cached).
|
|
199
250
|
|
|
@@ -12,6 +12,7 @@ https://learn.microsoft.com/en-us/rest/api/storageservices/blob-service-rest-api
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import base64
|
|
15
16
|
from collections.abc import Mapping
|
|
16
17
|
from datetime import datetime, timezone
|
|
17
18
|
from os import getenv
|
|
@@ -31,11 +32,19 @@ from asanypath_native import (
|
|
|
31
32
|
az_list_containers,
|
|
32
33
|
az_presign,
|
|
33
34
|
az_put_batch,
|
|
35
|
+
az_put_block,
|
|
36
|
+
az_put_block_list,
|
|
34
37
|
az_put_container_acl,
|
|
35
38
|
)
|
|
36
39
|
|
|
37
40
|
from asanypath.cloud import CloudPathMixin
|
|
38
|
-
from asanypath.options import
|
|
41
|
+
from asanypath.options import (
|
|
42
|
+
UPLOAD_CHUNK_SIZE,
|
|
43
|
+
AccessGrant,
|
|
44
|
+
AccessPolicy,
|
|
45
|
+
AccessPolicyPatch,
|
|
46
|
+
BackendOptions,
|
|
47
|
+
)
|
|
39
48
|
|
|
40
49
|
AZURE_API_VERSION = "2023-11-03"
|
|
41
50
|
|
|
@@ -82,6 +91,8 @@ class AzurePath(CloudPathMixin):
|
|
|
82
91
|
|
|
83
92
|
protocol: str = "az"
|
|
84
93
|
_supports_range_read: bool = True
|
|
94
|
+
# Bytes per staged block; objects below one block upload in a single PUT.
|
|
95
|
+
_MULTIPART_PART_SIZE = UPLOAD_CHUNK_SIZE
|
|
85
96
|
_is_dir_batch_fn = staticmethod(az_is_dir_batch)
|
|
86
97
|
_copy_batch_fn = staticmethod(az_copy_batch)
|
|
87
98
|
|
|
@@ -180,6 +191,46 @@ class AzurePath(CloudPathMixin):
|
|
|
180
191
|
# Backend-specific operations
|
|
181
192
|
# ------------------------------------------------------------------
|
|
182
193
|
|
|
194
|
+
async def _upload_buffer(
|
|
195
|
+
self, fileobj, size: int, *, backend_options=None, chunk_size: int | None = None
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Upload a spooled write buffer, using block blobs for large objects."""
|
|
198
|
+
part = chunk_size or self._MULTIPART_PART_SIZE
|
|
199
|
+
if size < part:
|
|
200
|
+
data = fileobj.read()
|
|
201
|
+
if backend_options is None:
|
|
202
|
+
await self.write_bytes(data)
|
|
203
|
+
else:
|
|
204
|
+
await self.write_bytes(data, backend_options=backend_options)
|
|
205
|
+
return
|
|
206
|
+
await self._block_upload(fileobj, part, backend_options=backend_options)
|
|
207
|
+
|
|
208
|
+
async def _block_upload(self, fileobj, part_size: int, *, backend_options=None) -> None:
|
|
209
|
+
"""Stage fixed-size blocks then commit the list (memory bounded to one block)."""
|
|
210
|
+
options_json = (
|
|
211
|
+
msgspec.json.encode(backend_options).decode() if backend_options is not None else "{}"
|
|
212
|
+
)
|
|
213
|
+
block_ids: list[str] = []
|
|
214
|
+
index = 0
|
|
215
|
+
while True:
|
|
216
|
+
chunk = fileobj.read(part_size)
|
|
217
|
+
if not chunk:
|
|
218
|
+
break
|
|
219
|
+
# Block ids must be equal-length base64 strings.
|
|
220
|
+
block_id = base64.b64encode(f"{index:048d}".encode()).decode()
|
|
221
|
+
await az_put_block(
|
|
222
|
+
blob=self._item_path, block_id=block_id, data=chunk, **self._native_kwargs
|
|
223
|
+
)
|
|
224
|
+
block_ids.append(block_id)
|
|
225
|
+
index += 1
|
|
226
|
+
await az_put_block_list(
|
|
227
|
+
blob=self._item_path,
|
|
228
|
+
block_ids=block_ids,
|
|
229
|
+
options_json=options_json,
|
|
230
|
+
**self._native_kwargs,
|
|
231
|
+
)
|
|
232
|
+
type(self)._listing_cache.pop(self._listing_cache_key(), None)
|
|
233
|
+
|
|
183
234
|
async def is_dir(self) -> bool:
|
|
184
235
|
"""Check if path is a directory prefix (has blobs with this prefix)."""
|
|
185
236
|
return await az_is_dir(prefix=self._blob_path, **self._native_kwargs)
|