This is an automated email from the ASF dual-hosted git repository.

Fokko pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-python.git


The following commit(s) were added to refs/heads/main by this push:
     new 8865ddbd9 CI: Add Windows unit test job (#3723)
8865ddbd9 is described below

commit 8865ddbd95767561fcf2812c9c948ab1252c6deb
Author: Jared Yu <[email protected]>
AuthorDate: Tue Aug 4 07:52:24 2026 -0700

    CI: Add Windows unit test job (#3723)
    
    ## Rationale
    
    Adds `windows-latest` to the Python CI workflow, enabling automated
    testing on Windows.
    
    **Merge order**: #3721 must merge first (library fix for drive letter
    parsing). This PR is branched on top of it.
    
    **Supersedes**: #3722 (test fixes are now here instead of separate).
    
    Depends on:
    - #3721 (IO: Fix Windows drive letters misidentified as URI schemes)
    - #3722 (Tests: Fix Windows compatibility in unit test suite)
    
    ## Changes
    - Add `windows-unit-test` job (Python 3.12, `windows-latest`)
    - Fix test platform assumptions (MAX_PATH, file locking, path formats,
    SQLite URIs)
    - Skip Kerberos tests on Windows (C lib unavailable)
    - Skip Rich box-rendering tests on Windows (terminal-dependent
    formatting)
    
    ## Expected result
    Locally verified: 3801 passed, 1 skipped, 0 failures on Windows.
    
    Depends on #3721. Closes #2477.
---
 .github/workflows/python-ci.yml | 24 ++++++++++++
 pyiceberg/io/__init__.py        | 26 +++++++++++--
 pyiceberg/io/fsspec.py          | 11 ++++--
 pyiceberg/io/pyarrow.py         | 10 ++++-
 tests/catalog/test_hive.py      |  8 +++-
 tests/catalog/test_sql.py       | 20 +++++-----
 tests/conftest.py               | 12 +++---
 tests/io/test_io.py             | 38 ++++++++++++++----
 tests/io/test_pyarrow.py        | 86 ++++++++++++++++++++++++-----------------
 tests/utils/test_manifest.py    |  2 +-
 10 files changed, 166 insertions(+), 71 deletions(-)

diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml
index 7e4dce39d..5645d684c 100644
--- a/.github/workflows/python-ci.yml
+++ b/.github/workflows/python-ci.yml
@@ -79,6 +79,30 @@ jobs:
     - name: Generate coverage report (85%) # Coverage threshold should only 
increase over time — never decrease it!
       run: COVERAGE_FAIL_UNDER=85 make coverage-report
 
+  windows-unit-test:
+    runs-on: windows-latest
+    timeout-minutes: 15
+    strategy:
+      fail-fast: true
+      matrix:
+        python: ['3.12']
+
+    steps:
+    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+      with:
+        persist-credentials: false
+    - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 
v6.3.0
+      with:
+        python-version: ${{ matrix.python }}
+    - name: Install UV
+      uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 
v8.2.0
+      with:
+        enable-cache: true
+    - name: Install
+      run: uv sync --all-extras --no-extra hive-kerberos
+    - name: Run unit tests
+      run: uv run python -m pytest tests/ -m "(unmarked or parametrize) and 
not integration" --ignore=tests/integration -v -x
+
   cibw-dev-env-smoke-test:
     runs-on: ubuntu-latest
     steps:
diff --git a/pyiceberg/io/__init__.py b/pyiceberg/io/__init__.py
index 7dbc65121..6ff167474 100644
--- a/pyiceberg/io/__init__.py
+++ b/pyiceberg/io/__init__.py
@@ -27,6 +27,7 @@ from __future__ import annotations
 
 import importlib
 import logging
+import os
 import warnings
 from abc import ABC, abstractmethod
 from io import SEEK_SET
@@ -41,6 +42,18 @@ from pyiceberg.typedef import EMPTY_DICT, Properties
 
 logger = logging.getLogger(__name__)
 
+
+def _is_local_path(path: str) -> bool:
+    r"""Check if a path is a local filesystem path rather than a URI.
+
+    Uses os.path.splitdrive to detect Windows drive letters (e.g. 'C:\\...')
+    in a platform-aware way. On non-Windows systems, splitdrive always returns
+    an empty drive component, so this only triggers on Windows.
+    """
+    drive, _ = os.path.splitdrive(path)
+    return drive != ""
+
+
 AWS_PROFILE_NAME = "client.profile-name"
 AWS_REGION = "client.region"
 AWS_ACCESS_KEY_ID = "client.access-key-id"
@@ -335,14 +348,19 @@ PY_IO_IMPL = "py-io-impl"
 
 
 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):
