Skip to content

Commit f37f2c4

Browse files
committed
IO: Fix Windows drive letters misidentified as URI schemes by urlparse
On Windows, Python's urlparse treats paths like 'C:\Users\...' as having scheme='c', causing 'Unrecognized filesystem type in URI: c' errors. Uses os.path.splitdrive to detect Windows drive-letter paths before urlparse is called, avoiding the misparse entirely. splitdrive is inherently platform-aware (no-op on Linux/macOS) so no sys.platform check is needed. Fixes all three parse sites (_infer_file_io_from_scheme, PyArrowFileIO.parse_location, FsspecFileIO._get_fs_from_uri) and includes platform-conditional tests. Related: #2477, #1005
1 parent 154288f commit f37f2c4

5 files changed

Lines changed: 71 additions & 8 deletions

File tree

pyiceberg/io/__init__.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import importlib
2929
import logging
30+
import os
3031
import warnings
3132
from abc import ABC, abstractmethod
3233
from io import SEEK_SET
@@ -41,6 +42,18 @@
4142

4243
logger = logging.getLogger(__name__)
4344

45+
46+
def _is_local_path(path: str) -> bool:
47+
"""Check if a path is a local filesystem path rather than a URI.
48+
49+
Uses os.path.splitdrive to detect Windows drive letters (e.g. 'C:\\...')
50+
in a platform-aware way. On non-Windows systems, splitdrive always returns
51+
an empty drive component, so this only triggers on Windows.
52+
"""
53+
drive, _ = os.path.splitdrive(path)
54+
return drive != ""
55+
56+
4457
AWS_PROFILE_NAME = "client.profile-name"
4558
AWS_REGION = "client.region"
4659
AWS_ACCESS_KEY_ID = "client.access-key-id"
@@ -335,14 +348,19 @@ def _import_file_io(io_impl: str, properties: Properties) -> FileIO | None:
335348

336349

337350
def _infer_file_io_from_scheme(path: str, properties: Properties) -> FileIO | None:
338-
parsed_url = urlparse(path)
339-
if parsed_url.scheme:
340-
if file_ios := SCHEMA_TO_FILE_IO.get(parsed_url.scheme):
351+
if _is_local_path(path):
352+
scheme = "file"
353+
else:
354+
parsed_url = urlparse(path)
355+
scheme = parsed_url.scheme
356+
357+
if scheme:
358+
if file_ios := SCHEMA_TO_FILE_IO.get(scheme):
341359
for file_io_path in file_ios:
342360
if file_io := _import_file_io(file_io_path, properties):
343361
return file_io
344362
else:
345-
warnings.warn(f"No preferred file implementation for scheme: {parsed_url.scheme}", stacklevel=2)
363+
warnings.warn(f"No preferred file implementation for scheme: {scheme}", stacklevel=2)
346364
return None
347365

348366

pyiceberg/io/fsspec.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
InputStream,
9090
OutputFile,
9191
OutputStream,
92+
_is_local_path,
9293
)
9394
from pyiceberg.typedef import Properties
9495
from pyiceberg.types import strtobool
@@ -443,7 +444,7 @@ def new_input(self, location: str) -> FsspecInputFile:
443444
FsspecInputFile: An FsspecInputFile instance for the given location.
444445
"""
445446
uri = urlparse(location)
446-
fs = self._get_fs_from_uri(uri)
447+
fs = self._get_fs_from_uri(uri, location)
447448
return FsspecInputFile(location=location, fs=fs)
448449

449450
@override
@@ -457,7 +458,7 @@ def new_output(self, location: str) -> FsspecOutputFile:
457458
FsspecOutputFile: An FsspecOutputFile instance for the given location.
458459
"""
459460
uri = urlparse(location)
460-
fs = self._get_fs_from_uri(uri)
461+
fs = self._get_fs_from_uri(uri, location)
461462
return FsspecOutputFile(location=location, fs=fs)
462463

463464
@override
@@ -475,11 +476,13 @@ def delete(self, location: str | InputFile | OutputFile) -> None:
475476
str_location = location
476477

477478
uri = urlparse(str_location)
478-
fs = self._get_fs_from_uri(uri)
479+
fs = self._get_fs_from_uri(uri, str_location)
479480
fs.rm(str_location)
480481

