fuse4dbricks 0.7.1__tar.gz → 0.7.3__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 (42) hide show
  1. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/.github/workflows/test.yml +1 -1
  2. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/CHANGELOG.md +30 -0
  3. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/PKG-INFO +1 -1
  4. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/auth/provider.py +17 -3
  5. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/fs/metadata_manager.py +12 -0
  6. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/fs/operations.py +18 -0
  7. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/pyproject.toml +1 -1
  8. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_auth_provider.py +25 -0
  9. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_metadata_manager.py +20 -0
  10. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_operations.py +46 -0
  11. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/uv.lock +1 -1
  12. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/.env.example +0 -0
  13. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/.gitignore +0 -0
  14. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/README.md +0 -0
  15. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/__init__.py +0 -0
  16. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/api/__init__.py +0 -0
  17. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/api/errors.py +0 -0
  18. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/api/uc_client.py +0 -0
  19. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/auth/__init__.py +0 -0
  20. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/fs/__init__.py +0 -0
  21. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/fs/auth_manager.py +0 -0
  22. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/fs/data_manager.py +0 -0
  23. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/fs/inode_manager.py +0 -0
  24. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/fs/ram_cache.py +0 -0
  25. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/fs/securable_filter.py +0 -0
  26. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/fs/utils.py +0 -0
  27. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/fs/write_buffer.py +0 -0
  28. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/main.py +0 -0
  29. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/storage/__init__.py +0 -0
  30. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/fuse4dbricks/storage/persistence.py +0 -0
  31. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_auth_manager.py +0 -0
  32. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_data_manager.py +0 -0
  33. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_e2e_mount.py +0 -0
  34. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_inode_manager.py +0 -0
  35. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_main.py +0 -0
  36. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_persistence.py +0 -0
  37. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_securable_filter.py +0 -0
  38. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_uc_client.py +0 -0
  39. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_uc_client_consistency.py +0 -0
  40. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_uc_client_live.py +0 -0
  41. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_utils.py +0 -0
  42. {fuse4dbricks-0.7.1 → fuse4dbricks-0.7.3}/tests/test_write_buffer.py +0 -0
@@ -54,7 +54,7 @@ jobs:
54
54
  - name: Build a binary wheel and a source tarball
55
55
  run: uv build
56
56
  - name: Store the distribution packages
57
- uses: actions/upload-artifact@v5
57
+ uses: actions/upload-artifact@v7
58
58
  with:
59
59
  name: python-package-distributions
60
60
  path: dist/
@@ -1,3 +1,33 @@
1
+ # 0.7.3 (2026-06-19)
2
+
3
+ - Fix `mv` of a freshly written file failing with an I/O error. `rename()`
4
+ copies the source by downloading it with an `If-Unmodified-Since` guard built
5
+ from the inode's `st_mtime`. A file just created/written through the mount
6
+ carries a locally-stamped mtime (the `create()`-time wall clock), which is
7
+ earlier than the server's `Last-Modified` (assigned when the upload
8
+ completed), so the server reported `412 Precondition Failed` and the copy read
9
+ failed with `EIO`. `rename()` now refreshes the source's metadata from the
10
+ server before copying, so the guard matches the server's view. (Ordinary reads
11
+ were unaffected: they refresh via `getattr` before reading; only `rename`
12
+ bypassed that.)
13
+ - Stop logging a missing Databricks config file at `ERROR`. A uid without a
14
+ Databricks setup (e.g. `root` or a system daemon probing the mount) produced
15
+ three error lines per request, flooding the journal. A missing default
16
+ `~/.databrickscfg` and the resulting "no token" outcome are now logged at
17
+ `debug`; genuinely unexpected stat failures (e.g. permission denied) are
18
+ logged at `warning`.
19
+
20
+ # 0.7.2 (2026-06-19)
21
+
22
+ - Fix a just-created file or directory being reported as nonexistent for up to
23
+ the negative-cache TTL (~5s) when its path had been stat'd before it existed.
24
+ Probing a not-yet-existing path records a negative (not-found) cache entry,
25
+ and `invalidate()` — called on every mutating op (create/write, `mkdir`,
26
+ `unlink`/`rmdir`, `rename`, truncate) — cleared the attribute and directory
27
+ caches but left that negative entry in place, so a subsequent `stat` kept
28
+ returning `ENOENT` until the entry expired. `invalidate()` now also evicts the
29
+ path's negative-cache entries (for every principal).
30
+
1
31
  # 0.7.1 (2026-06-17)