+    if _is_local_path(path):
+        scheme = "file"
+    else:
+        parsed_url = urlparse(path)
+        scheme = parsed_url.scheme
+
+    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
 
 
diff --git a/pyiceberg/io/fsspec.py b/pyiceberg/io/fsspec.py
index 7749268ff..b28ffd069 100644
--- a/pyiceberg/io/fsspec.py
+++ b/pyiceberg/io/fsspec.py
@@ -89,6 +89,7 @@ from pyiceberg.io import (
     InputStream,
     OutputFile,
     OutputStream,
+    _is_local_path,
 )
 from pyiceberg.typedef import Properties
 from pyiceberg.types import strtobool
@@ -443,7 +444,7 @@ class FsspecFileIO(FileIO):
             FsspecInputFile: An FsspecInputFile instance for the given 
location.
         """
         uri = urlparse(location)
-        fs = self._get_fs_from_uri(uri)
+        fs = self._get_fs_from_uri(uri, location)
         return FsspecInputFile(location=location, fs=fs)
 
     @override
@@ -457,7 +458,7 @@ class FsspecFileIO(FileIO):
             FsspecOutputFile: An FsspecOutputFile instance for the given 
location.
         """
         uri = urlparse(location)
-        fs = self._get_fs_from_uri(uri)
+        fs = self._get_fs_from_uri(uri, location)
         return FsspecOutputFile(location=location, fs=fs)
 
     @override
@@ -475,11 +476,13 @@ class FsspecFileIO(FileIO):
             str_location = location
 
         uri = urlparse(str_location)
-        fs = self._get_fs_from_uri(uri)
+        fs = self._get_fs_from_uri(uri, str_location)
         fs.rm(str_location)
 
-    def _get_fs_from_uri(self, uri: "ParseResult") -> AbstractFileSystem:
+    def _get_fs_from_uri(self, uri: "ParseResult", location: str = "") -> 
AbstractFileSystem:
         """Get a filesystem from a parsed URI, using hostname for ADLS account 
resolution."""
+        if _is_local_path(location):
+            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)
diff --git a/pyiceberg/io/pyarrow.py b/pyiceberg/io/pyarrow.py
index 29ede3ce9..c36f1639d 100644
--- a/pyiceberg/io/pyarrow.py
+++ b/pyiceberg/io/pyarrow.py
@@ -121,6 +121,7 @@ from pyiceberg.io import (
     InputStream,
     OutputFile,
     OutputStream,
+    _is_local_path,
 )
 from pyiceberg.io.fileformat import DataFileStatistics as DataFileStatistics
 from pyiceberg.io.fileformat import FileFormatFactory, FileFormatModel, 
FileFormatWriter
@@ -401,10 +402,17 @@ class PyArrowFileIO(FileIO):
 
     @staticmethod
     def parse_location(location: str, properties: Properties = EMPTY_DICT) -> 
tuple[str, str, str]:
-        """Return (scheme, netloc, path) for the given location.
+        r"""Return (scheme, netloc, path) for the given location.
 
         Uses DEFAULT_SCHEME and DEFAULT_NETLOC if scheme/netloc are missing.
