Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions pyiceberg/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import importlib
import logging
import sys
import warnings
from abc import ABC, abstractmethod
from io import SEEK_SET
Expand All @@ -41,6 +42,17 @@

logger = logging.getLogger(__name__)


def _is_windows_drive_letter(scheme: str) -> bool:
r"""Check if a parsed URL scheme is actually a Windows drive letter.
On Windows, Python's urlparse treats paths like 'C:\\Users\\...' as having
scheme='c'. This detects that case so callers can route to the local filesystem.
Only returns True on Windows — no behavior change on other platforms.
"""
return sys.platform == "win32" and len(scheme) == 1 and scheme.isalpha()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Windows newb here:

I'm looking at https://stackoverflow.com/questions/446209/possible-values-from-sys-platform. There's a couple other Windows derivatives mentioned. Should we do that for these too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good question! My understanding is that "win32" is sufficient here, the drive letter parsing bug only exists on native Windows where paths look like C:\....

The other sys.platform values from that SO thread don't need coverage because they use POSIX paths, so urlparse never produces a single-letter scheme on them:

  • Cygwin (sys.platform == "cygwin") translates C:\Users\... to /cygdrive/c/Users/... at the filesystem layer (Cygwin docs)
  • MSYS2 (sys.platform == "msys") similarly maps C:\Users to /c/Users (MSYS2 docs)

sys.platform == "win32" is also the standard CPython idiom for this, it's how the stdlib distinguishes Windows from POSIX behavior (sys.platform docs), and what pytest recommends for skipif markers.



AWS_PROFILE_NAME = "client.profile-name"
AWS_REGION = "client.region"
AWS_ACCESS_KEY_ID = "client.access-key-id"
Expand Down Expand Up @@ -336,13 +348,19 @@ def _import_file_io(io_impl: str, properties: Properties) -> FileIO | None:

def _infer_file_io_from_scheme(path: str, properties: Properties) -> FileIO | None:
parsed_url = urlparse(path)
if parsed_url.scheme:
if file_ios := SCHEMA_TO_FILE_IO.get(parsed_url.scheme):
scheme = parsed_url.scheme

if scheme and _is_windows_drive_letter(scheme):
# Windows drive letter (e.g. 'C:\...') misidentified as a URL scheme
scheme = "file"

if scheme:
if file_ios := SCHEMA_TO_FILE_IO.get(scheme):
for file_io_path in file_ios:
if file_io := _import_file_io(file_io_path, properties):
return file_io
else:
warnings.warn(f"No preferred file implementation for scheme: {parsed_url.scheme}", stacklevel=2)
warnings.warn(f"No preferred file implementation for scheme: {scheme}", stacklevel=2)
return None


Expand Down
3 changes: 3 additions & 0 deletions pyiceberg/io/fsspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
InputStream,
OutputFile,
OutputStream,
_is_windows_drive_letter,
)
from pyiceberg.typedef import Properties
from pyiceberg.types import strtobool
Expand Down Expand Up @@ -480,6 +481,8 @@ def delete(self, location: str | InputFile | OutputFile) -> None:

def _get_fs_from_uri(self, uri: "ParseResult") -> AbstractFileSystem:
"""Get a filesystem from a parsed URI, using hostname for ADLS account resolution."""
if _is_windows_drive_letter(uri.scheme):
return self.get_fs("file")
if uri.scheme in _ADLS_SCHEMES:
return self.get_fs(uri.scheme, uri.hostname)
return self.get_fs(uri.scheme)
Expand Down
5 changes: 4 additions & 1 deletion pyiceberg/io/pyarrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
InputStream,
OutputFile,
OutputStream,
_is_windows_drive_letter,
)
from pyiceberg.io.fileformat import DataFileStatistics as DataFileStatistics
from pyiceberg.io.fileformat import FileFormatFactory, FileFormatModel, FileFormatWriter
Expand Down Expand Up @@ -404,10 +405,12 @@ def parse_location(location: str, properties: Properties = EMPTY_DICT) -> tuple[
"""Return (scheme, netloc, path) for the given location.

Uses DEFAULT_SCHEME and DEFAULT_NETLOC if scheme/netloc are missing.
On Windows, single-letter schemes (drive letters like 'C') are treated
as local file paths rather than URI schemes.
"""
uri = urlparse(location)

if not uri.scheme:
if not uri.scheme or _is_windows_drive_letter(uri.scheme):
default_scheme = properties.get("DEFAULT_SCHEME", "file")
default_netloc = properties.get("DEFAULT_NETLOC", "")
return default_scheme, default_netloc, os.path.abspath(location)
Expand Down
30 changes: 30 additions & 0 deletions tests/io/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import os
import pickle
import sys
import tempfile
from typing import Any

Expand All @@ -27,6 +28,7 @@
PY_IO_IMPL,
_import_file_io,
_infer_file_io_from_scheme,
_is_windows_drive_letter,
load_file_io,
)
from pyiceberg.io.pyarrow import PyArrowFileIO
Expand Down Expand Up @@ -339,3 +341,31 @@ def test_infer_file_io_from_schema_unknown() -> None:
_infer_file_io_from_scheme("unknown://bucket/path/", {})

assert str(w[0].message) == "No preferred file implementation for scheme: unknown"


@pytest.mark.parametrize(
"scheme",
["s3", "hdfs", "file", "gs", ""],
)
def test_is_windows_drive_letter_false_for_real_schemes(scheme: str) -> None:
assert _is_windows_drive_letter(scheme) is False


@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only behavior")
@pytest.mark.parametrize("scheme", ["c", "D", "z"])
def test_is_windows_drive_letter_true_on_windows(scheme: str) -> None:
assert _is_windows_drive_letter(scheme) is True


@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only behavior")
def test_infer_file_io_from_scheme_windows_drive_letter() -> None:
# A Windows-style path like 'C:\Users\...' should be treated as a local file path,
# not as a URL with scheme 'c'
result = _infer_file_io_from_scheme(r"C:\Users\test\warehouse", {})
assert isinstance(result, PyArrowFileIO)


@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only behavior")
def test_infer_file_io_from_scheme_windows_drive_letter_lowercase() -> None:
result = _infer_file_io_from_scheme(r"d:\data\iceberg\table", {})
assert isinstance(result, PyArrowFileIO)
14 changes: 14 additions & 0 deletions tests/io/test_pyarrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
# pylint: disable=protected-access,unused-argument,redefined-outer-name
import logging
import os
import sys
import tempfile
import uuid
import warnings
Expand Down Expand Up @@ -2326,6 +2327,19 @@ def check_results(location: str, expected_schema: str, expected_netloc: str, exp
check_results("/root/tmp/foo.txt", "file", "", "/root/tmp/foo.txt")


@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only behavior")
def test_parse_location_windows_drive_letter() -> None:
"""Windows drive letters like 'C:' should be treated as local file paths, not URL schemes."""
import os

for drive in ("C", "D", "c", "d"):
path = f"{drive}:\\Users\\test\\file.avro"
scheme, netloc, result_path = PyArrowFileIO.parse_location(path)
assert scheme == "file"
assert netloc == ""
assert result_path == os.path.abspath(path)


def test_make_compatible_name() -> None:
assert make_compatible_name("label/abc") == "label_x2Fabc"
assert make_compatible_name("label?abc") == "label_x3Fabc"
Expand Down