481-
def _get_fs_from_uri(self, uri: "ParseResult") -> AbstractFileSystem:
482+
def _get_fs_from_uri(self, uri: "ParseResult", location: str = "") -> AbstractFileSystem:
482483
"""Get a filesystem from a parsed URI, using hostname for ADLS account resolution."""
484+
if _is_local_path(location):
485+
return self.get_fs("file")
483486
if uri.scheme in _ADLS_SCHEMES:
484487
return self.get_fs(uri.scheme, uri.hostname)
485488
return self.get_fs(uri.scheme)

pyiceberg/io/pyarrow.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
InputStream,
122122
OutputFile,
123123
OutputStream,
124+
_is_local_path,
124125
)
125126
from pyiceberg.io.fileformat import DataFileStatistics as DataFileStatistics
126127
from pyiceberg.io.fileformat import FileFormatFactory, FileFormatModel, FileFormatWriter
@@ -404,7 +405,14 @@ def parse_location(location: str, properties: Properties = EMPTY_DICT) -> tuple[
404405
"""Return (scheme, netloc, path) for the given location.
405406
406407
Uses DEFAULT_SCHEME and DEFAULT_NETLOC if scheme/netloc are missing.
408+
On Windows, paths with drive letters (e.g. 'C:\\...') are treated as
409+
local file paths rather than URIs.
407410
"""
411+
if _is_local_path(location):
412+
default_scheme = properties.get("DEFAULT_SCHEME", "file")
413+
default_netloc = properties.get("DEFAULT_NETLOC", "")
414+
return default_scheme, default_netloc, os.path.abspath(location)
415+
408416
uri = urlparse(location)
409417

410418
if not uri.scheme:

tests/io/test_io.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import os
1919
import pickle
20+
import sys
2021
import tempfile
2122
from typing import Any
2223

@@ -27,6 +28,7 @@
2728
PY_IO_IMPL,
2829
_import_file_io,
2930
_infer_file_io_from_scheme,
31+
_is_local_path,
3032
load_file_io,
3133
)
3234
from pyiceberg.io.pyarrow import PyArrowFileIO
@@ -339,3 +341,23 @@ def test_infer_file_io_from_schema_unknown() -> None:
339341
_infer_file_io_from_scheme("unknown://bucket/path/", {})
340342

341343
assert str(w[0].message) == "No preferred file implementation for scheme: unknown"
344+
345+
346+
@pytest.mark.parametrize(
347+
"path",
348+
["s3://bucket/key", "hdfs://cluster/path", "gs://bucket/obj", "/tmp/foo", ""],
349+
)
350+
def test_is_local_path_false_for_uris_and_posix(path: str) -> None:
351+
assert _is_local_path(path) is False
352+
353+
354+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only behavior")
355+
@pytest.mark.parametrize("path", [r"C:\Users\test", r"D:\data\iceberg", "E:/warehouse"])
356+
def test_is_local_path_true_on_windows(path: str) -> None:
357+
assert _is_local_path(path) is True
358+
359+
360+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only behavior")
361+
def test_infer_file_io_from_scheme_windows_path() -> None:
362+
result = _infer_file_io_from_scheme(r"C:\Users\test\warehouse", {})
363+
assert isinstance(result, PyArrowFileIO)

tests/io/test_pyarrow.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# pylint: disable=protected-access,unused-argument,redefined-outer-name
1818
import logging
1919
import os
20+
import sys
2021
import tempfile
2122
import uuid
2223
import warnings
@@ -2326,6 +2327,17 @@ def check_results(location: str, expected_schema: str, expected_netloc: str, exp
23262327
check_results("/root/tmp/foo.txt", "file", "", "/root/tmp/foo.txt")
23272328

23282329

2330+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only behavior")
2331+
def test_parse_location_windows_drive_letter() -> None:
2332+
"""Windows drive letters should be treated as local file paths, not URL schemes."""
2333+
for drive in ("C", "D", "c", "d"):
2334+
path = f"{drive}:\\Users\\test\\file.avro"
2335+
scheme, netloc, result_path = PyArrowFileIO.parse_location(path)
2336+
assert scheme == "file"
2337+
assert netloc == ""
2338+
assert result_path == os.path.abspath(path)
2339+
2340+
23292341
def test_make_compatible_name() -> None:
23302342
assert make_compatible_name("label/abc") == "label_x2Fabc"
23312343
assert make_compatible_name("label?abc") == "label_x3Fabc"

0 commit comments

Comments
 (0)