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

aglinxinyuan pushed a commit to branch release/v1.2
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/release/v1.2 by this push:
     new d70ae8c4ca fix(amber, v1.2): run Iceberg local storage on Windows in 
the Python worker (#6546)
d70ae8c4ca is described below

commit d70ae8c4ca0f4d2fc77330d60e5b4fe91d3b3b43
Author: Xinyuan Lin <[email protected]>
AuthorDate: Tue Jul 21 17:20:33 2026 -0700

    fix(amber, v1.2): run Iceberg local storage on Windows in the Python worker 
(#6546)
    
    ### What changes were proposed in this PR?
    
    Backport of apache/texera#6545 to `release/v1.2`.
    
    The Python UDF worker creates its output-port Iceberg storage through
    its own
    pyiceberg path (`iceberg_document.py`/`document_factory.py` →
    `IcebergCatalogInstance.get_instance()` → `create_postgres_catalog`),
    which the
    Scala/JVM fix in #6488 does not reach. On Windows with a
    local-filesystem
    `postgres` warehouse that path crashes:
    
    | Symptom | Cause |
    |---|---|
    | `Unrecognized filesystem type in URI: c` | a bare drive path `C:\...`
    is parsed by pyiceberg with the drive letter as a URI scheme |
    | `WinError 123` | even a `file:///C:/...` URI is rejected by the
    default pyarrow FileIO |
    
    `create_postgres_catalog` now, **only** when the warehouse carries a
    Windows drive
    letter, normalizes it to a `file:///` URI (built with
    `PureWindowsPath.as_posix()`
    — not `.as_uri()`, which would percent-encode a space to `%20` that
    fsspec would
    not decode) and selects `pyiceberg.io.fsspec.FsspecFileIO`, whose
    `LocalFileSystem`
    handles Windows drive paths. POSIX paths, colon-stripped paths
    (`C/...`), and
    remote object stores (`s3://...`) keep the plain-path / default-FileIO
    convention
    the Scala engine relies on (#4409). `create_rest_catalog` is left
    untouched
    (remote identifier resolved by the REST server, S3FileIO).
    
    ### Any related issues, documentation, discussions?
    
    Backport of #6545 (issue #6487, follow-up to #6488). Applies to
    `release/v1.2`.
    
    ### How was this PR tested?
    
    Clean cherry-pick: `release/v1.2`'s `iceberg_utils.py` and
    `test_iceberg_utils_catalog.py` are byte-identical to the fix's base on
    `main`,
    and the branch pins the same `pyiceberg==0.11.1`, so behavior matches
    the
    verified `main` change exactly. Squashed from `daa71367b5` +
    `900f2eb0f3`.
    
    On this `release/v1.2` branch: the 14 unit tests in
    `test_iceberg_utils_catalog.py` pass (OS-independent — they assert on
    the
    computed catalog props, so they run anywhere), and `ruff check` + `ruff
    format
    --check` are clean. No `LICENSE-binary` change — this is a pure-Python
    code fix
    with no new dependency.
    
    No `release/*` label is added — this PR *is* the backport.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
---
 .../python/core/storage/iceberg/iceberg_utils.py   |  82 ++++++++++-
 .../storage/iceberg/test_iceberg_utils_catalog.py  | 159 +++++++++++++++++++++
 2 files changed, 234 insertions(+), 7 deletions(-)

diff --git a/amber/src/main/python/core/storage/iceberg/iceberg_utils.py 
b/amber/src/main/python/core/storage/iceberg/iceberg_utils.py
index c66ba74ba2..88e1774b6a 100644
--- a/amber/src/main/python/core/storage/iceberg/iceberg_utils.py
+++ b/amber/src/main/python/core/storage/iceberg/iceberg_utils.py
@@ -15,6 +15,10 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import re
+from pathlib import PureWindowsPath
+from urllib.parse import urlparse
+
 import pyarrow as pa
 import pyiceberg.table
 from pyiceberg.catalog import Catalog, load_catalog
@@ -35,6 +39,58 @@ from core.models.schema.attribute_type import AttributeType, 
TO_ARROW_MAPPING
 # Suffix used to encode LARGE_BINARY fields in Iceberg (must match Scala 
IcebergUtil)
 LARGE_BINARY_FIELD_SUFFIX = "__texera_large_binary_ptr"
 
+# pyiceberg FileIO whose fsspec LocalFileSystem handles Windows drive paths,
+# which the default pyarrow FileIO cannot. See `_is_windows_local_warehouse`.
+_FSSPEC_FILE_IO = "pyiceberg.io.fsspec.FsspecFileIO"
+
+# Matches a filesystem path that starts with a Windows drive letter and a
+# separator, e.g. `C:\...` or `C:/...` (but not the drive-relative `C:foo`).
+_WINDOWS_DRIVE_PATH = re.compile(r"^[A-Za-z]:[\\/]")
+
+
+def _is_windows_local_warehouse(warehouse: str) -> bool:
+    """
+    Whether ``warehouse`` is a LOCAL filesystem path carrying a Windows drive
+    letter, either as a bare path (``C:\\...`` / ``C:/...``) or a ``file://``
+    URI (``file:///C:/...``).
+
+    Remote object stores (``s3://...``), POSIX paths, drive-less relative 
paths,
+    and colon-stripped paths (the form the Scala side registers, ``C/...``) are
+    excluded, so the default pyarrow FileIO / plain-path convention that Linux,
+    macOS, CI, and the Scala engine rely on is left untouched (see #4409).
+    """
+    if not warehouse:
+        return False
+    text = str(warehouse)
+    if _WINDOWS_DRIVE_PATH.match(text):
+        return True
+    parsed = urlparse(text)
+    if parsed.scheme.lower() == "file":  # `file` URI schemes are 
case-insensitive
+        return bool(_WINDOWS_DRIVE_PATH.match(parsed.path.lstrip("/")))
+    return False
+
+
+def _to_file_uri(warehouse: str) -> str:
+    """
+    Normalize a bare Windows drive path (``C:\\...``) to a ``file:///`` URI so
+    pyiceberg does not mis-parse the drive letter as a URI scheme (``c://`` ->
+    "Unrecognized filesystem type in URI: c"). Values that are already URIs
+    (e.g. ``file:///C:/...``) are returned unchanged.
+
+    The URI is built from ``PureWindowsPath.as_posix()`` rather than
+    ``.as_uri()`` so the path is *not* percent-encoded: the fsspec
+    ``LocalFileSystem`` behind ``FsspecFileIO`` does not URL-decode, so an
+    encoded space (``C:\\Users\\John Doe`` -> ``.../John%20Doe``) would send
+    writes to a literally ``%20``-named directory. ``PureWindowsPath`` keeps 
the
+    conversion deterministic on every host OS (the result is identical off
+    Windows).
+    """
+    text = str(warehouse)
+    if _WINDOWS_DRIVE_PATH.match(text):
+        return "file:///" + PureWindowsPath(text).as_posix()
+    return text
+
+
 # Type mappings
 _ICEBERG_TO_AMBER_TYPE_MAPPING = {
     "string": "STRING",
@@ -144,13 +200,22 @@ def create_postgres_catalog(
     :param password: the password of the postgres database.
     :return: a SQLCatalog instance.
     """
-    return SqlCatalog(
-        catalog_name,
-        **{
-            "uri": 
f"postgresql+pg8000://{username}:{password}@{uri_without_scheme}",
-            "warehouse": warehouse_path,
-        },
-    )
+    props = {
+        "uri": 
f"postgresql+pg8000://{username}:{password}@{uri_without_scheme}",
+        "warehouse": warehouse_path,
+    }
+    # On Windows with a local-filesystem warehouse, a bare drive path 
(`C:\...`)
+    # crashes pyiceberg, and even a `file:///C:/...` URI is rejected by the
+    # default pyarrow FileIO. Normalize the path to a `file:///` URI and switch
+    # to fsspec's LocalFileSystem, which handles Windows drive paths. This is
+    # the Python-worker counterpart to the Scala/JVM fix in #6488
+    # (WinutilsFreeLocalFileSystem, issue #6487). Gated to warehouses that
+    # actually carry a Windows drive letter, so remote object stores and POSIX
+    # paths keep the default FileIO / plain-path convention (see #4409).
+    if _is_windows_local_warehouse(warehouse_path):
+        props["warehouse"] = _to_file_uri(warehouse_path)
+        props["py-io-impl"] = _FSSPEC_FILE_IO
+    return SqlCatalog(catalog_name, **props)
 
 
 def create_rest_catalog(
@@ -168,6 +233,9 @@ def create_rest_catalog(
     :param rest_uri: the URI of the REST catalog endpoint.
     :return: a Catalog instance (REST catalog).
     """
+    # No Windows-local normalization here (unlike create_postgres_catalog):
+    # `warehouse_name` is a logical warehouse identifier resolved by the REST
+    # server, whose I/O goes through S3FileIO, not the local filesystem.
     return load_catalog(
         catalog_name,
         **{
diff --git 
a/amber/src/test/python/core/storage/iceberg/test_iceberg_utils_catalog.py 
b/amber/src/test/python/core/storage/iceberg/test_iceberg_utils_catalog.py
index 902829d44c..a387a51597 100644
--- a/amber/src/test/python/core/storage/iceberg/test_iceberg_utils_catalog.py
+++ b/amber/src/test/python/core/storage/iceberg/test_iceberg_utils_catalog.py
@@ -32,6 +32,10 @@ class TestCreatePostgresCatalog:
     written from Python UDFs become unreadable from the Scala/Java engine
     (and vice versa). These tests pin the Python side to the same plain-path
     convention used on the Scala side.
+
+    Windows drive-letter warehouses (e.g. ``C:\\...``) are the one documented
+    exception -- pyiceberg cannot parse them as-is -- and are covered by
+    ``TestCreatePostgresCatalogWindowsLocal`` below.
     """
 
     def test_warehouse_is_passed_without_file_scheme(self):
@@ -96,3 +100,158 @@ class TestCreatePostgresCatalog:
         )
         # And warehouse is still the plain path.
         assert kwargs["warehouse"] == warehouse_path
+
+
+# Reference the production constant (the fsspec FileIO selected for 
Windows-local
+# warehouses) rather than duplicating its literal, so the tests cannot drift 
from it.
+_FSSPEC_FILE_IO = iceberg_utils._FSSPEC_FILE_IO
+
+
+class TestCreatePostgresCatalogWindowsLocal:
+    """
+    Windows-local warehouse handling in `create_postgres_catalog`.
+
+    This is the Python-worker counterpart to the Scala/JVM fix in #6488
+    (`WinutilsFreeLocalFileSystem`, issue #6487). The Python UDF worker writes
+    its output-port Iceberg storage through its own pyiceberg path, which #6488
+    does not touch. On a Windows dev machine with a local-filesystem warehouse
+    (`postgres` catalog type), a bare drive path fails two ways:
+
+      1. `C:\\...` is mis-parsed by pyiceberg as URI scheme ``c`` -> 
"Unrecognized
+         filesystem type in URI: c" when a table is created.
+      2. Even a `file:///C:/...` URI is rejected by pyiceberg's default pyarrow
+         FileIO ("/C:/..." -> WinError 123).
+
+    The fix normalizes a drive path to a `file:///` URI and selects
+    `FsspecFileIO`, whose `LocalFileSystem` handles Windows drive paths. It is
+    gated to warehouses that actually carry a Windows drive letter, so POSIX
+    paths, colon-stripped paths, and remote object stores (`s3://...`) keep the
+    default pyarrow FileIO / plain-path convention (see the invariant pinned by
+    `TestCreatePostgresCatalog`).
+    """
+
+    def _make(self, warehouse_path):
+        with patch.object(iceberg_utils, "SqlCatalog") as mock_sql_catalog:
+            create_postgres_catalog(
+                catalog_name="texera_iceberg",
+                warehouse_path=warehouse_path,
+                uri_without_scheme="localhost:5432/texera_iceberg_catalog",
+                username="texera",
+                password="password",
+            )
+        assert mock_sql_catalog.call_count == 1
+        _, kwargs = mock_sql_catalog.call_args
+        return kwargs
+
+    def test_backslash_drive_path_is_normalized_and_uses_fsspec(self):
+        """
+        A bare Windows drive path (as produced by Java `Path.toString`, using
+        backslashes) is normalized to a `file:///` URI and `FsspecFileIO` is
+        selected.
+        """
+        kwargs = self._make("C:\\Users\\texera\\amber\\workflow-results")
+
+        assert kwargs["warehouse"] == 
"file:///C:/Users/texera/amber/workflow-results"
+        assert kwargs["py-io-impl"] == _FSSPEC_FILE_IO
+
+    def test_forward_slash_drive_path_is_normalized_and_uses_fsspec(self):
+        """A forward-slash drive path is normalized the same way."""
+        kwargs = self._make("C:/Users/texera/warehouse")
+
+        assert kwargs["warehouse"] == "file:///C:/Users/texera/warehouse"
+        assert kwargs["py-io-impl"] == _FSSPEC_FILE_IO
+
+    def test_drive_path_with_space_is_not_percent_encoded(self):
+        """
+        A warehouse path containing a space (very common on Windows -- e.g.
+        ``C:\\Users\\John Doe\\...`` or ``C:\\Program Files\\...``) must be
+        normalized with a RAW space, never ``%20``. The fsspec LocalFileSystem
+        does not URL-decode, so a percent-encoded path would send writes to a
+        literally ``%20``-named directory that the Scala engine never looks in.
+        """
+        kwargs = self._make("C:\\Users\\John Doe\\amber\\workflow-results")
+
+        assert kwargs["warehouse"] == "file:///C:/Users/John 
Doe/amber/workflow-results"
+        assert "%20" not in kwargs["warehouse"]
+        assert kwargs["py-io-impl"] == _FSSPEC_FILE_IO
+
+    def test_normalization_is_os_independent(self):
+        """
+        The drive-path normalization must be deterministic regardless of the
+        host OS (this test also runs on Linux CI), so it relies on
+        `PureWindowsPath` rather than the host's `pathlib.Path`.
+        """
+        kwargs = self._make("D:\\data\\iceberg")
+
+        assert kwargs["warehouse"] == "file:///D:/data/iceberg"
+        assert kwargs["py-io-impl"] == _FSSPEC_FILE_IO
+
+    def test_existing_file_uri_with_drive_uses_fsspec_unchanged(self):
+        """
+        A warehouse already expressed as a `file:///C:/...` URI still needs
+        `FsspecFileIO` (pyarrow FileIO rejects it), but must not be re-encoded.
+        """
+        kwargs = self._make("file:///C:/Users/texera/warehouse")
+
+        assert kwargs["warehouse"] == "file:///C:/Users/texera/warehouse"
+        assert kwargs["py-io-impl"] == _FSSPEC_FILE_IO
+
+    def test_uppercase_file_scheme_with_drive_is_detected(self):
+        """
+        `file` URI schemes are case-insensitive, so `FILE:///C:/...` must be
+        detected as Windows-local (and select `FsspecFileIO`) too.
+        """
+        kwargs = self._make("FILE:///C:/Users/texera/warehouse")
+
+        assert kwargs["warehouse"] == "FILE:///C:/Users/texera/warehouse"
+        assert kwargs["py-io-impl"] == _FSSPEC_FILE_IO
+
+    def test_posix_absolute_path_is_untouched(self):
+        """
+        A POSIX warehouse (Linux/macOS/CI) must keep the plain-path convention
+        and the default FileIO -- forcing `file://` here would reintroduce the
+        cross-runtime metadata mismatch fixed in #4409.
+        """
+        kwargs = self._make("/tmp/texera/iceberg-warehouse")
+
+        assert kwargs["warehouse"] == "/tmp/texera/iceberg-warehouse"
+        assert "py-io-impl" not in kwargs
+
+    def test_colon_stripped_path_is_untouched(self):
+        """
+        A colon-stripped path (the form the Scala side registers on Windows,
+        `C/...`) is a plain relative path pyiceberg accepts, so it is forwarded
+        verbatim with the default FileIO.
+        """
+        kwargs = self._make("C/Users/texera/iceberg-warehouse")
+
+        assert kwargs["warehouse"] == "C/Users/texera/iceberg-warehouse"
+        assert "py-io-impl" not in kwargs
+
+    def test_remote_object_store_is_untouched(self):
+        """
+        A remote object-store warehouse (`s3://...`) must not be forced onto
+        `FsspecFileIO`; pyiceberg selects the appropriate FileIO by scheme.
+        """
+        kwargs = self._make("s3://texera-bucket/iceberg-warehouse")
+
+        assert kwargs["warehouse"] == "s3://texera-bucket/iceberg-warehouse"
+        assert "py-io-impl" not in kwargs
+
+    def test_posix_file_uri_without_drive_is_untouched(self):
+        """
+        The `file://` detection branch must require a drive letter: a POSIX
+        `file:///tmp/...` URI (no drive) is handled fine by the default FileIO
+        and must not be forced onto `FsspecFileIO`.
+        """
+        kwargs = self._make("file:///tmp/texera/iceberg-warehouse")
+
+        assert kwargs["warehouse"] == "file:///tmp/texera/iceberg-warehouse"
+        assert "py-io-impl" not in kwargs
+
+    def test_empty_warehouse_is_untouched(self):
+        """An empty warehouse is treated as non-Windows-local (default 
FileIO)."""
+        kwargs = self._make("")
+
+        assert kwargs["warehouse"] == ""
+        assert "py-io-impl" not in kwargs

Reply via email to