2
32
 
3
33
  - Unmount cleanly on `SIGTERM`/`SIGHUP` (e.g. `systemctl stop`). trio only turns
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fuse4dbricks
3
- Version: 0.7.1
3
+ Version: 0.7.3
4
4
  Summary: FUSE driver for Databricks Unity Catalog Volumes.
5
5
  Project-URL: Homepage, https://github.com/zeehio/fuse4dbricks
6
6
  Author-email: Sergio Oller <sergioller@gmail.com>
@@ -171,8 +171,17 @@ class DatabricksUnifiedAuthProvider:
171
171
  """
172
172
  try:
173
173
  st = os.stat(config_file)
174
+ except FileNotFoundError:
175
+ # A missing config file is the normal state for any uid without a
176
+ # Databricks setup (e.g. root or a system daemon probing the mount).
177
+ # Logging it at error level floods the journal with one line per
178
+ # request, so keep it at debug.
179
+ logger.debug("Config file %s does not exist, ignoring", config_file)
180
+ return False
174
181
  except OSError as exc:
175
- logger.error("Cannot stat config file %s: %s", config_file, exc)
182
+ # Other stat failures (permission denied, etc.) are unexpected and
183
+ # worth surfacing, but are not fatal to the mount.
184
+ logger.warning("Cannot stat config file %s: %s", config_file, exc)
176
185
  return False
177
186
  if not stat.S_ISREG(st.st_mode):
178
187
  logger.error("Config file %s is not a regular file, ignoring", config_file)
@@ -216,7 +225,9 @@ class DatabricksUnifiedAuthProvider:
216
225
  config_file = default_config
217
226
 
218
227
  if config_file is None:
219
- logger.error("No databricks config file found for uid %s. Checked environment variable DATABRICKS_CONFIG_FILE and default location", uid)
228
+ # Expected for any uid without a Databricks config; debug-level so a
229
+ # repeatedly-probing uid (e.g. root) does not flood the journal.
230
+ logger.debug("No databricks config file found for uid %s. Checked environment variable DATABRICKS_CONFIG_FILE and default location", uid)
220
231
  return None, None
221
232
  return profile, config_file
222
233
 
@@ -247,7 +258,10 @@ class DatabricksUnifiedAuthProvider:
247
258
  # The process does not define a token, but defines a profile or config file:
248
259
  profile, config_file = self._get_profile_and_config_file_name(env, ctx.uid)
249
260
  if profile is None or config_file is None:
250
- logger.error("No databricks token found in environment variables for pid %s, and no profile or config file found for uid %s", ctx.pid, ctx.uid)
261
+ # Routine when a uid has neither a token in its environment nor a
262
+ # config file; the caller turns this into EACCES. Debug-level to
263
+ # avoid one error line per request from a probing uid.
264
+ logger.debug("No databricks token found in environment variables for pid %s, and no profile or config file found for uid %s", ctx.pid, ctx.uid)
251
265
  return None
252
266
  return self._get_token_from_config(profile, config_file)
253
267
 
@@ -480,6 +480,18 @@ class MetadataManager:
480
480
  if attr_cache_key in self._attr_cache:
481
481
  del self._attr_cache[attr_cache_key]
482
482
 
483
+ # A mutating op (create/write/mkdir/rename/truncate/delete) just
484
+ # changed this path's presence, so any cached negative is now stale.
485
+ # Drop it for every principal: invalidate() is synchronous and has
486
+ # no principal in hand, and a just-created path must be immediately
487
+ # visible on this mount rather than hidden until the negative TTL
488
+ # expires. Build the key list first, then delete -- there is no
489
+ # await between, so this stays atomic under trio (mirrors the
490
+ # lock-free contract of the rest of this method).
491
+ stale_negatives = [k for k in self._negative_cache if k[1] == fs_path]
492
+ for key in stale_negatives:
493
+ del self._negative_cache[key]
494
+
483
495
  parent_path = "/".join(fs_path.rstrip("/").split("/")[:-1]) or "/"
484
496
  if parent_path in self._dir_cache:
485
497
  del self._dir_cache[parent_path]
@@ -757,6 +757,24 @@ class UnityCatalogFS(pyfuse3.Operations):
757
757
  if src_entry.is_dir:
758
758
  raise pyfuse3.FUSEError(errno.EXDEV)
759
759
 
760
+ # Refresh the source's metadata from the server before copying. A file
761
+ # just created/written through this mount carries a locally-stamped
762
+ # st_mtime (the create()-time wall clock), which is earlier than the
763
+ # server's Last-Modified (assigned when the upload completed). The copy
764
+ # below sends that mtime as If-Unmodified-Since; a too-early value makes
765
+ # the server report 412 (file changed since) and the read fails with
766
+ # EIO, so `mv` of a freshly-written file breaks. getattr() refreshes the
767
+ # inode on the ordinary read path but rename() never does, so do it here.
768
+ try:
769
+ refreshed = await self.metadata_manager.get_attributes(src_entry, ctx)
770
+ except pyfuse3.FUSEError:
771
+ raise
772
+ except Exception as exc:
773
+ self._raise_fuse_error(exc, fs_path=old_fs_path, op="rename")
774
+ if refreshed is None:
775
+ raise pyfuse3.FUSEError(errno.ENOENT)
776
+ src_entry.attr.update(refreshed)
777
+
760
778
  # Inspect the destination for NOREPLACE / file-over-dir rules.
761
779
  try:
762
780
  dst_attr = await self.metadata_manager.lookup_child(new_parent, new_name, ctx)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fuse4dbricks"
7
- version = "0.7.1"
7
+ version = "0.7.3"
8
8
  description = "FUSE driver for Databricks Unity Catalog Volumes."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -184,6 +184,31 @@ def test_profile_config_nothing_found(unified, tmp_path, monkeypatch):
184
184
  assert (profile, config_file) == (None, None)
185
185
 
186
186
 
187
+ def test_missing_default_config_is_not_logged_as_error(unified, tmp_path, monkeypatch, caplog):
188
+ """A missing ~/.databrickscfg is the normal state for a uid without a
189
+ Databricks setup (e.g. root probing the mount). It must not be logged at
190
+ error level, or a repeatedly-probing uid floods the journal with one line
191
+ per request."""
192
+ monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "empty"))
193
+ with caplog.at_level("DEBUG", logger="fuse4dbricks.auth.provider"):
194
+ profile, config_file = unified._get_profile_and_config_file_name(env=None, uid=os.getuid())
195
+ assert (profile, config_file) == (None, None)
196
+ # The benign "does not exist" path logs at debug, never at error/warning.
197
+ assert not [r for r in caplog.records if r.levelname in ("ERROR", "WARNING")]
198
+ assert any(r.levelname == "DEBUG" for r in caplog.records)
199
+
200
+
201
+ def test_unreadable_config_is_logged_as_warning(unified, tmp_path, monkeypatch, caplog):
202
+ """A stat failure that is NOT 'file missing' (e.g. permission denied) is
203
+ unexpected and stays visible at warning level."""
204
+ def _boom(path):
205
+ raise PermissionError("denied")
206
+ monkeypatch.setattr(provider_mod.os, "stat", _boom)
207
+ with caplog.at_level("DEBUG", logger="fuse4dbricks.auth.provider"):
208
+ assert unified._is_safe_config_file(str(tmp_path / "cfg"), os.getuid()) is False
209
+ assert any(r.levelname == "WARNING" for r in caplog.records)
210
+
211
+
187
212
  # ---------------------------------------------------------------------------
188
213
  # _get_token_from_config
189
214
  # ---------------------------------------------------------------------------
@@ -453,6 +453,26 @@ def test_invalidate_nonexistent_path_does_not_raise(manager):
453
453
  manager.invalidate("/nonexistent/path/file.txt", is_dir=False) # Must not raise
454
454
 
455
455
 
456
+ def test_invalidate_clears_negative_cache_for_all_principals(manager, ctx):
457
+ """A mutating op (create/write/mkdir/...) routes through invalidate(); it
458
+ must drop the path's negative entry for EVERY principal so a just-created
459
+ file is immediately visible instead of hidden until the negative TTL
460
+ expires. This is the negative-cache poisoning bug: stat a path before it
461
+ exists, create it, then stat again -> stale ENOENT for ~ttl_negative."""
462
+ future = time.time() + 100
463
+ fs_path = "/cat/sch/vol/file.txt"
464
+ manager._negative_cache[("alice@example.com", fs_path)] = future
465
+ manager._negative_cache[("bob@example.com", fs_path)] = future
466
+ # A negative for a *different* path must survive.
467
+ manager._negative_cache[("alice@example.com", "/cat/sch/vol/other.txt")] = future
468
+
469
+ manager.invalidate(fs_path, is_dir=False)
470
+
471
+ assert ("alice@example.com", fs_path) not in manager._negative_cache
472
+ assert ("bob@example.com", fs_path) not in manager._negative_cache
473
+ assert ("alice@example.com", "/cat/sch/vol/other.txt") in manager._negative_cache
474
+
475
+
456
476
  # ---------------------------------------------------------------------------
457
477
  # reauthorize (token change -> refresh the uid -> principal mapping)
458
478
  # ---------------------------------------------------------------------------
@@ -1263,6 +1263,52 @@ async def test_rename_file_copies_then_deletes_and_moves_inode(
1263
1263
  metadata_manager.invalidate.assert_any_call("/cat/sch/vol/renamed.txt", is_dir=False)
1264
1264
 
1265
1265
 
1266
+ @pytest.mark.trio
1267
+ async def test_rename_refreshes_source_mtime_before_copy(
1268
+ fs, inode_manager, uc_client, metadata_manager, data_manager, ctx
1269
+ ):
1270
+ """Regression: a file just written through the mount carries a locally
1271
+ stamped st_mtime (create()-time wall clock) that is earlier than the
1272
+ server's Last-Modified. rename() must refresh the source from the server
1273
+ before copying, so the If-Unmodified-Since the copy sends matches the
1274
+ server and does not 412. The copy read must use the refreshed mtime/size,
1275
+ not the stale inode values."""
1276
+ vol, f = _make_vol_file(inode_manager, size=512)
1277
+ # Stale, locally-stamped attributes left on the inode after create()/write.
1278
+ f.attr.st_mtime = 9999.0
1279
+ f.attr.st_size = 512
1280
+ # The server's authoritative view, returned by the refresh.
1281
+ server_attr = _make_attr(False, size=5) # _make_attr -> st_mtime=2000.0
1282
+ metadata_manager.get_attributes = AsyncMock(return_value=server_attr)
1283
+ metadata_manager.lookup_child = AsyncMock(return_value=None) # dest is free
1284
+ metadata_manager.invalidate = MagicMock()
1285
+
1286
+ await fs.rename(vol.inode, b"data.txt", vol.inode, b"renamed.txt", 0, ctx)
1287
+
1288
+ # Source was refreshed from the server before the copy.
1289
+ metadata_manager.get_attributes.assert_awaited_once()
1290
+ # The copy read used the server mtime (2000.0) and size (5), not the stale
1291
+ # local values (9999.0 / 512).
1292
+ read_args = data_manager.read.call_args.args
1293
+ assert read_args[3] == 2000.0 # mtime -> If-Unmodified-Since
1294
+ assert read_args[4] == 5 # file_size
1295
+
1296
+
1297
+ @pytest.mark.trio
1298
+ async def test_rename_source_deleted_during_refresh_raises_enoent(
1299
+ fs, inode_manager, metadata_manager, ctx
1300
+ ):
1301
+ """If the source vanished by the time we refresh its metadata, rename
1302
+ surfaces ENOENT rather than copying with stale attributes."""
1303
+ vol, f = _make_vol_file(inode_manager, size=5)
1304
+ metadata_manager.get_attributes = AsyncMock(return_value=None) # gone
1305
+ metadata_manager.lookup_child = AsyncMock(return_value=None)
1306
+
1307
+ with pytest.raises(pyfuse3.FUSEError) as ei:
1308
+ await fs.rename(vol.inode, b"data.txt", vol.inode, b"renamed.txt", 0, ctx)
1309
+ assert ei.value.errno == errno.ENOENT
1310
+
1311
+
1266
1312
  @pytest.mark.trio
1267
1313
  async def test_rename_into_other_directory_reparents_inode(
1268
1314
  fs, inode_manager, uc_client, metadata_manager, ctx
@@ -353,7 +353,7 @@ wheels = [
353
353
 
354
354
  [[package]]
355
355
  name = "fuse4dbricks"
356
- version = "0.7.1"
356
+ version = "0.7.3"
357
357
  source = { editable = "." }
358
358
  dependencies = [
359
359
  { name = "databricks-sdk" },
File without changes
File without changes
File without changes