fsspec-databricks 0.2.0__tar.gz → 0.2.1__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 (41) hide show
  1. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/.gitignore +0 -3
  2. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/DEVELOPMENT.md +69 -11
  3. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/PKG-INFO +3 -2
  4. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/README.md +2 -1
  5. fsspec_databricks-0.2.1/SECURITY.md +30 -0
  6. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/pyproject.toml +9 -0
  7. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/base.py +14 -11
  8. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/spec.py +20 -0
  9. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/volume.py +78 -25
  10. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/test_volume_fs.py +74 -1
  11. fsspec_databricks-0.2.0/.github/dependabot.yml +0 -11
  12. fsspec_databricks-0.2.0/.github/workflows/check.yaml +0 -33
  13. fsspec_databricks-0.2.0/.github/workflows/on_push.yaml +0 -85
  14. fsspec_databricks-0.2.0/.github/workflows/on_release.yaml +0 -21
  15. fsspec_databricks-0.2.0/.github/workflows/publish.yaml +0 -43
  16. fsspec_databricks-0.2.0/.github/workflows/test.yaml +0 -139
  17. fsspec_databricks-0.2.0/.pre-commit-config.yaml +0 -20
  18. fsspec_databricks-0.2.0/CLAUDE.md +0 -108
  19. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/AUTHENTICATION.md +0 -0
  20. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/LICENSE +0 -0
  21. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/USAGE.md +0 -0
  22. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/__init__.py +0 -0
  23. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/dbfs.py +0 -0
  24. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/error.py +0 -0
  25. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/file.py +0 -0
  26. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/local.py +0 -0
  27. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/path.py +0 -0
  28. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/py.typed +0 -0
  29. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/utils.py +0 -0
  30. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/src/fsspec_databricks/workspace.py +0 -0
  31. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/__init__.py +0 -0
  32. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/conftest.py +0 -0
  33. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/dummy_api.py +0 -0
  34. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/test_base_fs.py +0 -0
  35. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/test_file.py +0 -0
  36. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/test_path.py +0 -0
  37. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/test_spec_fs.py +0 -0
  38. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/test_utils.py +0 -0
  39. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/test_volume.py +0 -0
  40. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/test_workspace_fs.py +0 -0
  41. {fsspec_databricks-0.2.0 → fsspec_databricks-0.2.1}/tests/utils.py +0 -0
@@ -82,9 +82,6 @@ ENV/
82
82
  # vi*
83
83
  *.swp
84
84
 
85
- # UV
86
- uv.lock
87
-
88
85
  build/