+        On Windows, paths with drive letters (e.g. 'C:\\...') are treated as
+        local file paths rather than URIs.
         """
+        if _is_local_path(location):
+            default_scheme = properties.get("DEFAULT_SCHEME", "file")
+            default_netloc = properties.get("DEFAULT_NETLOC", "")
+            return default_scheme, default_netloc, os.path.abspath(location)
+
         uri = urlparse(location)
 
         if not uri.scheme:
diff --git a/tests/catalog/test_hive.py b/tests/catalog/test_hive.py
index 09bb5ab92..f594aa876 100644
--- a/tests/catalog/test_hive.py
+++ b/tests/catalog/test_hive.py
@@ -18,6 +18,7 @@
 import base64
 import copy
 import struct
+import sys
 import threading
 import uuid
 from collections.abc import Generator
@@ -300,7 +301,7 @@ def test_create_table(
     # it to construct the assert_called_with
     metadata_location: str = called_hive_table.parameters["metadata_location"]
     assert metadata_location.endswith(".metadata.json")
-    assert "/database/table/metadata/" in metadata_location
+    assert "/database/table/metadata/" in metadata_location.replace("\\", "/")
     catalog._client.__enter__().create_table.assert_called_with(
         HiveTable(
             tableName="table",
@@ -481,7 +482,7 @@ def 
test_create_table_with_given_location_removes_trailing_slash(
     # it to construct the assert_called_with
     metadata_location: str = called_hive_table.parameters["metadata_location"]
     assert metadata_location.endswith(".metadata.json")
-    assert "/database/table-given-location/metadata/" in metadata_location
+    assert "/database/table-given-location/metadata/" in 
metadata_location.replace("\\", "/")
     catalog._client.__enter__().create_table.assert_called_with(
         HiveTable(
             tableName="table",
@@ -1369,6 +1370,7 @@ def test_create_hive_client_failure() -> None:
         assert mock_hive_client.call_count == 2
 
 
[email protected](sys.platform == "win32", reason="Kerberos/puresasl not 
available on Windows")
 def test_create_hive_client_with_kerberos(
     kerberized_hive_metastore_fake_url: str,
 ) -> None:
@@ -1381,6 +1383,7 @@ def test_create_hive_client_with_kerberos(
     assert client is not None
 
 
[email protected](sys.platform == "win32", reason="Kerberos/puresasl not 
available on Windows")
 def test_create_hive_client_with_kerberos_using_context_manager(
     kerberized_hive_metastore_fake_url: str,
 ) -> None:
@@ -1412,6 +1415,7 @@ def 
test_create_hive_client_with_kerberos_using_context_manager(
             assert open_client._iprot.trans.isOpen()
 
 
[email protected](sys.platform == "win32", reason="Kerberos/puresasl not 
available on Windows")
 def test_kerberized_client_uses_fresh_transport_on_reuse(
     kerberized_hive_metastore_fake_url: str,
 ) -> None:
diff --git a/tests/catalog/test_sql.py b/tests/catalog/test_sql.py
index 1eabc56a9..4ba63d783 100644
--- a/tests/catalog/test_sql.py
+++ b/tests/catalog/test_sql.py
@@ -61,7 +61,7 @@ def catalog_memory(catalog_name: str, warehouse: Path) -> 
Generator[SqlCatalog,
 @pytest.fixture(scope="module")
 def catalog_sqlite(catalog_name: str, warehouse: Path) -> 
Generator[SqlCatalog, None, None]:
     props = {
-        "uri": f"sqlite:////{warehouse}/sql-catalog",
+        "uri": f"sqlite:///{warehouse.as_posix()}/sql-catalog",
         "warehouse": f"file://{warehouse}",
     }
     catalog = SqlCatalog(catalog_name, **props)
@@ -72,7 +72,7 @@ def catalog_sqlite(catalog_name: str, warehouse: Path) -> 
Generator[SqlCatalog,
 
 @pytest.fixture(scope="module")
 def catalog_uri(warehouse: Path) -> str:
-    return f"sqlite:////{warehouse}/sql-catalog"
+    return f"sqlite:///{warehouse.as_posix()}/sql-catalog"
 
 
 @pytest.fixture(scope="module")
@@ -96,7 +96,7 @@ def test_creation_with_echo_parameter(catalog_name: str, 
warehouse: Path) -> Non
 
     for echo_param, expected_echo_value in test_cases:
         props = {
-            "uri": f"sqlite:////{warehouse}/sql-catalog",
+            "uri": f"sqlite:///{warehouse.as_posix()}/sql-catalog",
             "warehouse": f"file://{warehouse}",
         }
         # None is for default value
@@ -119,7 +119,7 @@ def 
test_creation_with_pool_pre_ping_parameter(catalog_name: str, warehouse: Pat
 
     for pool_pre_ping_param, expected_pool_pre_ping_value in test_cases:
         props = {
-            "uri": f"sqlite:////{warehouse}/sql-catalog",
+            "uri": f"sqlite:///{warehouse.as_posix()}/sql-catalog",
             "warehouse": f"file://{warehouse}",
         }
         # None is for default value
@@ -139,7 +139,7 @@ def test_creation_from_impl(catalog_name: str, warehouse: 
Path) -> None:
             catalog_name,
             **{
                 "py-catalog-impl": "pyiceberg.catalog.sql.SqlCatalog",
-                "uri": f"sqlite:////{warehouse}/sql-catalog",
+                "uri": f"sqlite:///{warehouse.as_posix()}/sql-catalog",
                 "warehouse": f"file://{warehouse}",
             },
         ),
@@ -268,7 +268,7 @@ def get_columns(engine: Engine) -> set[str]:
 
 
 def test_adds_iceberg_type_column_to_old_schema(warehouse: Path) -> None:
-    uri = f"sqlite:////{warehouse}/test-migration-add-col"
+    uri = f"sqlite:///{warehouse.as_posix()}/test-migration-add-col"
     engine = _create_v0_db(uri)
 
     # Verify the column does not exist in the old schema
@@ -334,7 +334,7 @@ def test_list_tables_filters_by_iceberg_type(warehouse: 
Path) -> None:
 
 
 def test_migration_to_v1_with_property_set(warehouse: Path) -> None:
-    uri = f"sqlite:////{warehouse}/test-v1-migrate"
+    uri = f"sqlite:///{warehouse.as_posix()}/test-v1-migrate"
     engine = _create_v0_db(uri)
 
     assert "iceberg_type" not in get_columns(engine)
@@ -361,7 +361,7 @@ def test_invalid_schema_version_raises(warehouse: Path) -> 
None:
 
 def test_list_tables_works_on_v0_schema(warehouse: Path) -> None:
     """list_tables should work on V0 schemas without iceberg_type column."""
-    uri = f"sqlite:////{warehouse}/test-v0-list"
+    uri = f"sqlite:///{warehouse.as_posix()}/test-v0-list"
     _create_v0_db(uri)
 
     catalog = SqlCatalog(
@@ -441,7 +441,7 @@ def _make_v0_catalog(uri: str, warehouse: Path) -> 
SqlCatalog:
 
 def test_namespace_exists_on_v0_schema(warehouse: Path) -> None:
     """namespace_exists should not fail on V0 schema (no iceberg_type 
column)."""
-    catalog = _make_v0_catalog(f"sqlite:////{warehouse}/test-v0-ns-exists", 
warehouse)
+    catalog = 
_make_v0_catalog(f"sqlite:///{warehouse.as_posix()}/test-v0-ns-exists", 
warehouse)
     catalog.create_namespace("ns")
     assert catalog.namespace_exists("ns")
     assert not catalog.namespace_exists("missing")
@@ -449,7 +449,7 @@ def test_namespace_exists_on_v0_schema(warehouse: Path) -> 
None:
 
 def test_create_and_load_table_on_v0_schema(warehouse: Path) -> None:
     """create_table and load_table should work on V0 schema without 
