fsspec-databricks 0.2.1__tar.gz → 0.3.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.
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/DEVELOPMENT.md +37 -12
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/PKG-INFO +1 -1
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/USAGE.md +8 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/base.py +10 -11
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/dbfs.py +3 -10
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/spec.py +3 -27
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/volume.py +216 -75
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/conftest.py +1 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/dummy_api.py +10 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_volume.py +117 -2
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_volume_fs.py +251 -6
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/.gitignore +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/AUTHENTICATION.md +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/LICENSE +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/README.md +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/SECURITY.md +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/pyproject.toml +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/__init__.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/error.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/file.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/local.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/path.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/py.typed +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/utils.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/workspace.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/__init__.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_base_fs.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_file.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_path.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_spec_fs.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_utils.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_workspace_fs.py +0 -0
- {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/utils.py +0 -0
|
@@ -147,6 +147,9 @@ Prefer federation: it needs no stored secret. GitHub's OIDC token identifies eac
|
|
|
147
147
|
|
|
148
148
|
- **`github-oidc`**: a federation policy on the service principal, in the Databricks account console. Its audience is
|
|
149
149
|
`DATABRICKS_TOKEN_AUDIENCE`, e.g. the account ID.
|
|
150
|
+
The SDK's `github-oidc` exchanges a new token for each request, which slows the tests down.
|
|
151
|
+
So `.github/scripts/with-id-token-file.sh` keeps GitHub's token in a file and runs the tests with `file-oidc`, which
|
|
152
|
+
caches the exchanged token.
|
|
150
153
|
- **`github-oidc-azure`**: a federated credential on the Entra ID app, with the audience `api://AzureADTokenExchange`.
|
|
151
154
|
The app is the workspace's service principal. This needs no access to the account console.
|
|
152
155
|
|
|
@@ -216,10 +219,14 @@ exception.
|
|
|
216
219
|
|
|
217
220
|
### Picklable
|
|
218
221
|
|
|
219
|
-
File systems are pickled to run in other processes, e.g. by Dask.
|
|
220
|
-
|
|
222
|
+
File systems are pickled to run in other processes, e.g. by Dask. fsspec pickles a file system as its constructor
|
|
223
|
+
arguments, and unpickling calls the constructor again:
|
|
221
224
|
|
|
222
|
-
|
|
225
|
+
- clients, event loops, threads and sessions are not pickled, and are made again on first use;
|
|
226
|
+
- attributes set after construction are lost;
|
|
227
|
+
- every constructor argument must be picklable.
|
|
228
|
+
|
|
229
|
+
A `WorkspaceClient` given as `client` is not picklable. Other ways of authenticating are.
|
|
223
230
|
|
|
224
231
|
### Known limits
|
|
225
232
|
|
|
@@ -271,7 +278,6 @@ Within one backend, both go to the backend.
|
|
|
271
278
|
|
|
272
279
|
- the Databricks SDK `Config` and `WorkspaceClient`, made on first use from `client`, `config` or `config_params`;
|
|
273
280
|
- `close()`, also called at exit and on garbage collection;
|
|
274
|
-
- pickling without the client and config;
|
|
275
281
|
- the `dbfs` protocol: `_strip_protocol` and `unstrip_protocol` use `path.py`.
|
|
276
282
|
|
|
277
283
|
Its `mkdir`, `makedirs`, `rmdir` and `_rm` only check preconditions, such as a missing parent or a file where a folder
|
|
@@ -309,17 +315,20 @@ when no glob pattern is involved. Files are opened with the SDK's `dbfs.open`.
|
|
|
309
315
|
|
|
310
316
|
### Volumes
|
|
311
317
|
|
|
312
|
-
`VolumeFileSystem`, in `volume.py`, subclasses `DBFS
|
|
318
|
+
`VolumeFileSystem`, in `volume.py`, subclasses `DBFS`. It keeps the SDK's `dbfs` for `exists` and for creating
|
|
319
|
+
folders. Listings, copies, moves and deletes inside a volume use the Files API and the backend's own file objects.
|
|
313
320
|
|
|
314
321
|
The APIs called depend on the depth of the path:
|
|
315
322
|
|
|
316
|
-
| Path | `info` | `ls`
|
|
317
|
-
|
|
318
|
-
| `/Volumes` | None: a fixed folder | `catalogs.list`
|
|
319
|
-
| `/Volumes/<catalog>` | `catalogs.get` | `schemas.list`
|
|
320
|
-
| `/Volumes/<catalog>/<schema>` | `schemas.get` | `volumes.list`
|
|
321
|
-
| `/Volumes/<catalog>/<schema>/<volume>` | `volumes.read` | `
|
|
322
|
-
| Deeper | `files.get_metadata`, else `files.get_directory_metadata` | `
|
|
323
|
+
| Path | `info` | `ls` |
|
|
324
|
+
|----------------------------------------|-----------------------------------------------------------|---------------------------------|
|
|
325
|
+
| `/Volumes` | None: a fixed folder | `catalogs.list` |
|
|
326
|
+
| `/Volumes/<catalog>` | `catalogs.get` | `schemas.list` |
|
|
327
|
+
| `/Volumes/<catalog>/<schema>` | `schemas.get` | `volumes.list` |
|
|
328
|
+
| `/Volumes/<catalog>/<schema>/<volume>` | `volumes.read` | `files.list_directory_contents` |
|
|
329
|
+
| Deeper | `files.get_metadata`, else `files.get_directory_metadata` | `files.list_directory_contents` |
|
|
330
|
+
|
|
331
|
+
`ls` of a file returns the file itself, from `files.get_metadata` after the listing fails.
|
|
323
332
|
|
|
324
333
|
`include_browse` is passed to the Unity Catalog APIs.
|
|
325
334
|
|
|
@@ -332,9 +341,22 @@ Each metadata call is a round trip, so the checks inside a volume make as few as
|
|
|
332
341
|
| `isdir`, so also `mkdir` and `rmdir` | 1: `files.get_directory_metadata` |
|
|
333
342
|
| `isfile` | 1: `files.get_metadata` |
|
|
334
343
|
| Opening for writing | 1: `isdir`, to refuse directories |
|
|
344
|
+
| `ls` of a directory | 1: `files.list_directory_contents` |
|
|
335
345
|
|
|
336
346
|
`DatabricksFileSystem` passes `isdir` and `isfile` to the backends. `test_volume_fs_metadata_calls` counts the calls.
|
|
337
347
|
|
|
348
|
+
Copies, moves and deletes inside a volume:
|
|
349
|
+
|
|
350
|
+
- `cp_file` streams the file from a `VolumeReadableFile` into a `VolumeWritableFile`, as the Files API has no copy. The
|
|
351
|
+
SDK's `dbfs.copy` would hold the whole file in memory.
|
|
352
|
+
- `mv` is fsspec's default: a copy, then a delete.
|
|
353
|
+
- `rm` of a file checks it with `info`, then calls `files.delete`.
|
|
354
|
+
- A recursive `rm` lists the tree one depth at a time, deletes the files, then the folders from the deepest. Each step
|
|
355
|
+
runs up to `_max_delete_concurrency` requests in a thread pool.
|
|
356
|
+
- Paths of volumes, schemas and catalogs go to `DBFS`.
|
|
357
|
+
|
|
358
|
+
`test_volume_fs_rm_calls` and `test_volume_fs_cp_file_streams` check these on a fake `WorkspaceClient.files`.
|
|
359
|
+
|
|
338
360
|
#### The IO event loop
|
|
339
361
|
|
|
340
362
|
Each `VolumeFileSystem` runs an asyncio event loop in a daemon thread, `dbfs-io-thread`, started on first use. It
|
|
@@ -370,6 +392,9 @@ loop:
|
|
|
370
392
|
`_compute_block_size` gives the block size: the smallest power-of-2 multiple of the minimum that covers the target with
|
|
371
393
|
`max_concurrency` blocks, capped at the maximum.
|
|
372
394
|
|
|
395
|
+
A range request that gets a `429`, `500`, `502`, `503` or `504` is retried up to 3 times, with exponential backoff and
|
|
396
|
+
jitter. Writes are not retried: a repeated `PUT` can fail with "already exists" when the first one went through.
|
|
397
|
+
|
|
373
398
|
#### Writing
|
|
374
399
|
|
|
375
400
|
`VolumeWritableFile` subclasses `AbstractAsyncWritableFile`. Writes are buffered:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fsspec-databricks
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: `fsspec`-based file system interface for Databricks file system"s"
|
|
5
5
|
Project-URL: Homepage, https://github.com/sugibuchi/fsspec-databricks
|
|
6
6
|
Author-email: Tsuyoshi SUGIBUCHI <sugibuchi@gmail.com>
|
|
@@ -166,6 +166,14 @@ fs.ls("dbfs:/Volumes/my_catalog/my_schema/my_volume/data")
|
|
|
166
166
|
Reads and writes run concurrent requests. Large writes use multipart upload, or resumable upload where the workspace
|
|
167
167
|
requires it.
|
|
168
168
|
|
|
169
|
+
Reads retry after transient server errors. Writes do not.
|
|
170
|
+
|
|
171
|
+
Copies, moves and deletes:
|
|
172
|
+
|
|
173
|
+
- A copy reads the file and writes it again, as the Files API cannot copy. It uses the options for reads and writes.
|
|
174
|
+
- A move is a copy, then a delete. It replaces an existing file at the destination.
|
|
175
|
+
- A recursive delete sends concurrent requests.
|
|
176
|
+
|
|
169
177
|
### Modes
|
|
170
178
|
|
|
171
179
|
| Mode | Supported | Notes |
|
|
@@ -90,17 +90,6 @@ class AbstractDatabricksFileSystem(AbstractFileSystem):
|
|
|
90
90
|
_register_cleanup(self)
|
|
91
91
|
self.log.debug("Opened: %s", self)
|
|
92
92
|
|
|
93
|
-
def __getstate__(self):
|
|
94
|
-
state = self.__dict__.copy()
|
|
95
|
-
del state["_AbstractDatabricksFileSystem__client"]
|
|
96
|
-
del state["_AbstractDatabricksFileSystem__config"]
|
|
97
|
-
return state
|
|
98
|
-
|
|
99
|
-
def __setstate__(self, state):
|
|
100
|
-
self.__dict__.update(state)
|
|
101
|
-
self.__client = None
|
|
102
|
-
self.__config = None
|
|
103
|
-
|
|
104
93
|
def close(self):
|
|
105
94
|
"""Close the file system and release any resources."""
|
|
106
95
|
if not self.closed:
|
|
@@ -197,6 +186,16 @@ class AbstractDatabricksFileSystem(AbstractFileSystem):
|
|
|
197
186
|
def cp_file(self, path1, path2, **kwargs):
|
|
198
187
|
self.log.debug("Copying file: path1=%s, path2=%s", path1, path2)
|
|
199
188
|
|
|
189
|
+
def _cp_directory(self, path2):
|
|
190
|
+
"""Copy a directory: create the destination, as directories have no content."""
|
|
191
|
+
try:
|
|
192
|
+
info = self.info(path2)
|
|
193
|
+
except FileNotFoundError:
|
|
194
|
+
self.mkdirs(path2)
|
|
195
|
+
return
|
|
196
|
+
if info["type"] != "directory":
|
|
197
|
+
raise file_exists_error(path2)
|
|
198
|
+
|
|
200
199
|
def _rm(self, path):
|
|
201
200
|
# Implement only the precondition check here
|
|
202
201
|
# Actual operations must be implemented by subclasses
|
|
@@ -8,7 +8,7 @@ from databricks.sdk.config import Config
|
|
|
8
8
|
from databricks.sdk.service.files import FileInfo
|
|
9
9
|
|
|
10
10
|
from .base import AbstractDatabricksFileSystem
|
|
11
|
-
from .error import error_mapping,
|
|
11
|
+
from .error import error_mapping, is_a_directory_error
|
|
12
12
|
from .path import parse_dbfs_path
|
|
13
13
|
from .utils import to_datetime
|
|
14
14
|
|
|
@@ -80,15 +80,8 @@ class DBFS(AbstractDatabricksFileSystem):
|
|
|
80
80
|
|
|
81
81
|
source_info = self.info(path1)
|
|
82
82
|
if source_info["type"] == "directory":
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if dest_info["type"] == "directory":
|
|
86
|
-
return
|
|
87
|
-
else:
|
|
88
|
-
raise file_exists_error(path2)
|
|
89
|
-
except FileNotFoundError:
|
|
90
|
-
self.mkdirs(path2)
|
|
91
|
-
return
|
|
83
|
+
self._cp_directory(path2)
|
|
84
|
+
return
|
|
92
85
|
|
|
93
86
|
with error_mapping(path1):
|
|
94
87
|
self.client.dbfs.copy(
|
|
@@ -10,7 +10,7 @@ from fsspec.spec import AbstractFileSystem
|
|
|
10
10
|
|
|
11
11
|
from .base import AbstractDatabricksFileSystem, root_dir_info
|
|
12
12
|
from .dbfs import DBFS
|
|
13
|
-
from .error import
|
|
13
|
+
from .error import file_not_found_error
|
|
14
14
|
from .local import DatabricksLocalFileSystem
|
|
15
15
|
from .path import (
|
|
16
16
|
FileSystemType,
|
|
@@ -293,23 +293,6 @@ class DatabricksFileSystem(AbstractDatabricksFileSystem):
|
|
|
293
293
|
self.__local_fs = None
|
|
294
294
|
super().close()
|
|
295
295
|
|
|
296
|
-
def __getstate__(self):
|
|
297
|
-
state = super().__getstate__()
|
|
298
|
-
del state["_DatabricksFileSystem__local_fs"]
|
|
299
|
-
del state["_DatabricksFileSystem__workspace_fs"]
|
|
300
|
-
del state["_DatabricksFileSystem__volume_fs"]
|
|
301
|
-
del state["_DatabricksFileSystem__legacy_fs"]
|
|
302
|
-
del state["_running_in_workspace"]
|
|
303
|
-
return state
|
|
304
|
-
|
|
305
|
-
def __setstate__(self, state):
|
|
306
|
-
super().__setstate__(state)
|
|
307
|
-
self.__workspace_fs = None
|
|
308
|
-
self.__volume_fs = None
|
|
309
|
-
self.__legacy_fs = None
|
|
310
|
-
self.__local_fs = None
|
|
311
|
-
self._running_in_workspace = None
|
|
312
|
-
|
|
313
296
|
@property
|
|
314
297
|
def _volume_fs(self) -> VolumeFileSystem:
|
|
315
298
|
"""Get or create the UnityCatalogVolumeFileSystem instance."""
|
|
@@ -495,15 +478,8 @@ class DatabricksFileSystem(AbstractDatabricksFileSystem):
|
|
|
495
478
|
else:
|
|
496
479
|
source_info = self.info(path1)
|
|
497
480
|
if source_info["type"] == "directory":
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
if dest_info["type"] == "directory":
|
|
501
|
-
return
|
|
502
|
-
else:
|
|
503
|
-
raise file_exists_error(path2)
|
|
504
|
-
except FileNotFoundError:
|
|
505
|
-
self.mkdirs(path2)
|
|
506
|
-
return
|
|
481
|
+
self._cp_directory(path2)
|
|
482
|
+
return
|
|
507
483
|
|
|
508
484
|
src_fs, src_path = self._resolve(path1)
|
|
509
485
|
dst_fs, dst_path = self._resolve(path2)
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import glob
|
|
2
3
|
import logging
|
|
3
4
|
import re
|
|
5
|
+
import shutil
|
|
4
6
|
import urllib.parse
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
9
|
from datetime import UTC, datetime, timedelta
|
|
6
10
|
from io import UnsupportedOperation
|
|
11
|
+
from os import PathLike
|
|
12
|
+
from random import random
|
|
7
13
|
from threading import Event, RLock, Thread
|
|
8
14
|
from typing import Any, Literal
|
|
9
15
|
|
|
10
16
|
from aiohttp import (
|
|
17
|
+
ClientConnectionError,
|
|
11
18
|
ClientHandlerType,
|
|
12
19
|
ClientMiddlewareType,
|
|
13
20
|
ClientRequest,
|
|
@@ -56,61 +63,49 @@ def _map_info(
|
|
|
56
63
|
detail: bool,
|
|
57
64
|
path: str | None = None,
|
|
58
65
|
) -> dict[str, Any] | str:
|
|
59
|
-
"""Map Unity Catalog CatalogInfo, SchemaInfo, VolumeInfo or GetMetadataResponse
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"created": to_datetime(info.created_at),
|
|
67
|
-
"modified": to_datetime(info.updated_at),
|
|
68
|
-
"islink": False,
|
|
69
|
-
}
|
|
70
|
-
else:
|
|
71
|
-
return f"/Volumes/{info.name}"
|
|
72
|
-
elif isinstance(info, SchemaInfo):
|
|
73
|
-
if detail:
|
|
74
|
-
return {
|
|
75
|
-
"name": f"/Volumes/{info.catalog_name}/{info.name}",
|
|
76
|
-
"size": None,
|
|
77
|
-
"type": "directory",
|
|
78
|
-
"created": to_datetime(info.created_at),
|
|
79
|
-
"modified": to_datetime(info.updated_at),
|
|
80
|
-
"islink": False,
|
|
81
|
-
}
|
|
66
|
+
"""Map Unity Catalog CatalogInfo, SchemaInfo, VolumeInfo, DirectoryEntry or GetMetadataResponse
|
|
67
|
+
to fsspec file info dict, or to the name only if not detail."""
|
|
68
|
+
if isinstance(info, CatalogInfo | SchemaInfo | VolumeInfo):
|
|
69
|
+
if isinstance(info, CatalogInfo):
|
|
70
|
+
name = f"/Volumes/{info.name}"
|
|
71
|
+
elif isinstance(info, SchemaInfo):
|
|
72
|
+
name = f"/Volumes/{info.catalog_name}/{info.name}"
|
|
82
73
|
else:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
"islink": False,
|
|
93
|
-
}
|
|
94
|
-
else:
|
|
95
|
-
return f"/Volumes/{info.catalog_name}/{info.schema_name}/{info.name}"
|
|
74
|
+
name = f"/Volumes/{info.catalog_name}/{info.schema_name}/{info.name}"
|
|
75
|
+
mapped = {
|
|
76
|
+
"name": name,
|
|
77
|
+
"size": None,
|
|
78
|
+
"type": "directory",
|
|
79
|
+
"created": to_datetime(info.created_at),
|
|
80
|
+
"modified": to_datetime(info.updated_at),
|
|
81
|
+
"islink": False,
|
|
82
|
+
}
|
|
96
83
|
elif isinstance(info, GetMetadataResponse):
|
|
97
84
|
if path is None:
|
|
98
85
|
raise ValueError("Path must be provided for GetMetadataResponse mapping.")
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
86
|
+
mapped = {
|
|
87
|
+
"name": path.rstrip("/"),
|
|
88
|
+
"size": info.content_length,
|
|
89
|
+
"type": "file",
|
|
90
|
+
"created": None,
|
|
91
|
+
"modified": to_datetime(info.last_modified),
|
|
92
|
+
"islink": False,
|
|
93
|
+
"content-type": info.content_type,
|
|
94
|
+
}
|
|
95
|
+
elif isinstance(info, DirectoryEntry):
|
|
96
|
+
mapped = {
|
|
97
|
+
"name": info.path.rstrip("/"),
|
|
98
|
+
"size": info.file_size,
|
|
99
|
+
"type": "directory" if info.is_directory else "file",
|
|
100
|
+
"created": None,
|
|
101
|
+
"modified": to_datetime(info.last_modified),
|
|
102
|
+
"islink": False,
|
|
103
|
+
}
|
|
111
104
|
else:
|
|
112
105
|
raise ValueError(f"Unsupported info type: {info} {type(info)}")
|
|
113
106
|
|
|
107
|
+
return mapped if detail else mapped["name"]
|
|
108
|
+
|
|
114
109
|
|
|
115
110
|
class VolumeFileSystem(DBFS):
|
|
116
111
|
"""Unity Catalog Volume file system.
|
|
@@ -149,6 +144,9 @@ class VolumeFileSystem(DBFS):
|
|
|
149
144
|
_storage_proxy_probe_timeout: float = 3.0
|
|
150
145
|
"""The timeout in seconds for the storage proxy probe request."""
|
|
151
146
|
|
|
147
|
+
_max_delete_concurrency: int = 16
|
|
148
|
+
"""The maximum number of concurrent delete requests of a recursive `rm`."""
|
|
149
|
+
|
|
152
150
|
log = logging.getLogger(__name__)
|
|
153
151
|
|
|
154
152
|
def __init__(
|
|
@@ -202,21 +200,6 @@ class VolumeFileSystem(DBFS):
|
|
|
202
200
|
self.__lock = RLock()
|
|
203
201
|
self.__session: ClientSession | None = None
|
|
204
202
|
|
|
205
|
-
def __getstate__(self):
|
|
206
|
-
state = super().__getstate__()
|
|
207
|
-
del state["_VolumeFileSystem__loop"]
|
|
208
|
-
del state["_VolumeFileSystem__io_thread"]
|
|
209
|
-
del state["_VolumeFileSystem__lock"]
|
|
210
|
-
del state["_VolumeFileSystem__session"]
|
|
211
|
-
return state
|
|
212
|
-
|
|
213
|
-
def __setstate__(self, state):
|
|
214
|
-
super().__setstate__(state)
|
|
215
|
-
self.__loop = None
|
|
216
|
-
self.__io_thread = None
|
|
217
|
-
self.__lock = RLock()
|
|
218
|
-
self.__session = None
|
|
219
|
-
|
|
220
203
|
@property
|
|
221
204
|
def _loop(self):
|
|
222
205
|
if self.closed:
|
|
@@ -340,12 +323,18 @@ class VolumeFileSystem(DBFS):
|
|
|
340
323
|
f"/DatabricksInternal/Probe/fullstack/wis?ew={workspace_id}"
|
|
341
324
|
)
|
|
342
325
|
auth = _workspace_authenticator(self.config)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
326
|
+
try:
|
|
327
|
+
async with session.head(
|
|
328
|
+
probe_url,
|
|
329
|
+
middlewares=(auth,),
|
|
330
|
+
timeout=ClientTimeout(
|
|
331
|
+
total=self._storage_proxy_probe_timeout
|
|
332
|
+
),
|
|
333
|
+
) as response:
|
|
334
|
+
return response.status == 200
|
|
335
|
+
except (TimeoutError, ClientConnectionError):
|
|
336
|
+
# The proxy can be unresolvable or unreachable from this network.
|
|
337
|
+
return False
|
|
349
338
|
|
|
350
339
|
available = asyncio.run_coroutine_threadsafe(
|
|
351
340
|
probe(self._session, self.workspace_id), self._loop
|
|
@@ -472,7 +461,7 @@ class VolumeFileSystem(DBFS):
|
|
|
472
461
|
return [volumes_root_info] if detail else [volumes_root]
|
|
473
462
|
|
|
474
463
|
try:
|
|
475
|
-
catalog, schema, volume, _,
|
|
464
|
+
catalog, schema, volume, _, full_path = parse_volume_path(path)
|
|
476
465
|
except ValueError:
|
|
477
466
|
raise file_not_found_error(path) from None
|
|
478
467
|
|
|
@@ -497,23 +486,146 @@ class VolumeFileSystem(DBFS):
|
|
|
497
486
|
)
|
|
498
487
|
]
|
|
499
488
|
else:
|
|
500
|
-
|
|
489
|
+
try:
|
|
490
|
+
entries = list(self.client.files.list_directory_contents(full_path))
|
|
491
|
+
except (NotFound, BadRequest):
|
|
492
|
+
# A file lists as itself
|
|
493
|
+
info = self._file_info(full_path)
|
|
494
|
+
return [info if detail else info["name"]]
|
|
495
|
+
return [_map_info(e, detail=detail) for e in entries]
|
|
496
|
+
|
|
497
|
+
### File/directory operations
|
|
498
|
+
def cp_file(self, path1, path2, **kwargs):
|
|
499
|
+
# Only logs: the base class has no precondition for copying
|
|
500
|
+
AbstractDatabricksFileSystem.cp_file(self, path1=path1, path2=path2, **kwargs)
|
|
501
501
|
|
|
502
|
-
|
|
502
|
+
try:
|
|
503
|
+
src = self.open(path1, "rb")
|
|
504
|
+
except IsADirectoryError:
|
|
505
|
+
self._cp_directory(path2)
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
# The Files API has no copy: stream the content with concurrent reads and uploads
|
|
509
|
+
with src, self.open(path2, "wb") as dst:
|
|
510
|
+
shutil.copyfileobj(src, dst, length=self.max_write_block_size)
|
|
511
|
+
|
|
512
|
+
def mv(self, path1, path2, recursive=False, maxdepth=None, **kwargs):
|
|
513
|
+
# fsspec's copy and delete, with the methods of this class
|
|
514
|
+
AbstractDatabricksFileSystem.mv(
|
|
515
|
+
self, path1, path2, recursive=recursive, maxdepth=maxdepth, **kwargs
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def _rm(self, path):
|
|
503
519
|
# The precondition check is implemented in the base class.
|
|
504
|
-
AbstractDatabricksFileSystem.
|
|
520
|
+
AbstractDatabricksFileSystem._rm(self, path=path)
|
|
505
521
|
|
|
506
522
|
_, _, _, _, full_path = parse_volume_path(path)
|
|
523
|
+
self._delete_file(full_path)
|
|
507
524
|
|
|
508
|
-
|
|
525
|
+
def rm(self, path, recursive=False, maxdepth=None):
|
|
526
|
+
self.log.debug(
|
|
527
|
+
"Deleting path: path=%s, recursive=%s, maxdepth=%s",
|
|
528
|
+
path,
|
|
529
|
+
recursive,
|
|
530
|
+
maxdepth,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
paths = [path] if isinstance(path, (str, PathLike)) else path
|
|
534
|
+
for p in paths:
|
|
535
|
+
if glob.has_magic(self._strip_protocol(p)):
|
|
536
|
+
for matched in self.expand_path(p):
|
|
537
|
+
self._rm_path(matched, recursive, maxdepth)
|
|
538
|
+
else:
|
|
539
|
+
self._rm_path(p, recursive, maxdepth)
|
|
540
|
+
|
|
541
|
+
def _rm_path(self, path, recursive: bool, maxdepth: int | None) -> None:
|
|
542
|
+
"""Delete a file, or a directory with its content if recursive."""
|
|
543
|
+
_, _, _, path_in_volume, full_path = parse_volume_path(path)
|
|
544
|
+
if path_in_volume is None or path_in_volume == "/":
|
|
545
|
+
# Volumes, schemas and catalogs. A list, as DBFS.rm calls self.rm for a string.
|
|
546
|
+
DBFS.rm(self, [path], recursive=recursive, maxdepth=maxdepth)
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
if not recursive:
|
|
550
|
+
self._rm(path)
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
if self.info(path)["type"] == "file":
|
|
554
|
+
self._delete_file(full_path)
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
files, directories = self._walk_tree(full_path, maxdepth)
|
|
558
|
+
self._run_concurrently(self._delete_file, files)
|
|
559
|
+
# Children before parents. Directories of the same depth are independent.
|
|
560
|
+
directories.append(full_path)
|
|
561
|
+
for depth in sorted({d.count("/") for d in directories}, reverse=True):
|
|
562
|
+
self._run_concurrently(
|
|
563
|
+
self._delete_directory,
|
|
564
|
+
[d for d in directories if d.count("/") == depth],
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
def _walk_tree(
|
|
568
|
+
self, full_path: str, maxdepth: int | None
|
|
569
|
+
) -> tuple[list[str], list[str]]:
|
|
570
|
+
"""List the files and directories under a directory, listing each depth concurrently."""
|
|
571
|
+
files: list[str] = []
|
|
572
|
+
directories: list[str] = []
|
|
573
|
+
|
|
574
|
+
def list_directory(directory: str) -> list[DirectoryEntry]:
|
|
575
|
+
with error_mapping(directory):
|
|
576
|
+
return list(self.client.files.list_directory_contents(directory))
|
|
577
|
+
|
|
578
|
+
level = [full_path]
|
|
579
|
+
depth = 0
|
|
580
|
+
while level and (maxdepth is None or depth < maxdepth):
|
|
581
|
+
depth += 1
|
|
582
|
+
next_level = []
|
|
583
|
+
with ThreadPoolExecutor(
|
|
584
|
+
max_workers=min(len(level), self._max_delete_concurrency)
|
|
585
|
+
) as executor:
|
|
586
|
+
for entries in executor.map(list_directory, level):
|
|
587
|
+
for e in entries:
|
|
588
|
+
if e.is_directory:
|
|
589
|
+
next_level.append(e.path.rstrip("/"))
|
|
590
|
+
else:
|
|
591
|
+
files.append(e.path)
|
|
592
|
+
directories.extend(next_level)
|
|
593
|
+
level = next_level
|
|
594
|
+
|
|
595
|
+
return files, directories
|
|
596
|
+
|
|
597
|
+
def _run_concurrently(self, func: Callable[[str], None], paths: Iterable[str]):
|
|
598
|
+
"""Call the function on each path in a thread pool, and raise the first error."""
|
|
599
|
+
paths = list(paths)
|
|
600
|
+
if not paths:
|
|
601
|
+
return
|
|
602
|
+
with ThreadPoolExecutor(
|
|
603
|
+
max_workers=min(len(paths), self._max_delete_concurrency)
|
|
604
|
+
) as executor:
|
|
605
|
+
for _ in executor.map(func, paths):
|
|
606
|
+
pass
|
|
607
|
+
|
|
608
|
+
def _delete_file(self, full_path: str) -> None:
|
|
609
|
+
with error_mapping(full_path):
|
|
610
|
+
self.client.files.delete(full_path)
|
|
611
|
+
|
|
612
|
+
def _delete_directory(self, full_path: str) -> None:
|
|
613
|
+
with error_mapping(full_path):
|
|
509
614
|
try:
|
|
510
615
|
self.client.files.delete_directory(full_path)
|
|
511
616
|
except BadRequest as e:
|
|
512
617
|
if "is not empty" in e.args[0]:
|
|
513
|
-
raise not_empty_error(
|
|
618
|
+
raise not_empty_error(full_path) from e
|
|
514
619
|
else:
|
|
515
620
|
raise
|
|
516
621
|
|
|
622
|
+
def rmdir(self, path):
|
|
623
|
+
# The precondition check is implemented in the base class.
|
|
624
|
+
AbstractDatabricksFileSystem.rmdir(self, path=path)
|
|
625
|
+
|
|
626
|
+
_, _, _, _, full_path = parse_volume_path(path)
|
|
627
|
+
self._delete_directory(full_path)
|
|
628
|
+
|
|
517
629
|
def _check_open_target(self, path: str, mode: str) -> dict[str, Any] | None:
|
|
518
630
|
"""Check that the path can be opened in the mode, and return its info if needed."""
|
|
519
631
|
if "r" not in mode and "x" not in mode:
|
|
@@ -669,6 +781,9 @@ def _workspace_authenticator(config: Config) -> ClientMiddlewareType:
|
|
|
669
781
|
return auth
|
|
670
782
|
|
|
671
783
|
|
|
784
|
+
_retryable_statuses = frozenset({429, 500, 502, 503, 504})
|
|
785
|
+
|
|
786
|
+
|
|
672
787
|
class VolumeReadableFile(AbstractAsyncReadableFile):
|
|
673
788
|
log = VolumeFileSystem.log
|
|
674
789
|
|
|
@@ -704,6 +819,12 @@ class VolumeReadableFile(AbstractAsyncReadableFile):
|
|
|
704
819
|
presigned_url_refresh_after: int = 30 # min
|
|
705
820
|
"""The duration in minutes after which the presigned URL should be refreshed."""
|
|
706
821
|
|
|
822
|
+
fetch_max_retries: int = 3
|
|
823
|
+
"""The maximal number of retries of a range request after a transient error."""
|
|
824
|
+
|
|
825
|
+
fetch_retry_delay: float = 1.0
|
|
826
|
+
"""The base delay in seconds before the first retry of a range request. It doubles with each retry."""
|
|
827
|
+
|
|
707
828
|
def __init__(
|
|
708
829
|
self,
|
|
709
830
|
path: str,
|
|
@@ -803,6 +924,26 @@ class VolumeReadableFile(AbstractAsyncReadableFile):
|
|
|
803
924
|
return self._presigned_url, self._presigned_url_headers
|
|
804
925
|
|
|
805
926
|
async def _fetch_range(self, start: int, end: int) -> bytes:
|
|
927
|
+
delay = self.fetch_retry_delay
|
|
928
|
+
for _ in range(self.fetch_max_retries):
|
|
929
|
+
try:
|
|
930
|
+
return await self._request_range(start, end)
|
|
931
|
+
except ClientResponseError as e:
|
|
932
|
+
if e.status not in _retryable_statuses:
|
|
933
|
+
raise
|
|
934
|
+
self.log.warning(
|
|
935
|
+
"Retrying range request after status %d: path=%s, range=%d-%d",
|
|
936
|
+
e.status,
|
|
937
|
+
self.path,
|
|
938
|
+
start,
|
|
939
|
+
end,
|
|
940
|
+
)
|
|
941
|
+
# The jitter keeps concurrent range requests from retrying together.
|
|
942
|
+
await asyncio.sleep(delay * (1 + random()))
|
|
943
|
+
delay *= 2
|
|
944
|
+
return await self._request_range(start, end)
|
|
945
|
+
|
|
946
|
+
async def _request_range(self, start: int, end: int) -> bytes:
|
|
806
947
|
if self.use_presigned_url:
|
|
807
948
|
path, headers = await self._get_presigned_url()
|
|
808
949
|
middlewares = ()
|
|
@@ -109,6 +109,7 @@ def dummy_api_context(dummy_api):
|
|
|
109
109
|
context.files.clear()
|
|
110
110
|
context.multipart_upload_sessions.clear()
|
|
111
111
|
context.resumable_upload_sessions.clear()
|
|
112
|
+
context.get_file_errors.clear()
|
|
112
113
|
|
|
113
114
|
context.check_signed_url = False
|
|
114
115
|
context.upload_mode = "multipart"
|
|
@@ -33,6 +33,9 @@ class DummyAPIContext:
|
|
|
33
33
|
check_signed_url: bool = False
|
|
34
34
|
abort_supported: bool = True
|
|
35
35
|
|
|
36
|
+
get_file_errors: list[int] = field(default_factory=list)
|
|
37
|
+
"""Statuses that the next requests to read a file get, one each."""
|
|
38
|
+
|
|
36
39
|
files: dict[str, bytes] = field(default_factory=dict)
|
|
37
40
|
|
|
38
41
|
multipart_upload_sessions: dict[str, dict[tuple[int, str], bytes | None]] = field(
|
|
@@ -118,6 +121,13 @@ async def get_file(
|
|
|
118
121
|
range: Annotated[str | None, Header()] = None,
|
|
119
122
|
signed: Annotated[str | None, Query()] = None,
|
|
120
123
|
):
|
|
124
|
+
if context.get_file_errors:
|
|
125
|
+
raise http_exception(
|
|
126
|
+
"INTERNAL_ERROR",
|
|
127
|
+
"Injected error",
|
|
128
|
+
context.get_file_errors.pop(0),
|
|
129
|
+
)
|
|
130
|
+
|
|
121
131
|
if context.check_signed_url and signed != "true":
|
|
122
132
|
raise http_exception(
|
|
123
133
|
"BAD_REQUEST",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import socket
|
|
1
2
|
from io import BytesIO
|
|
2
3
|
from random import randbytes
|
|
3
|
-
from unittest.mock import patch
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
4
5
|
|
|
5
6
|
import pandas as pd
|
|
6
7
|
import pyarrow as pa
|
|
@@ -12,7 +13,11 @@ from aiohttp import (
|
|
|
12
13
|
ClientResponse,
|
|
13
14
|
)
|
|
14
15
|
|
|
15
|
-
from fsspec_databricks.volume import
|
|
16
|
+
from fsspec_databricks.volume import (
|
|
17
|
+
VolumeFileSystem,
|
|
18
|
+
VolumeReadableFile,
|
|
19
|
+
VolumeWritableFile,
|
|
20
|
+
)
|
|
16
21
|
|
|
17
22
|
from .utils import bytes_sig
|
|
18
23
|
|
|
@@ -23,6 +28,39 @@ async def _dummy_auth(
|
|
|
23
28
|
return await handler(request)
|
|
24
29
|
|
|
25
30
|
|
|
31
|
+
def test_volume_fs_storage_proxy_probe_timeout():
|
|
32
|
+
client = MagicMock()
|
|
33
|
+
client.config.authenticate.return_value = {}
|
|
34
|
+
client.get_workspace_id.return_value = 1234
|
|
35
|
+
|
|
36
|
+
# Connections are accepted by the backlog, but no response ever comes.
|
|
37
|
+
with socket.create_server(("127.0.0.1", 0)) as server:
|
|
38
|
+
port = server.getsockname()[1]
|
|
39
|
+
|
|
40
|
+
with VolumeFileSystem(client=client) as fs:
|
|
41
|
+
fs._storage_proxy_hostname = f"http://127.0.0.1:{port}"
|
|
42
|
+
fs._storage_proxy_probe_timeout = 0.1
|
|
43
|
+
|
|
44
|
+
assert fs.storage_proxy_available is False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_volume_fs_storage_proxy_probe_connection_refused():
|
|
48
|
+
client = MagicMock()
|
|
49
|
+
client.config.authenticate.return_value = {}
|
|
50
|
+
client.get_workspace_id.return_value = 1234
|
|
51
|
+
|
|
52
|
+
# Nothing listens on the port once the server is closed.
|
|
53
|
+
with socket.create_server(("127.0.0.1", 0)) as server:
|
|
54
|
+
port = server.getsockname()[1]
|
|
55
|
+
|
|
56
|
+
with VolumeFileSystem(client=client) as fs:
|
|
57
|
+
fs._storage_proxy_hostname = f"http://127.0.0.1:{port}"
|
|
58
|
+
# Long enough for a refused connection on Windows, which retries for seconds.
|
|
59
|
+
fs._storage_proxy_probe_timeout = 30.0
|
|
60
|
+
|
|
61
|
+
assert fs.storage_proxy_available is False
|
|
62
|
+
|
|
63
|
+
|
|
26
64
|
def test_volume_file_read(
|
|
27
65
|
dummy_api, dummy_api_context, client_event_loop, aiohttp_session
|
|
28
66
|
):
|
|
@@ -58,6 +96,83 @@ def test_volume_file_read(
|
|
|
58
96
|
_ = f.read()
|
|
59
97
|
|
|
60
98
|
|
|
99
|
+
@pytest.mark.parametrize("status", [429, 500, 502, 503, 504])
|
|
100
|
+
def test_volume_file_read_retries(
|
|
101
|
+
dummy_api_context, dummy_api, client_event_loop, aiohttp_session, status
|
|
102
|
+
):
|
|
103
|
+
data = randbytes(1000)
|
|
104
|
+
path = "/Volumes/catalog_a/schema/b/volume_c/path/to/file"
|
|
105
|
+
|
|
106
|
+
dummy_api_context.files[path] = data
|
|
107
|
+
dummy_api_context.get_file_errors.extend([status, status])
|
|
108
|
+
|
|
109
|
+
with patch.object(VolumeReadableFile, "fetch_retry_delay", 0):
|
|
110
|
+
with VolumeReadableFile(
|
|
111
|
+
path=path,
|
|
112
|
+
session=aiohttp_session,
|
|
113
|
+
base_url=dummy_api,
|
|
114
|
+
auth=_dummy_auth,
|
|
115
|
+
loop=client_event_loop,
|
|
116
|
+
size=len(data),
|
|
117
|
+
) as f:
|
|
118
|
+
assert f.read() == data
|
|
119
|
+
|
|
120
|
+
assert not dummy_api_context.get_file_errors
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_volume_file_read_retries_exhausted(
|
|
124
|
+
dummy_api_context, dummy_api, client_event_loop, aiohttp_session
|
|
125
|
+
):
|
|
126
|
+
data = randbytes(1000)
|
|
127
|
+
path = "/Volumes/catalog_a/schema/b/volume_c/path/to/file"
|
|
128
|
+
|
|
129
|
+
dummy_api_context.files[path] = data
|
|
130
|
+
dummy_api_context.get_file_errors.extend([500] * 10)
|
|
131
|
+
|
|
132
|
+
with patch.object(VolumeReadableFile, "fetch_retry_delay", 0):
|
|
133
|
+
with VolumeReadableFile(
|
|
134
|
+
path=path,
|
|
135
|
+
session=aiohttp_session,
|
|
136
|
+
base_url=dummy_api,
|
|
137
|
+
auth=_dummy_auth,
|
|
138
|
+
loop=client_event_loop,
|
|
139
|
+
size=len(data),
|
|
140
|
+
max_concurrency=1,
|
|
141
|
+
) as f:
|
|
142
|
+
with pytest.raises(OSError):
|
|
143
|
+
f.read()
|
|
144
|
+
|
|
145
|
+
# The first request and its retries.
|
|
146
|
+
attempts = 10 - len(dummy_api_context.get_file_errors)
|
|
147
|
+
assert attempts == VolumeReadableFile.fetch_max_retries + 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@pytest.mark.parametrize("status", [400, 403, 404])
|
|
151
|
+
def test_volume_file_read_no_retry(
|
|
152
|
+
dummy_api_context, dummy_api, client_event_loop, aiohttp_session, status
|
|
153
|
+
):
|
|
154
|
+
data = randbytes(1000)
|
|
155
|
+
path = "/Volumes/catalog_a/schema/b/volume_c/path/to/file"
|
|
156
|
+
|
|
157
|
+
dummy_api_context.files[path] = data
|
|
158
|
+
dummy_api_context.get_file_errors.extend([status] * 2)
|
|
159
|
+
|
|
160
|
+
with patch.object(VolumeReadableFile, "fetch_retry_delay", 0):
|
|
161
|
+
with VolumeReadableFile(
|
|
162
|
+
path=path,
|
|
163
|
+
session=aiohttp_session,
|
|
164
|
+
base_url=dummy_api,
|
|
165
|
+
auth=_dummy_auth,
|
|
166
|
+
loop=client_event_loop,
|
|
167
|
+
size=len(data),
|
|
168
|
+
max_concurrency=1,
|
|
169
|
+
) as f:
|
|
170
|
+
with pytest.raises(OSError):
|
|
171
|
+
f.read()
|
|
172
|
+
|
|
173
|
+
assert len(dummy_api_context.get_file_errors) == 1
|
|
174
|
+
|
|
175
|
+
|
|
61
176
|
def test_volume_file_read_parquet(
|
|
62
177
|
client_event_loop,
|
|
63
178
|
dummy_api,
|
|
@@ -11,8 +11,8 @@ from unittest.mock import MagicMock, patch
|
|
|
11
11
|
|
|
12
12
|
import pytest
|
|
13
13
|
from databricks.sdk import WorkspaceClient
|
|
14
|
-
from databricks.sdk.errors import NotFound, ResourceDoesNotExist
|
|
15
|
-
from databricks.sdk.service.files import GetMetadataResponse
|
|
14
|
+
from databricks.sdk.errors import BadRequest, NotFound, ResourceDoesNotExist
|
|
15
|
+
from databricks.sdk.service.files import DirectoryEntry, GetMetadataResponse
|
|
16
16
|
|
|
17
17
|
from fsspec_databricks import DatabricksFileSystem
|
|
18
18
|
from fsspec_databricks.path import parse_volume_path
|
|
@@ -152,6 +152,24 @@ def test_volume_fs_serialization():
|
|
|
152
152
|
assert fs._loop is not fs2._loop
|
|
153
153
|
|
|
154
154
|
|
|
155
|
+
def test_volume_fs_pickled_as_arguments():
|
|
156
|
+
fs = VolumeFileSystem(
|
|
157
|
+
config_params={"host": "https://example.cloud.databricks.com", "token": "x"},
|
|
158
|
+
max_read_concurrency=3,
|
|
159
|
+
)
|
|
160
|
+
loop = fs._loop
|
|
161
|
+
|
|
162
|
+
fs2 = pickle.loads(pickle.dumps(fs))
|
|
163
|
+
try:
|
|
164
|
+
# fsspec rebuilds the file system from its constructor arguments
|
|
165
|
+
assert fs2.storage_options == fs.storage_options
|
|
166
|
+
assert fs2.max_read_concurrency == 3
|
|
167
|
+
assert fs2._loop is not loop
|
|
168
|
+
finally:
|
|
169
|
+
fs.close()
|
|
170
|
+
fs2.close()
|
|
171
|
+
|
|
172
|
+
|
|
155
173
|
def test_volume_fs_session():
|
|
156
174
|
with VolumeFileSystem() as fs:
|
|
157
175
|
# Created from a thread without a running event loop
|
|
@@ -184,9 +202,9 @@ def test_volume_fs_session_in_event_loop():
|
|
|
184
202
|
|
|
185
203
|
|
|
186
204
|
class _CountingFilesAPI:
|
|
187
|
-
"""A fake of `WorkspaceClient.files` for metadata, recording the calls."""
|
|
205
|
+
"""A fake of `WorkspaceClient.files` for metadata, listing and deletion, recording the calls."""
|
|
188
206
|
|
|
189
|
-
def __init__(self, files:
|
|
207
|
+
def __init__(self, files: dict[str, bytes], directories: list[str]):
|
|
190
208
|
self.files = files
|
|
191
209
|
self.directories = directories
|
|
192
210
|
self.calls: list[tuple[str, str]] = []
|
|
@@ -196,7 +214,7 @@ class _CountingFilesAPI:
|
|
|
196
214
|
if path not in self.files:
|
|
197
215
|
raise NotFound(f"Not found: {path}")
|
|
198
216
|
return GetMetadataResponse(
|
|
199
|
-
content_length=
|
|
217
|
+
content_length=len(self.files[path]),
|
|
200
218
|
content_type="application/octet-stream",
|
|
201
219
|
last_modified="Wed, 21 Oct 2026 07:28:00 GMT",
|
|
202
220
|
)
|
|
@@ -206,6 +224,37 @@ class _CountingFilesAPI:
|
|
|
206
224
|
if path not in self.directories:
|
|
207
225
|
raise NotFound(f"Not found: {path}")
|
|
208
226
|
|
|
227
|
+
def _children(self, path):
|
|
228
|
+
return [
|
|
229
|
+
p for p in [*self.files, *self.directories] if p.rsplit("/", 1)[0] == path
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
def list_directory_contents(self, path):
|
|
233
|
+
self.calls.append(("list", path))
|
|
234
|
+
if path not in self.directories:
|
|
235
|
+
raise NotFound(f"Not found: {path}")
|
|
236
|
+
for p in self._children(path):
|
|
237
|
+
yield DirectoryEntry(
|
|
238
|
+
path=p,
|
|
239
|
+
name=p.rsplit("/", 1)[1],
|
|
240
|
+
is_directory=p in self.directories,
|
|
241
|
+
file_size=len(self.files[p]) if p in self.files else None,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def delete(self, path):
|
|
245
|
+
self.calls.append(("delete", path))
|
|
246
|
+
if path not in self.files:
|
|
247
|
+
raise NotFound(f"Not found: {path}")
|
|
248
|
+
del self.files[path]
|
|
249
|
+
|
|
250
|
+
def delete_directory(self, path):
|
|
251
|
+
self.calls.append(("delete_directory", path))
|
|
252
|
+
if path not in self.directories:
|
|
253
|
+
raise NotFound(f"Not found: {path}")
|
|
254
|
+
if self._children(path):
|
|
255
|
+
raise BadRequest(f"Directory {path} is not empty")
|
|
256
|
+
self.directories.remove(path)
|
|
257
|
+
|
|
209
258
|
|
|
210
259
|
def test_volume_fs_metadata_calls():
|
|
211
260
|
"""Each check of a file or a directory makes as few API calls as possible."""
|
|
@@ -213,7 +262,7 @@ def test_volume_fs_metadata_calls():
|
|
|
213
262
|
directory = "/Volumes/c/s/v/dir"
|
|
214
263
|
missing = "/Volumes/c/s/v/dir/missing"
|
|
215
264
|
|
|
216
|
-
files_api = _CountingFilesAPI(files=
|
|
265
|
+
files_api = _CountingFilesAPI(files={file: b"data"}, directories=[directory])
|
|
217
266
|
client = MagicMock()
|
|
218
267
|
client.files = files_api
|
|
219
268
|
|
|
@@ -254,6 +303,134 @@ def test_volume_fs_metadata_calls():
|
|
|
254
303
|
assert calls_of(lambda: fs.isfile("/Volumes/c/s/v")) == []
|
|
255
304
|
assert not fs.isfile("/Volumes/c/s/v")
|
|
256
305
|
|
|
306
|
+
# Listing a directory: one call. A file lists as itself.
|
|
307
|
+
assert calls_of(lambda: fs.ls(directory)) == [("list", directory)]
|
|
308
|
+
assert fs.ls(directory, detail=False) == [file]
|
|
309
|
+
assert fs.ls(directory)[0]["size"] == len(b"data")
|
|
310
|
+
assert calls_of(lambda: fs.ls(file)) == [("list", file), ("file", file)]
|
|
311
|
+
assert fs.ls(file, detail=False) == [file]
|
|
312
|
+
with pytest.raises(FileNotFoundError):
|
|
313
|
+
fs.ls(missing)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _volume_fs_with_fake_files(
|
|
317
|
+
files: dict[str, bytes], directories: list[str]
|
|
318
|
+
) -> tuple[VolumeFileSystem, _CountingFilesAPI, MagicMock]:
|
|
319
|
+
"""A Volume file system on `_CountingFilesAPI`, whose files are opened as `BytesIO`."""
|
|
320
|
+
files_api = _CountingFilesAPI(files=files, directories=directories)
|
|
321
|
+
client = MagicMock()
|
|
322
|
+
client.files = files_api
|
|
323
|
+
|
|
324
|
+
class _Written(BytesIO):
|
|
325
|
+
def __init__(self, path):
|
|
326
|
+
super().__init__()
|
|
327
|
+
self.path = path
|
|
328
|
+
|
|
329
|
+
def close(self):
|
|
330
|
+
files_api.files[self.path] = self.getvalue()
|
|
331
|
+
super().close()
|
|
332
|
+
|
|
333
|
+
def fake_open(fs, path, mode="rb", **kwargs):
|
|
334
|
+
fs._check_open_target(path, mode)
|
|
335
|
+
return BytesIO(files_api.files[path]) if "r" in mode else _Written(path)
|
|
336
|
+
|
|
337
|
+
patcher = patch.object(VolumeFileSystem, "_open", fake_open)
|
|
338
|
+
patcher.start()
|
|
339
|
+
fs = VolumeFileSystem(client=client)
|
|
340
|
+
fs._fake_patcher = patcher
|
|
341
|
+
return fs, files_api, client
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@pytest.fixture
|
|
345
|
+
def fake_volume_fs():
|
|
346
|
+
created = []
|
|
347
|
+
|
|
348
|
+
def create(files: dict[str, bytes], directories: list[str]):
|
|
349
|
+
fs, files_api, client = _volume_fs_with_fake_files(files, directories)
|
|
350
|
+
created.append(fs)
|
|
351
|
+
return fs, files_api, client
|
|
352
|
+
|
|
353
|
+
yield create
|
|
354
|
+
|
|
355
|
+
for fs in created:
|
|
356
|
+
fs._fake_patcher.stop()
|
|
357
|
+
fs.close()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_volume_fs_cp_file_streams(fake_volume_fs):
|
|
361
|
+
"""Copying a file streams it with the file objects, not the SDK's `dbfs.copy`."""
|
|
362
|
+
src = "/Volumes/c/s/v/dir/src.bin"
|
|
363
|
+
dst = "/Volumes/c/s/v/dir/dst.bin"
|
|
364
|
+
data = randbytes(12345)
|
|
365
|
+
|
|
366
|
+
fs, files_api, client = fake_volume_fs({src: data}, ["/Volumes/c/s/v/dir"])
|
|
367
|
+
fs.cp_file(src, dst)
|
|
368
|
+
|
|
369
|
+
assert files_api.files[dst] == data
|
|
370
|
+
# One metadata call each: the source is a file, the destination is no directory
|
|
371
|
+
assert files_api.calls == [("file", src), ("directory", dst)]
|
|
372
|
+
assert client.dbfs.mock_calls == []
|
|
373
|
+
|
|
374
|
+
with pytest.raises(FileNotFoundError):
|
|
375
|
+
fs.cp_file("/Volumes/c/s/v/dir/missing", dst)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_volume_fs_mv_file(fake_volume_fs):
|
|
379
|
+
src = "/Volumes/c/s/v/dir/src.bin"
|
|
380
|
+
dst = "/Volumes/c/s/v/dir/dst.bin"
|
|
381
|
+
data = randbytes(12345)
|
|
382
|
+
|
|
383
|
+
fs, files_api, client = fake_volume_fs({src: data}, ["/Volumes/c/s/v/dir"])
|
|
384
|
+
fs.mv(src, dst)
|
|
385
|
+
|
|
386
|
+
assert files_api.files == {dst: data}
|
|
387
|
+
assert client.dbfs.mock_calls == []
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_volume_fs_rm_calls(fake_volume_fs):
|
|
391
|
+
"""Deleting calls the Files API directly, children before their parents."""
|
|
392
|
+
root = "/Volumes/c/s/v/dir"
|
|
393
|
+
files = {
|
|
394
|
+
f"{root}/a.bin": b"a",
|
|
395
|
+
f"{root}/sub/b.bin": b"b",
|
|
396
|
+
f"{root}/sub/c.bin": b"c",
|
|
397
|
+
}
|
|
398
|
+
directories = [root, f"{root}/sub", f"{root}/sub/empty", f"{root}/other"]
|
|
399
|
+
|
|
400
|
+
fs, files_api, client = fake_volume_fs(dict(files), list(directories))
|
|
401
|
+
|
|
402
|
+
# A file: one metadata call and the delete
|
|
403
|
+
fs.rm(f"{root}/a.bin")
|
|
404
|
+
assert files_api.calls == [("file", f"{root}/a.bin"), ("delete", f"{root}/a.bin")]
|
|
405
|
+
|
|
406
|
+
with pytest.raises(IsADirectoryError):
|
|
407
|
+
fs.rm(f"{root}/sub")
|
|
408
|
+
|
|
409
|
+
# A directory: one listing per directory, then the deletes
|
|
410
|
+
files_api.calls.clear()
|
|
411
|
+
fs.rm(root, recursive=True)
|
|
412
|
+
|
|
413
|
+
assert files_api.files == {}
|
|
414
|
+
assert files_api.directories == []
|
|
415
|
+
assert client.dbfs.mock_calls == []
|
|
416
|
+
|
|
417
|
+
kinds = [kind for kind, _ in files_api.calls]
|
|
418
|
+
assert kinds.count("list") == 4
|
|
419
|
+
assert sorted(p for kind, p in files_api.calls if kind == "delete") == [
|
|
420
|
+
f"{root}/sub/b.bin",
|
|
421
|
+
f"{root}/sub/c.bin",
|
|
422
|
+
]
|
|
423
|
+
deleted_directories = [
|
|
424
|
+
p for kind, p in files_api.calls if kind == "delete_directory"
|
|
425
|
+
]
|
|
426
|
+
assert deleted_directories.index(f"{root}/sub/empty") < deleted_directories.index(
|
|
427
|
+
f"{root}/sub"
|
|
428
|
+
)
|
|
429
|
+
assert deleted_directories[-1] == root
|
|
430
|
+
|
|
431
|
+
with pytest.raises(FileNotFoundError):
|
|
432
|
+
fs.rm(root, recursive=True)
|
|
433
|
+
|
|
257
434
|
|
|
258
435
|
def test_volume_fs_workspace_id(client: WorkspaceClient):
|
|
259
436
|
with VolumeFileSystem(client=client) as fs:
|
|
@@ -418,6 +595,9 @@ def test_volume_fs_ls(
|
|
|
418
595
|
stripped_dbfs_url(f"{test_dir}/2_directory"),
|
|
419
596
|
]
|
|
420
597
|
|
|
598
|
+
# A file lists as itself
|
|
599
|
+
assert fs.ls(dbfs_url(data_path), detail=False) == [stripped_dbfs_url(data_path)]
|
|
600
|
+
|
|
421
601
|
with pytest.raises(FileNotFoundError):
|
|
422
602
|
fs.ls(dbfs_url(f"{test_dir}/nonexistent"))
|
|
423
603
|
|
|
@@ -545,6 +725,17 @@ def test_volume_fs_cp_file(
|
|
|
545
725
|
|
|
546
726
|
assert data == copied_data
|
|
547
727
|
|
|
728
|
+
# Test copying a file larger than one block, with a multipart upload
|
|
729
|
+
large_data = randbytes(12 * 1024 * 1024)
|
|
730
|
+
client.files.upload(f"{test_dir}/3_large.bin", BytesIO(large_data))
|
|
731
|
+
fs.cp_file(
|
|
732
|
+
dbfs_url(f"{test_dir}/3_large.bin"),
|
|
733
|
+
dbfs_url(f"{test_dir}/3_large_copied.bin"),
|
|
734
|
+
)
|
|
735
|
+
assert bytes_sig(download(client, f"{test_dir}/3_large_copied.bin")) == bytes_sig(
|
|
736
|
+
large_data
|
|
737
|
+
)
|
|
738
|
+
|
|
548
739
|
# Test copying a directory
|
|
549
740
|
fs.cp_file(
|
|
550
741
|
dbfs_url(f"{test_dir}/2_directory"),
|
|
@@ -661,7 +852,12 @@ def test_volume_fs_rm(
|
|
|
661
852
|
"1_data.bin": data,
|
|
662
853
|
"2_directory": {
|
|
663
854
|
"3_data.bin": data,
|
|
855
|
+
"4_directory": {
|
|
856
|
+
"5_data.bin": data,
|
|
857
|
+
"6_empty_directory": {},
|
|
858
|
+
},
|
|
664
859
|
},
|
|
860
|
+
"7_data.bin": data,
|
|
665
861
|
},
|
|
666
862
|
)
|
|
667
863
|
|
|
@@ -676,10 +872,59 @@ def test_volume_fs_rm(
|
|
|
676
872
|
with pytest.raises(ResourceDoesNotExist):
|
|
677
873
|
client.workspace.get_status(f"{test_dir}/2_directory")
|
|
678
874
|
|
|
875
|
+
# A file, recursively
|
|
876
|
+
fs.rm(dbfs_url(f"{test_dir}/7_data.bin"), recursive=True)
|
|
877
|
+
with pytest.raises(NotFound):
|
|
878
|
+
client.files.get_metadata(f"{test_dir}/7_data.bin")
|
|
879
|
+
|
|
679
880
|
with pytest.raises(FileNotFoundError):
|
|
680
881
|
fs.rm(dbfs_url(f"{test_dir}/nonexistent"))
|
|
681
882
|
|
|
682
883
|
|
|
884
|
+
@pytest.mark.parametrize(
|
|
885
|
+
"fs_class",
|
|
886
|
+
[
|
|
887
|
+
VolumeFileSystem,
|
|
888
|
+
DatabricksFileSystem,
|
|
889
|
+
],
|
|
890
|
+
)
|
|
891
|
+
def test_volume_fs_mv(
|
|
892
|
+
client: WorkspaceClient,
|
|
893
|
+
fs_class: type[VolumeFileSystem | DatabricksFileSystem],
|
|
894
|
+
volume_test_root: str,
|
|
895
|
+
):
|
|
896
|
+
fs = fs_class(client=client, verbose_debug_log=True)
|
|
897
|
+
|
|
898
|
+
data1 = randbytes(12345)
|
|
899
|
+
data2 = randbytes(12345)
|
|
900
|
+
|
|
901
|
+
test_dir = init_test_dir(
|
|
902
|
+
client,
|
|
903
|
+
volume_test_root,
|
|
904
|
+
fs_class,
|
|
905
|
+
items={
|
|
906
|
+
"1_data.bin": data1,
|
|
907
|
+
"2_directory": {
|
|
908
|
+
"3_data.bin": data2,
|
|
909
|
+
},
|
|
910
|
+
},
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
fs.mv(dbfs_url(f"{test_dir}/1_data.bin"), dbfs_url(f"{test_dir}/1_moved.bin"))
|
|
914
|
+
assert download(client, f"{test_dir}/1_moved.bin") == data1
|
|
915
|
+
with pytest.raises(NotFound):
|
|
916
|
+
client.files.get_metadata(f"{test_dir}/1_data.bin")
|
|
917
|
+
|
|
918
|
+
fs.mv(
|
|
919
|
+
dbfs_url(f"{test_dir}/2_directory"),
|
|
920
|
+
dbfs_url(f"{test_dir}/2_moved"),
|
|
921
|
+
recursive=True,
|
|
922
|
+
)
|
|
923
|
+
assert download(client, f"{test_dir}/2_moved/3_data.bin") == data2
|
|
924
|
+
with pytest.raises(NotFound):
|
|
925
|
+
client.files.get_directory_metadata(f"{test_dir}/2_directory")
|
|
926
|
+
|
|
927
|
+
|
|
683
928
|
@pytest.mark.parametrize(
|
|
684
929
|
"fs_class",
|
|
685
930
|
[
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|