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.
Files changed (33) hide show
  1. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/DEVELOPMENT.md +37 -12
  2. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/PKG-INFO +1 -1
  3. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/USAGE.md +8 -0
  4. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/base.py +10 -11
  5. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/dbfs.py +3 -10
  6. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/spec.py +3 -27
  7. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/volume.py +216 -75
  8. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/conftest.py +1 -0
  9. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/dummy_api.py +10 -0
  10. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_volume.py +117 -2
  11. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_volume_fs.py +251 -6
  12. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/.gitignore +0 -0
  13. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/AUTHENTICATION.md +0 -0
  14. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/LICENSE +0 -0
  15. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/README.md +0 -0
  16. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/SECURITY.md +0 -0
  17. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/pyproject.toml +0 -0
  18. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/__init__.py +0 -0
  19. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/error.py +0 -0
  20. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/file.py +0 -0
  21. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/local.py +0 -0
  22. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/path.py +0 -0
  23. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/py.typed +0 -0
  24. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/utils.py +0 -0
  25. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/src/fsspec_databricks/workspace.py +0 -0
  26. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/__init__.py +0 -0
  27. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_base_fs.py +0 -0
  28. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_file.py +0 -0
  29. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_path.py +0 -0
  30. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_spec_fs.py +0 -0
  31. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_utils.py +0 -0
  32. {fsspec_databricks-0.2.1 → fsspec_databricks-0.3.0}/tests/test_workspace_fs.py +0 -0
  33. {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. Clients, event loops, threads and sessions are left
220
- out, and made again on first use.
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
- A `WorkspaceClient` given as `client` is kept, and is not picklable. Other ways of authenticating are.
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`, as the DBFS API also lists Volume folders.
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` | `dbfs.list` |
322
- | Deeper | `files.get_metadata`, else `files.get_directory_metadata` | `dbfs.list` |
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.2.1
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, file_exists_error, is_a_directory_error
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
- try:
84
- dest_info = self.info(path2)
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 file_exists_error, file_not_found_error
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
- try:
499
- dest_info = self.info(path2)
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 to fsspec file info dict."""
60
- if isinstance(info, CatalogInfo):
61
- if detail:
62
- return {
63
- "name": f"/Volumes/{info.name}",
64
- "size": None,
65
- "type": "directory",
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
- return f"/Volumes/{info.catalog_name}/{info.name}"
84
- elif isinstance(info, VolumeInfo):
85
- if detail:
86
- return {
87
- "name": f"/Volumes/{info.catalog_name}/{info.schema_name}/{info.name}",
88
- "size": None,
89
- "type": "directory",
90
- "created": to_datetime(info.created_at),
91
- "modified": to_datetime(info.updated_at),
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
- if detail:
100
- return {
101
- "name": path.rstrip("/"),
102
- "size": info.content_length,
103
- "type": "file",
104
- "created": None,
105
- "modified": to_datetime(info.last_modified),
106
- "islink": False,
107
- "content-type": info.content_type,
108
- }
109
- else:
110
- return path.rstrip("/")
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
- async with session.head(
344
- probe_url,
345
- middlewares=(auth,),
346
- timeout=ClientTimeout(total=self._storage_proxy_probe_timeout),
347
- ) as response:
348
- return response.status == 200
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, _, _ = parse_volume_path(path)
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
- return super().ls(path, detail=detail)
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
- def rmdir(self, path):
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.rmdir(self, path=path)
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
- with error_mapping(path):
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(path) from e
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 VolumeReadableFile, VolumeWritableFile
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: list[str], directories: list[str]):
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=123,
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=[file], directories=[directory])
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
  [