Skip to content

fix(fsspec): handle Windows drive paths with the file protocol - #801

Open
dajiaohuang wants to merge 2 commits into
developmentseed:mainfrom
dajiaohuang:fix/fsspec-windows-drive-paths
Open

dajiaohuang wants to merge 2 commits into
developmentseed:mainfrom
dajiaohuang:fix/fsspec-windows-drive-paths

Conversation

@dajiaohuang

Copy link
Copy Markdown

Issue

Fixes #763. Also resolves the Windows half of #555 (the exists()-on-existing-directory failure; see analysis below).

On Windows, every file-protocol operation of FsspecStore is broken. Reproduced on Windows 10 with obstore 0.12.0 / fsspec 2026.9.0:

from obstore.fsspec.fsspec import FsspecStore
fs = FsspecStore("file")
fs.exists("D:/some/existing/file.txt")   # False — should be True
fs.cat_file("D:/some/existing/file.txt")  # OSError: WinError 123 — "D:\D:\some\..." (duplicated drive)

Relative paths are broken too, because fsspec absolutizes them to drive paths before calling into the store.

Root cause

  1. _split_path runs urlparse on the incoming path. On Windows, urlparse("D:/x") parses D: as a URL scheme, so the drive is dropped from the bucket. Later, make_path_posix re-resolves the path against the current drive, producing duplicated-drive paths like D:/D:/x (os error 123). This also silently changes the semantics of explicit cross-drive paths (e.g. E:/x used from a D: cwd).
  2. _construct_store builds the LocalStore via from_url("file://") (root of the current drive) and joins the (already drive-less) path, which is where the duplication surfaces.
  3. _info: upstream object_store's head on a directory raises FileNotFoundError on POSIX, but on Windows File::open(dir) fails with os error 5, which surfaces as GenericError. The existing except FileNotFoundError never triggers, so the directory-listing fallback for _info/exists never runs on Windows (LocalStore.head() fails for existing local folders in Windows - making FsspecStore.exists(), FsspecStore.rm() fail too #555).
  4. _get_file: urlparse(lpath) treats a Windows drive-letter local path as having a scheme, so local downloads with drive paths silently no-op.

Changes

Minimal, Windows-only changes in obstore/python/obstore/fsspec.py:

  • _split_path: detect single-letter drive schemes on Windows and return the drive (with colon, e.g. D:) as the bucket — covers absolute posix/backslash forms, file://D:/... URLs, and (post-absolutization) relative paths; preserves the originally requested drive.
  • _construct_store: for the file protocol with a drive bucket, construct LocalStore("D:/") directly instead of going through from_url, which cannot express a drive root. The from_url path (empty bucket) is unchanged, so POSIX behavior is byte-for-byte identical.
  • _info: also catch GenericError on Windows and fall through to the directory fallback only when the local path really is a directory; otherwise re-raise.
  • _get_file: only treat a genuine URL scheme as a remote source, so drive-letter local paths are handled as local.

tests/test_fsspec.py: test_split_path is now platform-conditional (POSIX assertions unchanged); new test_file_protocol_windows_drive_paths (skipped on non-Windows) exercises exists/info/isdir/cat_file/open/ls/pipe_file/open(wb)/get/rm_file on real Windows drive paths without Docker.

Note: the project's Python CI runs on ubuntu-latest only, so none of this is currently caught by CI; that's why these bugs went unnoticed.

Validation

  • Windows 10, Python 3.13, obstore 0.12.0 (Rust ext) + fsspec 2026.9.0:
    • 29-check behavioral script against the real Rust extension: exists (posix/backslash/URL forms), missing→False, directory handling, info, cat_file, open read/write, ls, pipe_file, copy, rm_file, get/put with Windows local paths, modified, _split_path unit forms (absolute/URL/relative/other-drive preserved), and unchanged memory-protocol behavior — all pass.
    • Docker-free pytest subset of tests/test_fsspec.py: 13 passed, including the new Windows test and test_buffered_file_forwards_size_to_open_reader, which failed before this fix.
    • ruff check + ruff format --check on both modified files: clean (repo config).
    • pyright 1.1.414: zero new diagnostics vs. the unmodified baseline under the same setup (the one pre-existing Bytes stub-resolution diagnostic is identical on main).
  • Not run locally: the Docker/minio-dependent test suite and a full Linux run — Linux CI will exercise these on the PR.

Context: @amachanic offered to fix #763 back in August but no PR has landed; happy to hand this over if you'd rather take it from here.

On Windows, paths like `D:/data/file.txt` are parsed by `urlparse`
with `D:` treated as a URL scheme, so the drive is dropped from the
bucket and later re-resolved against the current drive, producing
duplicated-drive paths like `D:/D:/data/file.txt` (os error 123).
This broke every `file`-protocol operation on Windows, including
relative paths, which are absolutized to drive paths
(developmentseed#763, developmentseed#555).

- `_split_path`: detect single-letter drive schemes on Windows and
  return the drive (with colon, e.g. `D:`) as the bucket, preserving
  the original drive instead of re-resolving against the cwd drive.
- `_construct_store`: for the `file` protocol with a drive bucket,
  construct a `LocalStore` rooted at that drive directly instead of
  going through `from_url`, which cannot express drive roots.
- `_info`: on Windows, `head` on a directory raises `GenericError`
  rather than `FileNotFoundError`, so also catch `GenericError` and
  fall through to the directory-listing fallback for local directories.
- `_get_file`: a Windows drive-letter local path was treated as a
  URL scheme and silently skipped; only treat genuine schemes as remote
  sources.
@ds-release-bot ds-release-bot Bot added the fix label Oct 16, 2026
Comment on lines +310 to +332
if self.protocol == "file" and _is_windows_drive_path(path):
# A Windows drive path such as `D:\dir\file` or `D:/dir/file` is
# not a URL: `urlparse` would parse the drive letter as the URL
# scheme. Resolve the whole path and return the addressed drive
# as the bucket so that `_construct_store` can root a local
# store on that drive.
drive, _, rest = str(make_path_posix(path)).partition(":")
return (f"{drive}:", rest.lstrip("/"))
parsed_path = (
f"{parsed.netloc}/{parsed.path.lstrip('/')}" if parsed.scheme else path
)
if self.protocol == "file":
parsed_path = str(make_path_posix(parsed_path))
if os.name == "nt":
# On Windows, `make_path_posix` resolves any path to a
# drive-rooted path such as `D:/dir/file`. As the store for
# an empty bucket is rooted at the current drive, joining a
# drive-rooted path onto it would duplicate the drive;
# instead return the resolved drive as the bucket, as for
# drive paths above.
drive, _, rest = parsed_path.partition(":")
if len(drive) == 1 and drive.isalpha():
return (f"{drive}:", rest.lstrip("/"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is extremely verbose and I don't think you should need successive checks for windows.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LocalStore fsspec adding extra path stuff in Windows

2 participants