89
86
  downstream/*
90
87
 
@@ -105,21 +105,60 @@ The workflows are in `.github/workflows/`:
105
105
 
106
106
  Each run's work directories are `<root>/<branch>/<Python version>` under the environment's roots.
107
107
 
108
+ ### Supply chain
109
+
110
+ The test jobs hold the workspaces' credentials, so what they run is fixed:
111
+
112
+ - `uv.lock` is committed, with every package's hash. CI installs with `uv sync --locked`, which fails if the lock is
113
+ out of date.
114
+ - Actions are pinned to commit SHAs. setup-uv installs only uv versions whose checksums it knows (`latest-known`).
115
+ - Dependabot updates `uv.lock` and the Actions' SHAs weekly.
116
+ - Workflows run only on pushes and releases, never on pull requests from forks.
117
+ - Each workflow's token can only read the repository. Publishing adds `id-token: write`, for PyPI's trusted
118
+ publishing.
119
+ - The release build uses no cache.
120
+
121
+ ruff, run with `uvx`, is the exception: CI runs its latest version, in a job without secrets.
122
+
108
123
  ### Environments
109
124
 
110
125
  Each GitHub environment is a Databricks workspace. Create at least `default`, and `azure`, `aws` or `gcp` as needed.
111
126
  The tests authenticate as a service principal with read-write access to the work directories.
112
127
 
113
- | Secret | Value |
114
- |----------------------------|-----------------------------------------------|
115
- | `DATABRICKS_HOST` | The workspace's URL |
116
- | `DATABRICKS_CLIENT_ID` | The service principal's client ID |
117
- | `DATABRICKS_CLIENT_SECRET` | The service principal's client secret |
128
+ Every environment has these:
129
+
130
+ | Name | Value |
131
+ |----------------------------------------------------|------------------------------------------------|
132
+ | `DATABRICKS_HOST` (secret) | The workspace's URL |
133
+ | `FSSPEC_DATABRICKS_VOLUME_TEST_ROOT` (variable) | The POSIX path of the Volume work directory |
134
+ | `FSSPEC_DATABRICKS_WORKSPACE_TEST_ROOT` (variable) | The POSIX path of the Workspace work directory |
135
+
136
+ The variable `DATABRICKS_AUTH_TYPE` chooses how the tests authenticate:
137
+
138
+ | `DATABRICKS_AUTH_TYPE` | Authentication | Also needs |
139
+ |------------------------|-------------------------------------------------------|------------------------------------------------------------------|
140
+ | Not set | The service principal's client secret | `DATABRICKS_CLIENT_ID` and `DATABRICKS_CLIENT_SECRET` (secrets) |
141
+ | `github-oidc` | Databricks federation policy | `DATABRICKS_CLIENT_ID` (secret), `DATABRICKS_TOKEN_AUDIENCE` |
142
+ | `github-oidc-azure` | Federated credential of a Microsoft Entra ID app | `ARM_CLIENT_ID`, the app's ID |
143
+
144
+ Prefer federation: it needs no stored secret. GitHub's OIDC token identifies each environment by the subject
145
+ `repo:sugibuchi/fsspec-databricks:environment:<name>`. The federation's trust must name that subject and the issuer
146
+ `https://token.actions.githubusercontent.com`.
118
147
 
119
- | Variable | Value |
120
- |-----------------------------------------|-----------------------------------------------|
121
- | `FSSPEC_DATABRICKS_VOLUME_TEST_ROOT` | The POSIX path of the Volume work directory |
122
- | `FSSPEC_DATABRICKS_WORKSPACE_TEST_ROOT` | The POSIX path of the Workspace work directory |
148
+ - **`github-oidc`**: a federation policy on the service principal, in the Databricks account console. Its audience is
149
+ `DATABRICKS_TOKEN_AUDIENCE`, e.g. the account ID.
150
+ - **`github-oidc-azure`**: a federated credential on the Entra ID app, with the audience `api://AzureADTokenExchange`.
151
+ The app is the workspace's service principal. This needs no access to the account console.
152
+
153
+ With federation, the client secret is not passed to the tests, even if the environment still has one.
154
+
155
+ The environments are:
156
+
157
+ | Environment | Workspace | Authentication |
158
+ |-------------|----------------------------------|---------------------|
159
+ | `default` | Databricks Free Edition | Client secret |
160
+ | `azure` | Azure Databricks | `github-oidc-azure` |
161
+ | `gcp` | Databricks on Google Cloud | `github-oidc` |
123
162
 
124
163
  At the repository level:
125
164
 
@@ -138,6 +177,12 @@ The version comes from the git tag, through hatch-vcs.
138
177
 
139
178
  `uv publish` authenticates to PyPI with the workflow's OIDC token, through trusted publishing.
140
179
 
180
+ Repository rules protect the release path:
181
+
182
+ - The ruleset "Release tags" lets only repository admins create, move or delete `v*` tags.
183
+ - The `pypi` environment deploys only from `v*` tags.
184
+ - The ruleset "Default branch" blocks force-pushes to `main` and its deletion.
185
+
141
186
  ## Design
142
187
 
143
188
  ### One file system for all of Databricks' paths
@@ -178,7 +223,8 @@ A `WorkspaceClient` given as `client` is kept, and is not picklable. Other ways
178
223
 
179
224
  ### Known limits
180
225
 
181
- - **Multipart upload uses an undocumented API.** Databricks may change it without notice.
226
+ - **Multipart upload is not in the REST reference.** The Databricks SDK for Python uses the same endpoints, as an
227
+ experimental feature, in `databricks/sdk/mixins/files.py`. Compare with it when the API changes.
182
228
  - **Workspace files are at most 10 MB.** The Workspace API's limit.
183
229
  - **No append or update modes on Volumes.** `ab` and `r+b` are refused.
184
230
  - **No listing cache.** Each call reaches the workspace.
@@ -273,10 +319,22 @@ The APIs called depend on the depth of the path:
273
319
  | `/Volumes/<catalog>` | `catalogs.get` | `schemas.list` |
274
320
  | `/Volumes/<catalog>/<schema>` | `schemas.get` | `volumes.list` |
275
321
  | `/Volumes/<catalog>/<schema>/<volume>` | `volumes.read` | `dbfs.list` |
276
- | Deeper | `files.get_directory_metadata`, else `files.get_metadata` | `dbfs.list` |
322
+ | Deeper | `files.get_metadata`, else `files.get_directory_metadata` | `dbfs.list` |
277
323
 
278
324
  `include_browse` is passed to the Unity Catalog APIs.
279
325
 
326
+ Each metadata call is a round trip, so the checks inside a volume make as few as they can:
327
+
328
+ | Check | Calls |
329
+ |-----------------------------------------------|-------------------------------------------------|
330
+ | `info` of a file, so also `size` and reading | 1: `files.get_metadata` |
331
+ | `info` of a directory | 2: `files.get_metadata`, then the directory's |
332
+ | `isdir`, so also `mkdir` and `rmdir` | 1: `files.get_directory_metadata` |
333
+ | `isfile` | 1: `files.get_metadata` |
334
+ | Opening for writing | 1: `isdir`, to refuse directories |
335
+
336
+ `DatabricksFileSystem` passes `isdir` and `isfile` to the backends. `test_volume_fs_metadata_calls` counts the calls.
337
+
280
338
  #### The IO event loop
281
339
 
282
340
  Each `VolumeFileSystem` runs an asyncio event loop in a daemon thread, `dbfs-io-thread`, started on first use. It
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fsspec-databricks
3
- Version: 0.2.0
3
+ Version: 0.2.1
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>
@@ -117,7 +117,8 @@ to Workspace files only in workspaces with legacy DBFS disabled. See
117
117
 
118
118
  **Early beta.** The API and behaviour may change.
119
119
 
120
- * Writing Volume files uses Databricks' multipart upload API, which is undocumented and may change without notice.
120
+ * Writing Volume files uses the Files API's multipart upload. The Databricks REST reference does not document it, but
121
+ the official Databricks SDK for Python uses it, as an experimental feature.
121
122
  * Databricks on AWS is not tested.
122
123
  * Legacy DBFS is not tested.
123
124
 
@@ -92,7 +92,8 @@ to Workspace files only in workspaces with legacy DBFS disabled. See
92
92
 
93
93
  **Early beta.** The API and behaviour may change.
94
94
 
95
- * Writing Volume files uses Databricks' multipart upload API, which is undocumented and may change without notice.
95
+ * Writing Volume files uses the Files API's multipart upload. The Databricks REST reference does not document it, but
96
+ the official Databricks SDK for Python uses it, as an experimental feature.
96
97
  * Databricks on AWS is not tested.
97
98
  * Legacy DBFS is not tested.
98
99
 
@@ -0,0 +1,30 @@
1
+ # Security policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are made in the latest release only.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Report vulnerabilities privately, through
10
+ [GitHub's private vulnerability reporting](https://github.com/sugibuchi/fsspec-databricks/security/advisories/new).
11
+ Do not open a public issue.
12
+
13
+ Include:
14
+
15
+ - the version of `fsspec-databricks`, and of `databricks-sdk`;
16
+ - the steps to reproduce, or a proof of concept;
17
+ - the impact, e.g. leaked credentials or access to files beyond the user's permissions.
18
+
19
+ Never include real credentials, tokens or workspace URLs in a report.
20
+
21
+ ## Scope
22
+
23
+ `fsspec-databricks` accesses Databricks with the credentials of its user, through the Databricks SDK. In scope are,
24
+ for example:
25
+
26
+ - credentials or tokens written to logs, exceptions or pickled file systems;
27
+ - access to files or paths beyond what the given path names;
28
+ - requests sent to hosts other than the workspace, its storage proxy, or the URLs Databricks returns.
29
+
30
+ Vulnerabilities of Databricks itself or of the Databricks SDK go to Databricks.
@@ -73,6 +73,15 @@ source = [
73
73
  source = "vcs"
74
74
  raw-options = { 'version_scheme' = 'post-release' }
75
75
 
76
+ [tool.hatch.build.targets.sdist]
77
+ # Files for development only
78
+ exclude = [
79
+ ".github",
80
+ ".pre-commit-config.yaml",
81
+ "CLAUDE.md",
82
+ "uv.lock",
83
+ ]
84
+
76
85
  [tool.ruff]
77
86
  exclude = [
78
87
  ".tox",
@@ -213,15 +213,17 @@ class AbstractDatabricksFileSystem(AbstractFileSystem):
213
213
  "Making directory: path=%s, create_parents=%s", path, create_parents
214
214
  )
215
215
 
216
- try:
217
- info = self.info(self._parent(path))
218
- if info["type"] != "directory":
219
- raise not_a_directory_error(self._parent(path))
220
- except FileNotFoundError:
221
- if create_parents:
222
- return
223
- else:
224
- raise
216
+ parent = self._parent(path)
217
+ if not self.isdir(parent):
218
+ # Tell a missing parent from a file
219
+ try:
220
+ self.info(parent)
221
+ except FileNotFoundError:
222
+ if create_parents:
223
+ return
224
+ else:
225
+ raise
226
+ raise not_a_directory_error(parent)
225
227
 
226
228
  if self.exists(path):
227
229
  raise file_exists_error(path)
@@ -248,8 +250,9 @@ class AbstractDatabricksFileSystem(AbstractFileSystem):
248
250
  # Actual operations must be implemented by subclasses
249
251
  self.log.debug("Deleting directory: path=%s", path)
250
252
 
251
- info = self.info(path)
252
- if info["type"] != "directory":
253
+ if not self.isdir(path):
254
+ # Raises FileNotFoundError if the path is missing
255
+ self.info(path)
253
256
  raise not_a_directory_error(path)
254
257
 
255
258
  def open(
@@ -458,6 +458,26 @@ class DatabricksFileSystem(AbstractDatabricksFileSystem):
458
458
  fs, p = self._resolve(path)
459
459
  return fs.exists(p, **kwargs)
460
460
 
461
+ def isdir(self, path) -> bool:
462
+ if self._strip_protocol(path) == "/":
463
+ return True
464
+
465
+ try:
466
+ fs, p = self._resolve(path)
467
+ except FileNotFoundError:
468
+ return False
469
+ return fs.isdir(p)
470
+
471
+ def isfile(self, path) -> bool:
472
+ if self._strip_protocol(path) == "/":
473
+ return False
474
+
475
+ try:
476
+ fs, p = self._resolve(path)
477
+ except FileNotFoundError:
478
+ return False
479
+ return fs.isfile(p)
480
+
461
481
  def created(self, path):
462
482
  fs, p = self._resolve(path)
463
483
  return fs.created(p)
@@ -20,7 +20,7 @@ from aiohttp import (
20
20
  from databricks.sdk import WorkspaceClient
21
21
  from databricks.sdk.client_types import HostType
22
22
  from databricks.sdk.config import Config
23
- from databricks.sdk.errors import BadRequest, DatabricksError
23
+ from databricks.sdk.errors import BadRequest, NotFound
24
24
  from databricks.sdk.service.catalog import CatalogInfo, SchemaInfo, VolumeInfo
25
25
  from databricks.sdk.service.files import DirectoryEntry, GetMetadataResponse
26
26
 
@@ -403,19 +403,61 @@ class VolumeFileSystem(DBFS):
403
403
  detail=True,
404
404
  )
405
405
  else:
406
+ # Files first: they are asked about more often than directories
406
407
  try:
407
- self.client.files.get_directory_metadata(full_path)
408
- return {
409
- "name": full_path.rstrip("/"),
410
- "size": None,
411
- "type": "directory",
412
- "created": None,
413
- "modified": None,
414
- "islink": False,
415
- }
416
- except DatabricksError:
417
- metadata = self.client.files.get_metadata(full_path)
418
- return _map_info(metadata, detail=True, path=full_path)
408
+ return self._file_info(full_path)
409
+ except NotFound:
410
+ return self._directory_info(full_path)
411
+
412
+ def _file_info(self, full_path: str) -> dict[str, Any]:
413
+ """Info of a file in a volume, with one API call. Raises `NotFound` for other paths."""
414
+ metadata = self.client.files.get_metadata(full_path)
415
+ return _map_info(metadata, detail=True, path=full_path)
416
+
417
+ def _directory_info(self, full_path: str) -> dict[str, Any]:
418
+ """Info of a directory in a volume, with one API call. Raises `NotFound` for other paths."""
419
+ self.client.files.get_directory_metadata(full_path)
420
+ return {
421
+ "name": full_path.rstrip("/"),
422
+ "size": None,
423
+ "type": "directory",
424
+ "created": None,
425
+ "modified": None,
426
+ "islink": False,
427
+ }
428
+
429
+ def isdir(self, path):
430
+ try:
431
+ _, _, _, path_in_volume, full_path = parse_volume_path(path)
432
+ except ValueError:
433
+ return super().isdir(path)
434
+
435
+ if path_in_volume is None or path_in_volume == "/":
436
+ # The volumes root, catalogs, schemas and volumes
437
+ return super().isdir(path)
438
+
439
+ try:
440
+ with error_mapping(path):
441
+ self._directory_info(full_path)
442
+ return True
443
+ except OSError:
444
+ return False
445
+
446
+ def isfile(self, path):
447
+ try:
448
+ _, _, _, path_in_volume, full_path = parse_volume_path(path)
449
+ except ValueError:
450
+ return False
451
+
452
+ if path_in_volume is None or path_in_volume == "/":
453
+ return False
454
+
455
+ try:
456
+ with error_mapping(path):
457
+ self._file_info(full_path)
458
+ return True
459
+ except Exception:
460
+ return False
419
461
 
420
462
  def ls(self, path, detail=True, include_browse: bool | None = None, **kwargs):
421
463
  self.log.debug(
@@ -472,6 +514,28 @@ class VolumeFileSystem(DBFS):
472
514
  else:
473
515
  raise
474
516
 
517
+ def _check_open_target(self, path: str, mode: str) -> dict[str, Any] | None:
518
+ """Check that the path can be opened in the mode, and return its info if needed."""
519
+ if "r" not in mode and "x" not in mode:
520
+ # Writing only needs to refuse directories
521
+ if self.isdir(path):
522
+ raise is_a_directory_error(path)
523
+ return None
524
+
525
+ try:
526
+ info = self.info(path)
527
+ except FileNotFoundError:
528
+ info = None
529
+
530
+ if info is not None and info["type"] == "directory":
531
+ raise is_a_directory_error(path)
532
+ if "r" in mode and info is None:
533
+ raise file_not_found_error(path)
534
+ if "x" in mode and info is not None:
535
+ raise file_exists_error(path)
536
+
537
+ return info
538
+
475
539
  def _open(
476
540
  self,
477
541
  path,
@@ -487,18 +551,7 @@ class VolumeFileSystem(DBFS):
487
551
  if "a" in mode or "+" in mode:
488
552
  raise UnsupportedOperation("Append and read/write modes are not supported.")
489
553
 
490
- try:
491
- info = self.info(path)
492
- if info["type"] == "directory":
493
- raise is_a_directory_error(path)
494
- except FileNotFoundError:
495
- info = None
496
-
497
- if "r" in mode and info is None:
498
- raise file_not_found_error(path)
499
-
500
- if "x" in mode and info is not None:
501
- raise file_exists_error(path)
554
+ info = self._check_open_target(path, mode)
502
555
 
503
556
  auth = _workspace_authenticator(self.config)
504
557
 
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import contextlib
2
3
  import errno
3
4
  import inspect
4
5
  import logging
@@ -6,7 +7,7 @@ import pickle
6
7
  from datetime import UTC, datetime, timedelta
7
8
  from io import BytesIO
8
9
  from random import randbytes
9
- from unittest.mock import patch
10
+ from unittest.mock import MagicMock, patch
10
11
 
11
12
  import pytest
12
13
  from databricks.sdk import WorkspaceClient
@@ -182,6 +183,78 @@ def test_volume_fs_session_in_event_loop():
182
183
  assert session is fs._session
183
184
 
184
185
 
186
+ class _CountingFilesAPI:
187
+ """A fake of `WorkspaceClient.files` for metadata, recording the calls."""
188
+
189
+ def __init__(self, files: list[str], directories: list[str]):
190
+ self.files = files
191
+ self.directories = directories
192
+ self.calls: list[tuple[str, str]] = []
193
+
194
+ def get_metadata(self, path):
195
+ self.calls.append(("file", path))
196
+ if path not in self.files:
197
+ raise NotFound(f"Not found: {path}")
198
+ return GetMetadataResponse(
199
+ content_length=123,
200
+ content_type="application/octet-stream",
201
+ last_modified="Wed, 21 Oct 2026 07:28:00 GMT",
202
+ )
203
+
204
+ def get_directory_metadata(self, path):
205
+ self.calls.append(("directory", path))
206
+ if path not in self.directories:
207
+ raise NotFound(f"Not found: {path}")
208
+
209
+
210
+ def test_volume_fs_metadata_calls():
211
+ """Each check of a file or a directory makes as few API calls as possible."""
212
+ file = "/Volumes/c/s/v/dir/file.bin"
213
+ directory = "/Volumes/c/s/v/dir"
214
+ missing = "/Volumes/c/s/v/dir/missing"
215
+
216
+ files_api = _CountingFilesAPI(files=[file], directories=[directory])
217
+ client = MagicMock()
218
+ client.files = files_api
219
+
220
+ def calls_of(func):
221
+ files_api.calls.clear()
222
+ with contextlib.suppress(OSError):
223
+ func()
224
+ return list(files_api.calls)
225
+
226
+ with VolumeFileSystem(client=client) as fs:
227
+ # Files: one call
228
+ assert calls_of(lambda: fs.info(file)) == [("file", file)]
229
+ assert fs.info(file)["type"] == "file"
230
+ assert calls_of(lambda: fs.isfile(file)) == [("file", file)]
231
+ assert fs.isfile(file)
232
+ assert not fs.isfile(directory)
233
+
234
+ # Directories: one call to isdir, two to info
235
+ assert calls_of(lambda: fs.isdir(directory)) == [("directory", directory)]
236
+ assert fs.isdir(directory)
237
+ assert not fs.isdir(file)
238
+ assert calls_of(lambda: fs.info(directory)) == [
239
+ ("file", directory),
240
+ ("directory", directory),
241
+ ]
242
+ assert fs.info(directory)["type"] == "directory"
243
+
244
+ # Missing paths
245
+ with pytest.raises(FileNotFoundError):
246
+ fs.info(missing)
247
+
248
+ # Opening a directory for writing: one call
249
+ with pytest.raises(IsADirectoryError):
250
+ fs.open(directory, "wb")
251
+ assert calls_of(lambda: fs.open(directory, "wb")) == [("directory", directory)]
252
+
253
+ # Volumes, schemas and catalogs are directories, and never files
254
+ assert calls_of(lambda: fs.isfile("/Volumes/c/s/v")) == []
255
+ assert not fs.isfile("/Volumes/c/s/v")
256
+
257
+
185
258
  def test_volume_fs_workspace_id(client: WorkspaceClient):
186
259
  with VolumeFileSystem(client=client) as fs:
187
260
  workspace_id = fs.workspace_id
@@ -1,11 +0,0 @@
1
- version: 2
2
- updates:
3
- - package-ecosystem: "pip"
4
- directory: "/"
5
- schedule:
6
- interval: "weekly"
7
-
8
- - package-ecosystem: "github-actions"
9
- directory: "/"
10
- schedule:
11
- interval: "weekly"
@@ -1,33 +0,0 @@
1
- name: Check the source code
2
-
3
- on:
4
- workflow_call:
5
- inputs:
6
- lint:
7
- description: "Whether to run the linter."
8
- type: boolean
9
- required: false
10
- default: true
11
-
12
- concurrency:
13
- group: "check-${{ github.ref_name }}"
14
- cancel-in-progress: true
15
-
16
- jobs:
17
- lint:
18
- name: Lint
19
- if: ${{ inputs.lint }}
20
- runs-on: ubuntu-latest
21
- steps:
22
- - uses: actions/checkout@v7
23
-
24
- - name: Install uv
25
- uses: astral-sh/setup-uv@v7
26
- with:
27
- enable-cache: true
28
-
29
- - name: Run ruff check
30
- run: uvx ruff check .
31
-
32
- - name: Run ruff format check
33
- run: uvx ruff format --check .
@@ -1,85 +0,0 @@
1
- name: Actions for push events
2
-
3
- on:
4
- push:
5
- paths:
6
- - ".github/**"
7
- - "src/**"
8
- - "tests/**"
9
- - "pyproject.toml"
10
-
11
- concurrency:
12
- group: "on-push-${{ github.ref_name }}"
13
- cancel-in-progress: true
14
-
15
- jobs:
16
- check:
17
- name: Check the source code
18
- uses: ./.github/workflows/check.yaml
19
- with:
20
- lint: true
21
-
22
- test:
23
- name: Test Python package
24
- needs:
25
- - check
26
- uses: ./.github/workflows/test.yaml
27
- with:
28
- python-version: "3.14"
29
- coverage: ${{ github.ref_name == 'main' }}
30
- environment: "default"
31
- secrets: inherit
32
-
33
- cross_version_test:
34
- name: Test Python package (Cross version)
35
- if: ${{ github.ref_name == 'main' }}
36
- needs:
37
- - check
38
- strategy:
39
- matrix:
40
- python-version:
41
- - "3.11"
42
- - "3.12"
43
- - "3.13"
44
- uses: ./.github/workflows/test.yaml
45
- with:
46
- python-version: ${{ matrix.python-version }}
47
- coverage: false
48
- environment: "default"
49
- secrets: inherit
50
-
51
- test_on_azure:
52
- name: Test Python package on Azure Databricks
53
- if: ${{ contains(vars.TEST_ENVIRONMENTS, 'azure') }}
54
- needs:
55
- - check
56
- uses: ./.github/workflows/test.yaml
57
- with:
58
- python-version: "3.14"
59
- coverage: ${{ github.ref_name == 'main' }}
60
- environment: "azure"
61
- secrets: inherit
62
-
63
- test_on_aws:
64
- name: Test Python package on Databricks on AWS
65
- needs:
66
- - check
67
- if: ${{ contains(vars.TEST_ENVIRONMENTS, 'aws') }}
68
- uses: ./.github/workflows/test.yaml
69
- with:
70
- python-version: "3.14"
71
- coverage: ${{ github.ref_name == 'main' }}
72
- environment: "aws"
73
- secrets: inherit
74
-
75
- test_on_gcp:
76
- name: Test Python package on Databricks on GCP
77
- needs:
78
- - check
79
- if: ${{ contains(vars.TEST_ENVIRONMENTS, 'gcp') }}
80
- uses: ./.github/workflows/test.yaml
81
- with:
82
- python-version: "3.14"
83
- coverage: ${{ github.ref_name == 'main' }}
84
- environment: "gcp"
85
- secrets: inherit
@@ -1,21 +0,0 @@
1
- name: Actions for release events
2
-
3
- on:
4
- release:
5
- types:
6
- - published
7
-
8
- concurrency:
9
- group: "on-release-${{ github.ref_name }}"
10
- cancel-in-progress: true
11
-
12
- jobs:
13
- publish:
14
- name: Publish Python package
15
- uses: ./.github/workflows/publish.yaml
16
- with:
17
- environment: "pypi"
18
- python-version: "3.14"
19
- permissions:
20
- id-token: write
21
- contents: read
@@ -1,43 +0,0 @@
1
- name: Publish Python package
2
-
3
- on:
4
- workflow_call:
5
- inputs:
6
- environment:
7
- description: "The environment to publish to. Should be either 'pypi' or 'testpypi'."
8
- type: string
9
- required: true
10
- python-version:
11
- description: "The Python version to use for building and publishing the package."
12
- type: string
13
- required: true
14
-
15
- concurrency:
16
- group: "publish-${{ github.ref_name }}"
17
- cancel-in-progress: true
18
-
19
- jobs:
20
- publish:
21
- name: Publish Python package to PyPI
22
- runs-on: ubuntu-latest
23
- environment:
24
- name: ${{ inputs.environment }}
25
-
26
- steps:
27
- - uses: actions/checkout@v7
28
-
29
- - name: Set up Python
30
- uses: actions/setup-python@v7
31
- with:
32
- python-version: ${{ inputs.python-version }}
33
-
34
- - name: Install uv
35
- uses: astral-sh/setup-uv@v7
36
- with:
37
- enable-cache: true
38
-
39
- - name: Build Python package
40
- run: uv build
41
-
42
- - name: Publish Python package
43
- run: uv publish
@@ -1,139 +0,0 @@
1
- name: Test Python package
2
- run-name: Test the Python package
3
-
4
- on:
5
- workflow_call:
6
- inputs:
7
- python-version:
8
- description: "The Python version to use for testing the package."
9
- type: string
10
- required: true
11
- coverage:
12
- description: "Whether to run tests with coverage reporting."
13
- type: boolean
14
- required: false
15
- default: false
16
- environment:
17
- description: "The environment to run tests"
18
- type: string
19
- required: true
20
- secrets:
21
- DATABRICKS_HOST:
22
- required: true
23
- DATABRICKS_CLIENT_ID:
24
- required: true
25
- DATABRICKS_CLIENT_SECRET:
26
- required: true
27
-
28
- workflow_dispatch:
29
- inputs:
30
- python-version:
31
- description: "The Python version to use for testing the package."
32
- type: string
33
- required: true
34
- default: "3.14"
35
- coverage:
36
- description: "Whether to run tests with coverage reporting."
37
- type: boolean
38
- required: true
39
- default: false
40
- environment:
41
- description: "The environment to run tests"
42
- type: choice
43
- required: true
44
- options:
45
- - default
46
- - azure
47
- - aws
48
- - gcp
49
- default: default
50
-
51
-
52
- concurrency:
53
- group: "test-${{ inputs.environment }}-${{ github.ref_name }}-${{ inputs.python-version }}"
54
- cancel-in-progress: false
55
-
56
- jobs:
57
-
58
- test:
59
- name: Test Python package
60
- runs-on: ubuntu-latest
61
- environment: ${{ inputs.environment }}
62
-
63
- steps:
64
- - uses: actions/checkout@v7
65
-
66
- - name: Set up Python ${{ inputs.python-version }}
67
- uses: actions/setup-python@v7
68
- with:
69
- python-version: ${{ inputs.python-version }}
70
-
71
- - name: Install uv
72
- uses: astral-sh/setup-uv@v7
73
- with:
74
- enable-cache: true
75
-
76
- - name: Install the project
77
- run: uv sync
78
-
79
- - name: Set up env variables for working directories
80
- shell: bash
81
- run: |
82
- test_path="${{ github.ref_name }}/${{ inputs.python-version }}"
83
-
84
- has_error=no
85
- if [[ "${{ vars.FSSPEC_DATABRICKS_VOLUME_TEST_ROOT }}" == "" ]]; then
86
- echo "GitHub Actions variable FSSPEC_DATABRICKS_VOLUME_TEST_ROOT must be set."
87
- has_error=yes
88
- else
89
- echo "FSSPEC_DATABRICKS_VOLUME_TEST_ROOT=${{ vars.FSSPEC_DATABRICKS_VOLUME_TEST_ROOT }}/${test_path}" >> $GITHUB_ENV
90
- fi
91
-
92
- if [[ "${{ vars.FSSPEC_DATABRICKS_WORKSPACE_TEST_ROOT }}" == "" ]]; then
93
- echo "GitHub Actions variable FSSPEC_DATABRICKS_WORKSPACE_TEST_ROOT must be set."
94
- has_error=yes
95
- else
96
- echo "FSSPEC_DATABRICKS_WORKSPACE_TEST_ROOT=${{ vars.FSSPEC_DATABRICKS_WORKSPACE_TEST_ROOT }}/${test_path}" >> $GITHUB_ENV
97
- fi
98
-
99
- if [[ "$has_error" = "yes" ]]; then
100
- exit 1
101
- fi
102
-
103
- - name: Run test
104
- if: ${{ !inputs.coverage }}
105
- shell: bash
106
- env:
107
- DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }}
108
- DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }}
109
- DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
110
- run: |
111
- uv run pytest
112
-
113
- - name: Run test with coverage
114
- if: ${{ inputs.coverage }}
115
- shell: bash
116
- env:
117
- DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }}
118
- DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }}
119
- DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
120
- run: |
121
- uv run pytest --cov \
122
- --cov-report=term \
123
- --cov-report=xml:coverage.xml \
124
- --junitxml=junit.xml \
125
- -o junit_family=legacy
126
-
127
- - name: Upload coverage to Codecov
128
- if: ${{ inputs.coverage && !cancelled() }}
129
- uses: codecov/codecov-action@v7
130
- with:
131
- token: ${{ secrets.CODECOV_TOKEN }}
132
- report_type: coverage
133
-
134
- - name: Upload test results to Codecov
135
- if: ${{ inputs.coverage && !cancelled() }}
136
- uses: codecov/codecov-action@v7
137
- with:
138
- token: ${{ secrets.CODECOV_TOKEN }}
139
- report_type: test_results
@@ -1,20 +0,0 @@
1
- exclude: >
2
- (?x)^(
3
- \.tox/.*
4
- )$
5
- repos:
6
-
7
- - repo: https://github.com/pre-commit/pre-commit-hooks
8
- rev: v6.0.0
9
- hooks:
10
- - id: trailing-whitespace
11
- - id: end-of-file-fixer
12
- - id: check-json
13
- - id: check-yaml
14
- - repo: https://github.com/astral-sh/ruff-pre-commit
15
- rev: v0.17.2
16
- hooks:
17
- - id: ruff-check
18
- args: [ --fix, "--show-fixes"]
19
- - id: ruff-format
20
- types_or: [python]
@@ -1,108 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Commands
6
-
7
- All Python goes through uv: never call `python` or `pip` directly.
8
-
9
- ```bash
10
- uv sync # the package, editable, with the dev dependencies
11
- uv run pytest # all tests: most need a Databricks workspace (see below)
12
- uv run pytest tests/test_path.py -k volume # one file / matching tests
13
- uv run pytest --cov # with coverage of src/
14
- uvx ruff check . # lint (ruff is not a dev dependency; config in pyproject.toml)
15
- uvx ruff format . # format
16
- uvx pre-commit run --all-files # the hooks: ruff, whitespace, end of files, YAML
17
- ```
18
-
19
- CI, in `.github/workflows/on_push.yaml`, runs on pushes:
20
-
21
- - ruff check and ruff format check, with the latest ruff;
22
- - pytest on Python 3.14 against the default workspace, with coverage on `main`;
23
- - on `main`, pytest on the other supported Python versions;
24
- - pytest on Azure, AWS or GCP workspaces named in the `TEST_ENVIRONMENTS` variable.
25
-
26
- Releases are published GitHub releases. DEVELOPMENT.md describes them.
27
-
28
- ## Tests and layout
29
-
30
- DEVELOPMENT.md describes the layout, the tests and their setup. Read its Tests section before writing tests. The traps:
31
-
32
- - Most tests run against a real workspace. They need credentials and the two test root variables, from `.env` or the
33
- environment.
34
- - The session fixtures `volume_test_root` and `workspace_test_root` delete everything in the test roots first. Never
35
- point them at folders holding anything else.
36
- - Without credentials, run `tests/test_file.py`, `tests/test_volume.py`, `tests/test_path.py` and `tests/test_utils.py`.
37
- `test_volume.py` and `test_file.py` use the fake Files API in `tests/dummy_api.py`.
38
- - A test of anything a workspace serves gets a check without one where possible, on the fake API or with mocks.
39
- - Test helpers shared by files are in `tests/utils.py`.
40
- - `tests/conftest.py` sets `cachable = False`, so each test gets new file system instances.
41
-
42
- ## Architecture
43
-
44
- DEVELOPMENT.md's Architecture section describes each part: read it before changing one. The traps:
45
-
46
- - `DatabricksFileSystem` routes each call to a backend by path. It holds no file system logic of its own, except for
47
- the root folder and transfers between backends.
48
- - Its methods get the backend and the path to pass with `_resolve(path)`. Inside a workspace, that path is a local
49
- path, and names returned are mapped back with `from_local_path`.
50
- - Base class methods in `base.py` only check preconditions. Subclasses call `super()` first, then the API.
51
- - Wrap Databricks SDK calls in `error_mapping(path)`, so that callers get `OSError` subclasses.
52
- - Parse paths with the helpers of `path.py`. Every method takes `dbfs:/` URLs and POSIX paths alike.
53
- - aiohttp objects are created and used in the Volume backend's IO event loop. `TCPConnector` needs a running loop.
54
- - Never block on the IO loop from code running in it. File objects' synchronous methods raise there.
55
- - `DatabricksFileSystem.__init__` takes every parameter of the SDK's `WorkspaceClient`, in its order.
56
- `test_dbx_fs_accepts_workspace_client_params` fails when the SDK adds one: add it to the signature, the docstring
57
- and `config_params`.
58
- - File systems must stay picklable. A new attribute holding a client, a loop, a thread or a session is dropped in
59
- `__getstate__` and reset in `__setstate__`.
60
- - Multipart upload uses an undocumented Databricks API. Keep its fallback to resumable upload working.
61
- - Volume write block sizes are multiples of 256 KB.
62
- - Workspace files are cached whole in memory, up to the API's 10 MB.
63
- - Chatty debug logs go under `if self.verbose_debug_log:`.
64
-
65
- ## Conventions
66
-
67
- ### Code
68
-
69
- - Comments are short, and say why the code does something not obvious. Keep that density and tone.
70
- - Type hints use built-in generics and `X | None`.
71
- - Each module ends with `__all__`.
72
-
73
- ### Docstrings
74
-
75
- Docstrings of the public API are in numpy style: `Parameters`, `Returns`, `Raises`. The public API is what users
76
- construct or call:
77
-
78
- - `DatabricksFileSystem` and `use`;
79
- - `VolumeFileSystem`, `WorkspaceFileSystem` and `DBFS`, with their constructors and `open`.
80
-
81
- Internal docstrings stay one line or short prose: file objects, helpers and underscore names.
82
-
83
- ### Writing docs
84
-
85
- Write docs so that people can review them. That covers this file, DEVELOPMENT.md and the user docs.
86
-
87
- - One idea per sentence: about 30 words, never more than 50.
88
- - At most one parenthesis per sentence, never nested.
89
- - Three or more items make a list.
90
- - Add a new fact as its own sentence or bullet where it belongs. Never append it to an existing sentence.
91
- - Name an identifier only when the reader needs it to find the code. A function's details belong in its docstring.
92
- - Describe what is, not what was: git keeps the history.
93
- - When editing a passage that breaks these rules, rewrite it.
94
-
95
- ### Where docs go
96
-
97
- - README.md is the quick start: features, installation, paths, compatibility, status and limitations.
98
- - USAGE.md has the user-facing details: operations, each backend's behaviour, and the options.
99
- - AUTHENTICATION.md covers the ways to configure authentication.
100
- - DEVELOPMENT.md has setup, tests, CI, releasing, the design choices and the architecture.
101
-
102
- Update the docs when behaviour, parameters or options change. Keep user docs short and compact: what users do, not how
103
- it works or why.
104
-
105
- ### Links
106
-
107
- README.md, USAGE.md and AUTHENTICATION.md are user docs. README.md uses absolute GitHub URLs for links to files, as
108
- PyPI shows it but cannot resolve relative paths. The other docs are read on GitHub only, so they use relative paths.