iceberg_type column."""
-    catalog = _make_v0_catalog(f"sqlite:////{warehouse}/test-v0-create", 
warehouse)
+    catalog = 
_make_v0_catalog(f"sqlite:///{warehouse.as_posix()}/test-v0-create", warehouse)
     catalog.create_namespace("ns")
     schema = Schema(NestedField(1, "id", StringType(), required=True))
     tbl = catalog.create_table(("ns", "tbl"), schema)
diff --git a/tests/conftest.py b/tests/conftest.py
index 2b1f2366e..51a663434 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2388,7 +2388,7 @@ def empty_home_dir_path(tmp_path_factory: 
pytest.TempPathFactory) -> str:
     return home_path
 
 
-RANDOM_LENGTH = 20
+RANDOM_LENGTH = 8  # Keep short to stay within Windows MAX_PATH (260 chars)
 NUM_TABLES = 2
 
 
@@ -2445,15 +2445,15 @@ def 
hierarchical_namespace_list(hierarchical_namespace_name: str) -> list[str]:
 
 BUCKET_NAME = "test_bucket"
 TABLE_METADATA_LOCATION_REGEX = re.compile(
-    r"""s3://test_bucket/my_iceberg_database-[a-z]{20}.db/
-    my_iceberg_table-[a-z]{20}/metadata/
+    r"""s3://test_bucket/my_iceberg_database-[a-z]{8}.db/
+    my_iceberg_table-[a-z]{8}/metadata/
     
[0-9]{5}-[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}.metadata.json""",
     re.X,
 )
 
 BQ_TABLE_METADATA_LOCATION_REGEX = re.compile(
-    r"""gs://alexstephen-test-bq-bucket/my_iceberg_database_[a-z]{20}.db/
-    my_iceberg_table-[a-z]{20}/metadata/
+    r"""gs://alexstephen-test-bq-bucket/my_iceberg_database_[a-z]{8}.db/
+    my_iceberg_table-[a-z]{8}/metadata/
     
[0-9]{5}-[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}.metadata.json""",
     re.X,
 )
@@ -3138,7 +3138,7 @@ def _create_sql_without_rowcount_catalog(name: str, 
warehouse: Path) -> Catalog:
     from pyiceberg.catalog.sql import SqlCatalog
 
     props = {
-        "uri": f"sqlite:////{warehouse}/sql-catalog",
+        "uri": f"sqlite:///{warehouse.as_posix()}/sql-catalog",
         "warehouse": f"file://{warehouse}",
     }
     catalog = SqlCatalog(name, **props)
diff --git a/tests/io/test_io.py b/tests/io/test_io.py
index d9bee33f8..51aa86c67 100644
--- a/tests/io/test_io.py
+++ b/tests/io/test_io.py
@@ -17,6 +17,7 @@
 
 import os
 import pickle
+import sys
 import tempfile
 from typing import Any
 
@@ -27,6 +28,7 @@ from pyiceberg.io import (
     PY_IO_IMPL,
     _import_file_io,
     _infer_file_io_from_scheme,
+    _is_local_path,
     load_file_io,
 )
 from pyiceberg.io.pyarrow import PyArrowFileIO
@@ -47,10 +49,10 @@ def test_custom_local_input_file() -> None:
         input_file = 
PyArrowFileIO().new_input(location=f"{absolute_file_location}")
 
         # Test opening and reading the file
-        f = input_file.open()
-        data = f.read()
-        assert data == b"foo"
-        assert len(input_file) == 3
+        with input_file.open() as f:
+            data = f.read()
+            assert data == b"foo"
+            assert len(input_file) == 3
 
 
 def test_custom_local_output_file() -> None:
@@ -85,10 +87,10 @@ def test_pickled_pyarrow_round_trip() -> None:
             f.write(b"foo")
 
         input_file = 
deserialized_file_io.new_input(location=f"{absolute_file_location}")
-        f = input_file.open()
-        data = f.read()
-        assert data == b"foo"
-        assert len(input_file) == 3
+        with input_file.open() as f:
+            data = f.read()
+            assert data == b"foo"
+            assert len(input_file) == 3
         deserialized_file_io.delete(location=f"{absolute_file_location}")
 
 
@@ -339,3 +341,23 @@ 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"
+
+
[email protected](
+    "path",
+    ["s3://bucket/key", "hdfs://cluster/path", "gs://bucket/obj", "/tmp/foo", 
""],
+)
+def test_is_local_path_false_for_uris_and_posix(path: str) -> None:
+    assert _is_local_path(path) is False
+
+
[email protected](sys.platform != "win32", reason="Windows-only behavior")
[email protected]("path", [r"C:\Users\test", r"D:\data\iceberg", 
"E:/warehouse"])
+def test_is_local_path_true_on_windows(path: str) -> None:
+    assert _is_local_path(path) is True
+
+
[email protected](sys.platform != "win32", reason="Windows-only behavior")
+def test_infer_file_io_from_scheme_windows_path() -> None:
+    result = _infer_file_io_from_scheme(r"C:\Users\test\warehouse", {})
+    assert isinstance(result, PyArrowFileIO)
diff --git a/tests/io/test_pyarrow.py b/tests/io/test_pyarrow.py
index fef33a25c..b31c18949 100644
--- a/tests/io/test_pyarrow.py
+++ b/tests/io/test_pyarrow.py
@@ -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
@@ -162,14 +163,14 @@ def test_pyarrow_input_file() -> None:
         input_file = 
PyArrowFileIO().new_input(location=f"{absolute_file_location}")
 
         # Test opening and reading the file
-        r = input_file.open(seekable=False)
-        assert isinstance(r, InputStream)  # Test that the file object abides 
by the InputStream protocol
-        data = r.read()
-        assert data == b"foo"
-        assert len(input_file) == 3
-        with pytest.raises(OSError) as exc_info:
-            r.seek(0, 0)
-        assert "only valid on seekable files" in str(exc_info.value)
+        with input_file.open(seekable=False) as r:
+            assert isinstance(r, InputStream)  # Test that the file object 
abides by the InputStream protocol
+            data = r.read()
+            assert data == b"foo"
+            assert len(input_file) == 3
+            with pytest.raises(OSError) as exc_info:
+                r.seek(0, 0)
+            assert "only valid on seekable files" in str(exc_info.value)
 
 
 def test_pyarrow_input_file_seekable() -> None:
@@ -188,15 +189,15 @@ def test_pyarrow_input_file_seekable() -> None:
         input_file = 
PyArrowFileIO().new_input(location=f"{absolute_file_location}")
 
         # Test opening and reading the file
-        r = input_file.open(seekable=True)
-        assert isinstance(r, InputStream)  # Test that the file object abides 
by the InputStream protocol
-        data = r.read()
-        assert data == b"foo"
-        assert len(input_file) == 3
-        r.seek(0, 0)
-        data = r.read()
-        assert data == b"foo"
-        assert len(input_file) == 3
+        with input_file.open(seekable=True) as r:
+            assert isinstance(r, InputStream)  # Test that the file object 
abides by the InputStream protocol
+            data = r.read()
+            assert data == b"foo"
+            assert len(input_file) == 3
+            r.seek(0, 0)
+            data = r.read()
+            assert data == b"foo"
+            assert len(input_file) == 3
 
 
 def test_pyarrow_output_file() -> None:
@@ -210,9 +211,9 @@ def test_pyarrow_output_file() -> None:
         output_file = 
PyArrowFileIO().new_output(location=f"{absolute_file_location}")
 
         # Create the output file and write to it
-        f = output_file.create()
-        assert isinstance(f, OutputStream)  # Test that the file object abides 
by the OutputStream protocol
-        f.write(b"foo")
+        with output_file.create() as output_stream:
+            assert isinstance(output_stream, OutputStream)  # Test that the 
file object abides by the OutputStream protocol
+            output_stream.write(b"foo")
 
         # Confirm that bytes were written
         with open(file_location, "rb") as f:
@@ -283,7 +284,7 @@ def test_raise_on_opening_a_local_file_not_found() -> None:
         with pytest.raises(FileNotFoundError) as exc_info:
             f.open()
 
-        assert "[Errno 2] Failed to open local file" in str(exc_info.value)
+        assert "Failed to open local file" in str(exc_info.value)
 
 
 def test_raise_on_opening_an_s3_file_no_permission() -> None:
@@ -1002,7 +1003,7 @@ def _write_table_to_data_file(filepath: str, schema: 
pa.Schema, table: pa.Table)
 def file_int(schema_int: Schema, tmpdir: str) -> str:
     pyarrow_schema = schema_to_pyarrow(schema_int, metadata={ICEBERG_SCHEMA: 
bytes(schema_int.model_dump_json(), UTF8)})
     return _write_table_to_file(
-        f"file:{tmpdir}/a.parquet", pyarrow_schema, 
pa.Table.from_arrays([pa.array([0, 1, 2])], schema=pyarrow_schema)
+        f"{tmpdir}/a.parquet", pyarrow_schema, 
pa.Table.from_arrays([pa.array([0, 1, 2])], schema=pyarrow_schema)
     )
 
 
@@ -1010,7 +1011,7 @@ def file_int(schema_int: Schema, tmpdir: str) -> str:
 def file_int_str(schema_int_str: Schema, tmpdir: str) -> str:
     pyarrow_schema = schema_to_pyarrow(schema_int_str, 
metadata={ICEBERG_SCHEMA: bytes(schema_int_str.model_dump_json(), UTF8)})
     return _write_table_to_file(
-        f"file:{tmpdir}/a.parquet",
+        f"{tmpdir}/a.parquet",
         pyarrow_schema,
         pa.Table.from_arrays([pa.array([0, 1, 2]), pa.array(["0", "1", "2"])], 
schema=pyarrow_schema),
     )
@@ -1020,7 +1021,7 @@ def file_int_str(schema_int_str: Schema, tmpdir: str) -> 
str:
 def file_string(schema_str: Schema, tmpdir: str) -> str:
     pyarrow_schema = schema_to_pyarrow(schema_str, metadata={ICEBERG_SCHEMA: 
bytes(schema_str.model_dump_json(), UTF8)})
     return _write_table_to_file(
-        f"file:{tmpdir}/b.parquet", pyarrow_schema, 
pa.Table.from_arrays([pa.array(["0", "1", "2"])], schema=pyarrow_schema)
+        f"{tmpdir}/b.parquet", pyarrow_schema, 
pa.Table.from_arrays([pa.array(["0", "1", "2"])], schema=pyarrow_schema)
     )
 
 
@@ -1028,7 +1029,7 @@ def file_string(schema_str: Schema, tmpdir: str) -> str:
 def file_long(schema_long: Schema, tmpdir: str) -> str:
     pyarrow_schema = schema_to_pyarrow(schema_long, metadata={ICEBERG_SCHEMA: 
bytes(schema_long.model_dump_json(), UTF8)})
     return _write_table_to_file(
-        f"file:{tmpdir}/c.parquet", pyarrow_schema, 
pa.Table.from_arrays([pa.array([0, 1, 2])], schema=pyarrow_schema)
+        f"{tmpdir}/c.parquet", pyarrow_schema, 
pa.Table.from_arrays([pa.array([0, 1, 2])], schema=pyarrow_schema)
     )
 
 
@@ -1036,7 +1037,7 @@ def file_long(schema_long: Schema, tmpdir: str) -> str:
 def file_struct(schema_struct: Schema, tmpdir: str) -> str:
     pyarrow_schema = schema_to_pyarrow(schema_struct, 
metadata={ICEBERG_SCHEMA: bytes(schema_struct.model_dump_json(), UTF8)})
     return _write_table_to_file(
-        f"file:{tmpdir}/d.parquet",
+        f"{tmpdir}/d.parquet",
         pyarrow_schema,
         pa.Table.from_pylist(
             [
@@ -1053,7 +1054,7 @@ def file_struct(schema_struct: Schema, tmpdir: str) -> 
str:
 def file_list(schema_list: Schema, tmpdir: str) -> str:
     pyarrow_schema = schema_to_pyarrow(schema_list, metadata={ICEBERG_SCHEMA: 
bytes(schema_list.model_dump_json(), UTF8)})
     return _write_table_to_file(
-        f"file:{tmpdir}/e.parquet",
+        f"{tmpdir}/e.parquet",
         pyarrow_schema,
         pa.Table.from_pylist(
             [
@@ -1072,7 +1073,7 @@ def file_list_of_structs(schema_list_of_structs: Schema, 
tmpdir: str) -> str:
         schema_list_of_structs, metadata={ICEBERG_SCHEMA: 
bytes(schema_list_of_structs.model_dump_json(), UTF8)}
     )
     return _write_table_to_file(
-        f"file:{tmpdir}/e.parquet",
+        f"{tmpdir}/e.parquet",
         pyarrow_schema,
         pa.Table.from_pylist(
             [
@@ -1091,7 +1092,7 @@ def file_map_of_structs(schema_map_of_structs: Schema, 
tmpdir: str) -> str:
         schema_map_of_structs, metadata={ICEBERG_SCHEMA: 
bytes(schema_map_of_structs.model_dump_json(), UTF8)}
     )
     return _write_table_to_file(
-        f"file:{tmpdir}/e.parquet",
+        f"{tmpdir}/e.parquet",
         pyarrow_schema,
         pa.Table.from_pylist(
             [
@@ -1108,7 +1109,7 @@ def file_map_of_structs(schema_map_of_structs: Schema, 
tmpdir: str) -> str:
 def file_map(schema_map: Schema, tmpdir: str) -> str:
     pyarrow_schema = schema_to_pyarrow(schema_map, metadata={ICEBERG_SCHEMA: 
bytes(schema_map.model_dump_json(), UTF8)})
     return _write_table_to_file(
-        f"file:{tmpdir}/e.parquet",
+        f"{tmpdir}/e.parquet",
         pyarrow_schema,
         pa.Table.from_pylist(
             [
@@ -2322,8 +2323,19 @@ def test_parse_location() -> None:
     check_results("hdfs://127.0.0.1/root/foo.txt", "hdfs", "127.0.0.1", 
"/root/foo.txt")
     check_results("hdfs://clusterA/root/foo.txt", "hdfs", "clusterA", 
"/root/foo.txt")
 
-    check_results("/root/foo.txt", "file", "", "/root/foo.txt")
-    check_results("/root/tmp/foo.txt", "file", "", "/root/tmp/foo.txt")
+    check_results("/root/foo.txt", "file", "", 
os.path.abspath("/root/foo.txt"))
+    check_results("/root/tmp/foo.txt", "file", "", 
os.path.abspath("/root/tmp/foo.txt"))
+
+
[email protected](sys.platform != "win32", reason="Windows-only behavior")
+def test_parse_location_windows_drive_letter() -> None:
+    """Windows drive letters should be treated as local file paths, not URL 
schemes."""
+    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:
@@ -2480,6 +2492,7 @@ def 
test_bin_pack_record_batches_is_lazy(arrow_table_with_null: pa.Table) -> Non
     assert len(consumed) == arrow_table_with_null.num_rows
 
 
[email protected](sys.platform == "win32", reason="Rich renders different 
box characters on Windows terminals")
 def test_schema_mismatch_type(table_schema_simple: Schema) -> None:
     other_schema = pa.schema(
         (
@@ -2503,6 +2516,7 @@ def test_schema_mismatch_type(table_schema_simple: 
Schema) -> None:
         _check_pyarrow_schema_compatible(table_schema_simple, other_schema)
 
 
[email protected](sys.platform == "win32", reason="Rich renders different 
box characters on Windows terminals")
 def test_schema_mismatch_nullability(table_schema_simple: Schema) -> None:
     other_schema = pa.schema(
         (
@@ -2541,6 +2555,7 @@ def 
test_schema_compatible_nullability_diff(table_schema_simple: Schema) -> None
         pytest.fail("Unexpected Exception raised when calling 
`_check_pyarrow_schema_compatible`")
 
 
[email protected](sys.platform == "win32", reason="Rich renders different 
box characters on Windows terminals")
 def test_schema_mismatch_missing_field(table_schema_simple: Schema) -> None:
     other_schema = pa.schema(
         (
@@ -2583,6 +2598,7 @@ def 
test_schema_compatible_missing_nullable_field_nested(table_schema_nested: Sc
         pytest.fail("Unexpected Exception raised when calling 
`_check_pyarrow_schema_compatible`")
 
 
[email protected](sys.platform == "win32", reason="Rich renders different 
box characters on Windows terminals")
 def test_schema_mismatch_missing_required_field_nested(table_schema_nested: 
Schema) -> None:
     other_schema = table_schema_nested.as_arrow()
     other_schema = other_schema.remove(6).insert(
@@ -3485,21 +3501,21 @@ def test_parse_location_defaults() -> None:
     scheme, netloc, path = PyArrowFileIO.parse_location("/foo/bar")
     assert scheme == "file"
     assert netloc == ""
-    assert path == "/foo/bar"
+    assert path == os.path.abspath("/foo/bar")
 
     scheme, netloc, path = PyArrowFileIO.parse_location(
         "/foo/bar", properties={"DEFAULT_SCHEME": "scheme", "DEFAULT_NETLOC": 
"netloc:8000"}
     )
     assert scheme == "scheme"
     assert netloc == "netloc:8000"
-    assert path == "/foo/bar"
+    assert path == os.path.abspath("/foo/bar")
 
     scheme, netloc, path = PyArrowFileIO.parse_location(
         "/foo/bar", properties={"DEFAULT_SCHEME": "hdfs", "DEFAULT_NETLOC": 
"netloc:8000"}
     )
     assert scheme == "hdfs"
     assert netloc == "netloc:8000"
-    assert path == "/foo/bar"
+    assert path == os.path.abspath("/foo/bar")
 
 
 def test_write_and_read_orc(tmp_path: Path) -> None:
diff --git a/tests/utils/test_manifest.py b/tests/utils/test_manifest.py
index eac4d520b..18f71993a 100644
--- a/tests/utils/test_manifest.py
+++ b/tests/utils/test_manifest.py
@@ -351,7 +351,7 @@ def 
test_read_manifest_cache(generated_manifest_file_file_v2: str) -> None:
 def test_write_empty_manifest() -> None:
     io = load_file_io()
     test_schema = Schema(NestedField(1, "foo", IntegerType(), False))
-    with TemporaryDirectory() as tmpdir:
+    with TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
         tmp_avro_file = tmpdir + "/test_write_manifest.avro"
 
         with pytest.raises(ValueError, match="An empty manifest file has been 
written"):

Reply via email to