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

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new bda25bd5c1 [#12211] feat(client-python): split optional storage 
dependencies (#13750)
bda25bd5c1 is described below

commit bda25bd5c16a7f3020ae848caff91b0179409507
Author: Thunderkeg <[email protected]>
AuthorDate: Fri Oct 23 08:34:48 2026 -0700

    [#12211] feat(client-python): split optional storage dependencies (#13750)
    
    ### What changes were proposed in this pull request?
    
    - Restrict the base `apache-gravitino` installation to metadata-client
    runtime dependencies.
    - Add `[gvfs]` for local GVFS use, backend extras for HDFS, S3, GCS,
    OSS, and Azure, and `[storage]` for all storage backends; share GVFS
    requirements while keeping provider dependencies isolated.
    - Load `gravitino.gvfs` only when requested and provide installation
    guidance when GVFS or a storage-provider dependency is missing.
    - Move development and test dependencies out of the base install,
    include requirement files in the source distribution, and document
    installation and migration options.
    
    ### Why are the changes needed?
    
    Ray Data applications need the official Gravitino Python client to
    resolve catalog metadata. A metadata-only base install lets applications
    co-install the client with `ray[data]` without installing unused GVFS
    backends or building a separate HTTP client. General dependency-range
    changes remain tracked by #13556.
    
    Fix: #12211
    
    ### Does this PR introduce any user-facing change?
    
    Yes. `pip install apache-gravitino` no longer installs GVFS or
    storage-provider dependencies. Install `apache-gravitino[gvfs]` for
    local paths, a backend extra such as `apache-gravitino[s3]` or
    `apache-gravitino[hdfs]` for remote storage, or
    `apache-gravitino[storage]` for all backends. The existing `lance` extra
    remains available.
    
    ### How was this patch tested?
    
    Focused Python unit suites were run from `clients/client-python` with:
    
    ```shell
    python -m unittest discover -s tests/unittests -p 
'test_client_import_without_storage_dependencies.py' -v
    python -m unittest discover -s tests/unittests -p 'test_storage_handler.py' 
-v
    python -m unittest discover -s tests/unittests -p 
'test_package_metadata.py' -v
    python -m unittest discover -s tests/unittests -p 
'test_gravitino_client.py' -v
    ```
    
    All four focused suites passed (2 import-boundary tests, 4
    storage-handler tests, 1 package-metadata test, and 3 Gravitino client
    tests).
    
    - Black checks and `flake8 --select=F` passed for changed Python files;
    the commit's Ruff and Ruff-format hooks also passed.
    - The source distribution installed alongside `ray[data]==2.59.0` on
    Python 3.13.7; `uv pip check` passed, `ray.data` and `GravitinoClient`
    imported, and `GravitinoClient.list_catalogs()` returned the expected
    catalog from a loopback Gravitino-compatible endpoint.
    - Isolated Python 3.13 environments installed `[gvfs]`, `[hdfs]`,
    `[s3]`, `[gcs]`, `[oss]`, `[azure]`, and `[storage]` from the source
    distribution; `uv pip check`, provider imports, and safe handler checks
    passed without contacting storage services.
    - The full client test suite, Gradle tasks, documentation build, and
    live Gravitino or storage-service compatibility were not run.
    
    ---------
    
    Signed-off-by: jiangxt2 <[email protected]>
    Co-authored-by: Qi Yu <[email protected]>
---
 clients/client-python/MANIFEST.in                  |   6 +
 clients/client-python/gravitino/__init__.py        |  29 +++-
 .../gravitino/filesystem/gvfs_storage_handler.py   |  36 ++++-
 .../{MANIFEST.in => requirements-azure.txt}        |  10 +-
 clients/client-python/requirements-dev.txt         |   8 +
 .../{MANIFEST.in => requirements-gcs.txt}          |  10 +-
 .../{MANIFEST.in => requirements-gvfs.txt}         |  13 +-
 .../{MANIFEST.in => requirements-hdfs.txt}         |  10 +-
 .../{MANIFEST.in => requirements-oss.txt}          |  10 +-
 .../{MANIFEST.in => requirements-s3.txt}           |  10 +-
 clients/client-python/requirements.txt             |  14 +-
 clients/client-python/setup.py                     |  58 ++++++-
 ...t_client_import_without_storage_dependencies.py | 103 ++++++++++++
 .../tests/unittests/test_package_metadata.py       | 173 +++++++++++++++++++++
 .../tests/unittests/test_storage_handler.py        |  97 +++++++++++-
 docs/fileset-catalog-with-adls.md                  |   2 +-
 docs/fileset-catalog-with-gcs.md                   |   2 +-
 docs/fileset-catalog-with-oss.md                   |   2 +-
 docs/fileset-catalog-with-s3.md                    |   2 +-
 docs/how-to-use-gvfs.md                            |  13 +-
 docs/how-to-use-python-client.md                   |  24 ++-
 21 files changed, 544 insertions(+), 88 deletions(-)

diff --git a/clients/client-python/MANIFEST.in 
b/clients/client-python/MANIFEST.in
index f75d13e184..22762bc8e1 100644
--- a/clients/client-python/MANIFEST.in
+++ b/clients/client-python/MANIFEST.in
@@ -18,7 +18,13 @@
 
 include requirements.txt
 include requirements-dev.txt
+include requirements-gvfs.txt
+include requirements-hdfs.txt
 include requirements-lance.txt
+include requirements-s3.txt
+include requirements-gcs.txt
+include requirements-oss.txt
+include requirements-azure.txt
 include README.md
 include LICENSE
 include NOTICE
diff --git a/clients/client-python/gravitino/__init__.py 
b/clients/client-python/gravitino/__init__.py
index 63ccaacf73..55970fb9a7 100644
--- a/clients/client-python/gravitino/__init__.py
+++ b/clients/client-python/gravitino/__init__.py
@@ -15,6 +15,9 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import importlib
+from typing import TYPE_CHECKING
+
 from gravitino.api.catalog import Catalog
 from gravitino.api.schema import Schema
 from gravitino.api.file.fileset import Fileset
@@ -25,7 +28,31 @@ from gravitino.client.gravitino_client import GravitinoClient
 from gravitino.client.gravitino_admin_client import GravitinoAdminClient
 from gravitino.client.gravitino_metalake import GravitinoMetalake
 from gravitino.name_identifier import NameIdentifier
-from gravitino.filesystem import gvfs
+
+if TYPE_CHECKING:
+    from gravitino.filesystem import gvfs
+
+
+def __getattr__(name):
+    """Load the optional GVFS module only when a caller requests it."""
+    if name != "gvfs":
+        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+    try:
+        gvfs = importlib.import_module("gravitino.filesystem.gvfs")
+    except ModuleNotFoundError as error:
+        if error.name not in {"fsspec", "cachetools", "readerwriterlock"}:
+            raise
+        raise ImportError(
+            "GVFS support requires optional filesystem dependencies. "
+            "Install `apache-gravitino[gvfs]` for local files, a backend extra 
"
+            "such as `apache-gravitino[hdfs]`, or `apache-gravitino[storage]` "
+            "for all backends."
+        ) from error
+
+    globals()[name] = gvfs
+    return gvfs
+
 
 __all__ = [
     "Catalog",
diff --git a/clients/client-python/gravitino/filesystem/gvfs_storage_handler.py 
b/clients/client-python/gravitino/filesystem/gvfs_storage_handler.py
index 22abc27aa9..1fdbe317f0 100644
--- a/clients/client-python/gravitino/filesystem/gvfs_storage_handler.py
+++ b/clients/client-python/gravitino/filesystem/gvfs_storage_handler.py
@@ -55,6 +55,21 @@ SLASH = "/"
 logger = logging.getLogger(__name__)
 
 
+def _import_optional_storage_module(module_name: str, extra_name: str):
+    """Import a provider dependency with an actionable optional-extra error."""
+    try:
+        return importlib.import_module(module_name)
+    except ModuleNotFoundError as error:
+        provider_module = module_name.split(".", maxsplit=1)[0]
+        if error.name != provider_module:
+            raise
+        raise GravitinoRuntimeException(
+            f"Storage provider dependency '{provider_module}' "
+            "is not installed. "
+            f'Install it with `pip install "apache-gravitino[{extra_name}]"`.'
+        ) from error
+
+
 class StorageType(Enum):
     HDFS = "hdfs"
     LOCAL = "file"
@@ -256,7 +271,9 @@ class HDFSStorageHandler(StorageHandler):
     def get_filesystem(
         self, actual_path: Optional[str] = None, **kwargs
     ) -> AbstractFileSystem:
-        fs_class = importlib.import_module("pyarrow.fs").HadoopFileSystem
+        fs_class = _import_optional_storage_module(
+            "pyarrow.fs", "hdfs"
+        ).HadoopFileSystem
         return ArrowFSWrapper(fs_class.from_uri(actual_path))
 
     def get_filesystem_with_expiration(
@@ -362,7 +379,8 @@ class S3StorageHandler(StorageHandler):
     def get_filesystem(
         self, actual_path: Optional[str] = None, **kwargs
     ) -> AbstractFileSystem:
-        return importlib.import_module("s3fs").S3FileSystem(**kwargs)
+        s3fs = _import_optional_storage_module("s3fs", "s3")
+        return s3fs.S3FileSystem(**kwargs)
 
     def _get_actual_prefix(
         self,
@@ -395,7 +413,8 @@ class GCSStorageHandler(StorageHandler):
     def get_filesystem(
         self, actual_path: Optional[str] = None, **kwargs
     ) -> AbstractFileSystem:
-        return importlib.import_module("gcsfs").GCSFileSystem(**kwargs)
+        gcsfs = _import_optional_storage_module("gcsfs", "gcs")
+        return gcsfs.GCSFileSystem(**kwargs)
 
     def get_filesystem_with_expiration(
         self,
@@ -415,7 +434,8 @@ class GCSStorageHandler(StorageHandler):
                     if not GOOGLE_AUTH_AVAILABLE:
                         raise GravitinoRuntimeException(
                             "Failed to import google.auth. "
-                            "Please ensure google-auth is installed."
+                            "Install the GCS extra with "
+                            '`pip install "apache-gravitino[gcs]"`.'
                         )
 
                     # gcsfs expects a google.auth.credentials.Credentials 
object
@@ -509,7 +529,8 @@ class OSSStorageHandler(StorageHandler):
     def get_filesystem(
         self, actual_path: Optional[str] = None, **kwargs
     ) -> AbstractFileSystem:
-        return importlib.import_module("ossfs").OSSFileSystem(**kwargs)
+        ossfs = _import_optional_storage_module("ossfs", "oss")
+        return ossfs.OSSFileSystem(**kwargs)
 
     def get_filesystem_with_expiration(
         self,
@@ -568,7 +589,7 @@ class OSSStorageHandler(StorageHandler):
 
         return (
             TIME_WITHOUT_EXPIRATION,
-            importlib.import_module("ossfs").OSSFileSystem(
+            self.get_filesystem(
                 key=oss_access_key_id,
                 secret=oss_secret_access_key,
                 endpoint=oss_endpoint,
@@ -613,7 +634,8 @@ class ABSStorageHandler(StorageHandler):
     def get_filesystem(
         self, actual_path: Optional[str] = None, **kwargs
     ) -> AbstractFileSystem:
-        return importlib.import_module("adlfs").AzureBlobFileSystem(**kwargs)
+        adlfs = _import_optional_storage_module("adlfs", "azure")
+        return adlfs.AzureBlobFileSystem(**kwargs)
 
     def get_filesystem_with_expiration(
         self,
diff --git a/clients/client-python/MANIFEST.in 
b/clients/client-python/requirements-azure.txt
similarity index 81%
copy from clients/client-python/MANIFEST.in
copy to clients/client-python/requirements-azure.txt
index f75d13e184..fc1fb1561c 100644
--- a/clients/client-python/MANIFEST.in
+++ b/clients/client-python/requirements-azure.txt
@@ -15,12 +15,4 @@
 # specific language governing permissions and limitations
 # under the License.
 
-
-include requirements.txt
-include requirements-dev.txt
-include requirements-lance.txt
-include README.md
-include LICENSE
-include NOTICE
-include gravitino/version.ini
-graft licenses
+adlfs==2026.2.0
diff --git a/clients/client-python/requirements-dev.txt 
b/clients/client-python/requirements-dev.txt
index 8d13479d0a..852d7d301c 100644
--- a/clients/client-python/requirements-dev.txt
+++ b/clients/client-python/requirements-dev.txt
@@ -17,12 +17,20 @@
 
 requests==2.32.5
 dataclasses-json==0.6.7
+simplejson==3.20.1
 pylint==4.0.5
 black==26.3.1
+flake8==7.0.0
+pre-commit==3.5.0
 twine==6.2.0
 coverage==7.14.3
 pandas==2.3.3
 pyarrow==23.0.1
+fsspec==2026.2.0
+gcsfs==2026.2.0
+s3fs==2026.2.0
+ossfs==2025.5.0
+adlfs==2026.2.0
 llama-index==0.13.0
 tenacity==9.1.4
 cachetools==7.1.4
diff --git a/clients/client-python/MANIFEST.in 
b/clients/client-python/requirements-gcs.txt
similarity index 81%
copy from clients/client-python/MANIFEST.in
copy to clients/client-python/requirements-gcs.txt
index f75d13e184..d5ad973db7 100644
--- a/clients/client-python/MANIFEST.in
+++ b/clients/client-python/requirements-gcs.txt
@@ -15,12 +15,4 @@
 # specific language governing permissions and limitations
 # under the License.
 
-
-include requirements.txt
-include requirements-dev.txt
-include requirements-lance.txt
-include README.md
-include LICENSE
-include NOTICE
-include gravitino/version.ini
-graft licenses
+gcsfs==2026.2.0
diff --git a/clients/client-python/MANIFEST.in 
b/clients/client-python/requirements-gvfs.txt
similarity index 81%
copy from clients/client-python/MANIFEST.in
copy to clients/client-python/requirements-gvfs.txt
index f75d13e184..d6cac76aa4 100644
--- a/clients/client-python/MANIFEST.in
+++ b/clients/client-python/requirements-gvfs.txt
@@ -15,12 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-
-include requirements.txt
-include requirements-dev.txt
-include requirements-lance.txt
-include README.md
-include LICENSE
-include NOTICE
-include gravitino/version.ini
-graft licenses
+# Shared runtime dependencies for the Gravitino virtual filesystem.
+cachetools==7.1.4
+fsspec==2026.2.0
+readerwriterlock==1.0.9
diff --git a/clients/client-python/MANIFEST.in 
b/clients/client-python/requirements-hdfs.txt
similarity index 81%
copy from clients/client-python/MANIFEST.in
copy to clients/client-python/requirements-hdfs.txt
index f75d13e184..ac734a8cca 100644
--- a/clients/client-python/MANIFEST.in
+++ b/clients/client-python/requirements-hdfs.txt
@@ -15,12 +15,4 @@
 # specific language governing permissions and limitations
 # under the License.
 
-
-include requirements.txt
-include requirements-dev.txt
-include requirements-lance.txt
-include README.md
-include LICENSE
-include NOTICE
-include gravitino/version.ini
-graft licenses
+pyarrow==23.0.1
diff --git a/clients/client-python/MANIFEST.in 
b/clients/client-python/requirements-oss.txt
similarity index 81%
copy from clients/client-python/MANIFEST.in
copy to clients/client-python/requirements-oss.txt
index f75d13e184..08824ddf18 100644
--- a/clients/client-python/MANIFEST.in
+++ b/clients/client-python/requirements-oss.txt
@@ -15,12 +15,4 @@
 # specific language governing permissions and limitations
 # under the License.
 
-
-include requirements.txt
-include requirements-dev.txt
-include requirements-lance.txt
-include README.md
-include LICENSE
-include NOTICE
-include gravitino/version.ini
-graft licenses
+ossfs==2025.5.0
diff --git a/clients/client-python/MANIFEST.in 
b/clients/client-python/requirements-s3.txt
similarity index 81%
copy from clients/client-python/MANIFEST.in
copy to clients/client-python/requirements-s3.txt
index f75d13e184..ba3c9c935e 100644
--- a/clients/client-python/MANIFEST.in
+++ b/clients/client-python/requirements-s3.txt
@@ -15,12 +15,4 @@
 # specific language governing permissions and limitations
 # under the License.
 
-
-include requirements.txt
-include requirements-dev.txt
-include requirements-lance.txt
-include README.md
-include LICENSE
-include NOTICE
-include gravitino/version.ini
-graft licenses
+s3fs==2026.2.0
diff --git a/clients/client-python/requirements.txt 
b/clients/client-python/requirements.txt
index 8f586463d4..bc339201da 100644
--- a/clients/client-python/requirements.txt
+++ b/clients/client-python/requirements.txt
@@ -16,18 +16,6 @@
 # under the License.
 
 
-# the tools to publish the python client to Pypi
-requests==2.32.5
+# Runtime dependencies for the core metadata client.
 dataclasses-json==0.6.7
 simplejson==3.20.1
-readerwriterlock==1.0.9
-fsspec==2026.2.0
-pyarrow==23.0.1
-cachetools==7.1.4
-gcsfs==2026.2.0
-s3fs==2026.2.0
-ossfs==2025.5.0
-adlfs==2026.2.0
-black==26.3.1
-flake8==7.0.0
-pre-commit==3.5.0
diff --git a/clients/client-python/setup.py b/clients/client-python/setup.py
index 56eade07c9..7c9df24b5c 100644
--- a/clients/client-python/setup.py
+++ b/clients/client-python/setup.py
@@ -15,8 +15,57 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from pathlib import Path
+
 from setuptools import find_packages, setup
 
+CLIENT_PYTHON_ROOT = Path(__file__).resolve().parent
+
+
+def read_requirements(file_name):
+    """Read active requirement lines from a project file."""
+    requirements_path = CLIENT_PYTHON_ROOT / file_name
+    with requirements_path.open(encoding="utf-8") as requirements_file:
+        return [
+            line.strip()
+            for line in requirements_file
+            if line.strip() and not line.lstrip().startswith("#")
+        ]
+
+
+def combine_requirements(*requirement_groups):
+    """Combine requirement groups and remove duplicates without reordering."""
+    return list(
+        dict.fromkeys(
+            requirement
+            for requirement_group in requirement_groups
+            for requirement in requirement_group
+        )
+    )
+
+
+gvfs_requirements = read_requirements("requirements-gvfs.txt")
+provider_requirement_files = {
+    "hdfs": "requirements-hdfs.txt",
+    "s3": "requirements-s3.txt",
+    "gcs": "requirements-gcs.txt",
+    "oss": "requirements-oss.txt",
+    "azure": "requirements-azure.txt",
+}
+provider_requirements = {
+    extra_name: combine_requirements(
+        gvfs_requirements, read_requirements(requirements_file)
+    )
+    for extra_name, requirements_file in provider_requirement_files.items()
+}
+storage_requirements = combine_requirements(
+    gvfs_requirements,
+    *(
+        read_requirements(file_name)
+        for file_name in provider_requirement_files.values()
+    ),
+)
+
 
 try:
     with open("README.md") as f:
@@ -53,10 +102,13 @@ setup(
         "Programming Language :: Python :: 3.11",
         "Programming Language :: Python :: 3.12",
     ],
-    install_requires=open("requirements.txt").read(),
+    install_requires=read_requirements("requirements.txt"),
     extras_require={
-        "dev": open("requirements-dev.txt").read(),
-        "lance": open("requirements-lance.txt").read(),
+        "dev": read_requirements("requirements-dev.txt"),
+        "lance": read_requirements("requirements-lance.txt"),
+        "gvfs": gvfs_requirements,
+        **provider_requirements,
+        "storage": storage_requirements,
     },
     include_package_data=True,
 )
diff --git 
a/clients/client-python/tests/unittests/test_client_import_without_storage_dependencies.py
 
b/clients/client-python/tests/unittests/test_client_import_without_storage_dependencies.py
new file mode 100644
index 0000000000..e7d431df22
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/test_client_import_without_storage_dependencies.py
@@ -0,0 +1,103 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#  http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+import subprocess
+import sys
+import unittest
+from pathlib import Path
+
+
+class TestClientImportWithoutStorageDependencies(unittest.TestCase):
+    """Verify core imports do not eagerly load optional GVFS packages."""
+
+    def setUp(self):
+        self.client_root = Path(__file__).resolve().parents[2]
+
+    def test_metadata_client_import_does_not_load_storage_packages(self):
+        script = r"""
+import builtins
+
+blocked = {
+    "cachetools",
+    "fsspec",
+    "gcsfs",
+    "adlfs",
+    "ossfs",
+    "pyarrow",
+    "readerwriterlock",
+    "s3fs",
+}
+original_import = builtins.__import__
+
+def import_without_storage(
+    name, globals=None, locals=None, fromlist=(), level=0
+):
+    if name.split(".", maxsplit=1)[0] in blocked:
+        missing_module = name.split(".", maxsplit=1)[0]
+        raise ModuleNotFoundError(
+            f"No module named '{name}'", name=missing_module
+        )
+    return original_import(name, globals, locals, fromlist, level)
+
+builtins.__import__ = import_without_storage
+from gravitino import GravitinoClient
+assert GravitinoClient.__name__ == "GravitinoClient"
+"""
+        result = subprocess.run(
+            [sys.executable, "-c", script],
+            cwd=self.client_root,
+            env=os.environ.copy(),
+            capture_output=True,
+            check=False,
+            text=True,
+        )
+
+        self.assertEqual(result.returncode, 0, result.stderr)
+
+    def test_gvfs_access_without_common_extra_has_install_guidance(self):
+        script = r"""
+import builtins
+
+original_import = builtins.__import__
+
+def import_without_fsspec(
+    name, globals=None, locals=None, fromlist=(), level=0
+):
+    if name.split(".", maxsplit=1)[0] == "fsspec":
+        raise ModuleNotFoundError("No module named 'fsspec'", name="fsspec")
+    return original_import(name, globals, locals, fromlist, level)
+
+builtins.__import__ = import_without_fsspec
+try:
+    from gravitino import gvfs
+except ImportError as error:
+    assert "apache-gravitino[gvfs]" in str(error)
+    assert "apache-gravitino[storage]" in str(error)
+else:
+    raise AssertionError("Accessing GVFS without its extra should fail")
+"""
+        result = subprocess.run(
+            [sys.executable, "-c", script],
+            cwd=self.client_root,
+            env=os.environ.copy(),
+            capture_output=True,
+            check=False,
+            text=True,
+        )
+
+        self.assertEqual(result.returncode, 0, result.stderr)
diff --git a/clients/client-python/tests/unittests/test_package_metadata.py 
b/clients/client-python/tests/unittests/test_package_metadata.py
new file mode 100644
index 0000000000..2fe22668b4
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_package_metadata.py
@@ -0,0 +1,173 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#  http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import re
+import subprocess
+import sys
+import tempfile
+import unittest
+from email.parser import Parser
+from pathlib import Path
+
+
+class TestPackageMetadata(unittest.TestCase):
+    """Verify the built package separates core and optional dependencies."""
+
+    def test_core_requirements_and_storage_extras(self):
+        metadata, source_files = self._read_package_metadata()
+
+        self._assert_core_requirements(metadata)
+        self._assert_source_manifest(source_files)
+        self._assert_storage_extras(metadata)
+
+    def _read_package_metadata(self):
+        client_root = Path(__file__).resolve().parents[2]
+        with tempfile.TemporaryDirectory() as egg_base:
+            result = subprocess.run(
+                [
+                    sys.executable,
+                    "setup.py",
+                    "egg_info",
+                    "--egg-base",
+                    egg_base,
+                ],
+                cwd=client_root,
+                capture_output=True,
+                check=False,
+                text=True,
+            )
+            self.assertEqual(
+                result.returncode,
+                0,
+                result.stdout + result.stderr,
+            )
+
+            metadata_files = list(Path(egg_base).glob("*.egg-info/PKG-INFO"))
+            self.assertEqual(len(metadata_files), 1)
+            metadata_content = metadata_files[0].read_text(encoding="utf-8")
+            metadata = Parser().parsestr(metadata_content)
+            source_files = set(
+                (metadata_files[0].parent / "SOURCES.txt")
+                .read_text(encoding="utf-8")
+                .splitlines()
+            )
+        return metadata, source_files
+
+    def _assert_core_requirements(self, metadata):
+        requirements = metadata.get_all("Requires-Dist", [])
+        base_requirements = [
+            requirement for requirement in requirements if "extra ==" not in 
requirement
+        ]
+        base_names = {
+            re.split(r"[<>=!~; ]", requirement, maxsplit=1)[0]
+            .strip()
+            .lower()
+            .replace("_", "-")
+            for requirement in base_requirements
+        }
+        self.assertIn("dataclasses-json", base_names)
+        self.assertIn("simplejson", base_names)
+        for optional_name in (
+            "black",
+            "cachetools",
+            "fsspec",
+            "gcsfs",
+            "adlfs",
+            "ossfs",
+            "pyarrow",
+            "pre-commit",
+            "readerwriterlock",
+            "requests",
+            "s3fs",
+            "flake8",
+        ):
+            with self.subTest(requirement=optional_name):
+                self.assertNotIn(optional_name, base_names)
+
+        extras = set(metadata.get_all("Provides-Extra", []))
+        self.assertTrue(
+            {
+                "dev",
+                "lance",
+                "gvfs",
+                "hdfs",
+                "s3",
+                "gcs",
+                "oss",
+                "azure",
+                "storage",
+            }.issubset(extras)
+        )
+
+    def _assert_source_manifest(self, source_files):
+        for requirements_file in (
+            "requirements.txt",
+            "requirements-dev.txt",
+            "requirements-gvfs.txt",
+            "requirements-hdfs.txt",
+            "requirements-s3.txt",
+            "requirements-gcs.txt",
+            "requirements-oss.txt",
+            "requirements-azure.txt",
+            "requirements-lance.txt",
+        ):
+            with self.subTest(source=requirements_file):
+                self.assertIn(requirements_file, source_files)
+
+    def _assert_storage_extras(self, metadata):
+        shared_gvfs_requirements = {"cachetools", "fsspec", "readerwriterlock"}
+        gvfs_requirements = self._requirement_names_for_extra(metadata, "gvfs")
+        self.assertTrue(shared_gvfs_requirements.issubset(gvfs_requirements))
+        self.assertTrue(
+            gvfs_requirements.isdisjoint({"pyarrow", "s3fs", "gcsfs", "ossfs", 
"adlfs"})
+        )
+
+        for extra_name, provider in (
+            ("hdfs", "pyarrow"),
+            ("s3", "s3fs"),
+            ("gcs", "gcsfs"),
+            ("oss", "ossfs"),
+            ("azure", "adlfs"),
+        ):
+            with self.subTest(extra=extra_name):
+                extra_requirements = self._requirement_names_for_extra(
+                    metadata, extra_name
+                )
+                
self.assertTrue(shared_gvfs_requirements.issubset(extra_requirements))
+                self.assertIn(provider, extra_requirements)
+
+        storage_requirements = self._requirement_names_for_extra(metadata, 
"storage")
+        self.assertTrue(
+            shared_gvfs_requirements.union(
+                {"pyarrow", "s3fs", "gcsfs", "ossfs", "adlfs"}
+            ).issubset(storage_requirements)
+        )
+
+    @staticmethod
+    def _requirement_names_for_extra(metadata, extra_name):
+        extra_requirements = [
+            requirement
+            for requirement in metadata.get_all("Requires-Dist", [])
+            if f'extra == "{extra_name}"' in requirement
+        ]
+        return {
+            re.split(r"[<>=!~; ]", requirement, maxsplit=1)[0]
+            .strip()
+            .lower()
+            .replace("_", "-")
+            for requirement in extra_requirements
+        }
diff --git a/clients/client-python/tests/unittests/test_storage_handler.py 
b/clients/client-python/tests/unittests/test_storage_handler.py
index 53871eb33a..3d5e04d755 100644
--- a/clients/client-python/tests/unittests/test_storage_handler.py
+++ b/clients/client-python/tests/unittests/test_storage_handler.py
@@ -15,12 +15,24 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import re
 import unittest
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
 
 from fsspec.implementations.memory import MemoryFileSystem
 
-from gravitino.filesystem.gvfs_storage_handler import S3StorageHandler
+from gravitino.exceptions.base import GravitinoRuntimeException
+from gravitino.filesystem.gvfs_storage_handler import (
+    ABSStorageHandler,
+    GCSStorageHandler,
+    HDFSStorageHandler,
+    OSSStorageHandler,
+    S3StorageHandler,
+)
+
+STORAGE_IMPORT_MODULE = (
+    "gravitino.filesystem.gvfs_storage_handler.importlib.import_module"
+)
 
 
 class TestStorageHandler(unittest.TestCase):
@@ -84,3 +96,84 @@ class TestStorageHandler(unittest.TestCase):
             self.assertEqual(captured_args["key"], "access_key_from_client")
             self.assertEqual(captured_args["secret"], "secret_key_from_client")
             self.assertEqual(captured_args["endpoint_url"], 
"endpoint_from_catalog")
+
+    def test_missing_provider_dependency_has_install_guidance(self):
+        handlers = [
+            (HDFSStorageHandler(), "pyarrow", "hdfs"),
+            (S3StorageHandler(), "s3fs", "s3"),
+            (GCSStorageHandler(), "gcsfs", "gcs"),
+            (OSSStorageHandler(), "ossfs", "oss"),
+            (ABSStorageHandler(), "adlfs", "azure"),
+        ]
+
+        for handler, missing_module, extra_name in handlers:
+            with self.subTest(extra=extra_name):
+                with patch(
+                    STORAGE_IMPORT_MODULE,
+                    side_effect=ModuleNotFoundError(
+                        f"No module named '{missing_module}'",
+                        name=missing_module,
+                    ),
+                ):
+                    with self.assertRaisesRegex(
+                        GravitinoRuntimeException,
+                        re.escape(f"apache-gravitino[{extra_name}]"),
+                    ):
+                        handler.get_filesystem("unused://path")
+
+    def test_provider_handlers_dispatch_to_their_filesystem_class(self):
+        handlers = [
+            (HDFSStorageHandler(), "pyarrow.fs", "HadoopFileSystem"),
+            (S3StorageHandler(), "s3fs", "S3FileSystem"),
+            (GCSStorageHandler(), "gcsfs", "GCSFileSystem"),
+            (OSSStorageHandler(), "ossfs", "OSSFileSystem"),
+            (ABSStorageHandler(), "adlfs", "AzureBlobFileSystem"),
+        ]
+
+        for handler, module_name, class_name in handlers:
+            with self.subTest(provider=module_name):
+                provider_module = MagicMock()
+                filesystem_class = getattr(provider_module, class_name)
+                filesystem = MagicMock()
+
+                if module_name == "pyarrow.fs":
+                    filesystem_class.from_uri.return_value = filesystem
+                    with (
+                        patch(
+                            
"gravitino.filesystem.gvfs_storage_handler.ArrowFSWrapper",
+                            return_value=filesystem,
+                        ) as arrow_wrapper,
+                        patch(
+                            STORAGE_IMPORT_MODULE, return_value=provider_module
+                        ) as import_module,
+                    ):
+                        result = 
handler.get_filesystem("hdfs://namenode:8020/path")
+
+                    filesystem_class.from_uri.assert_called_once_with(
+                        "hdfs://namenode:8020/path"
+                    )
+                    arrow_wrapper.assert_called_once_with(filesystem)
+                else:
+                    filesystem_class.return_value = filesystem
+                    with patch(
+                        STORAGE_IMPORT_MODULE, return_value=provider_module
+                    ) as import_module:
+                        result = handler.get_filesystem(
+                            "unused://path", test_option="value"
+                        )
+
+                    
filesystem_class.assert_called_once_with(test_option="value")
+
+                import_module.assert_called_once_with(module_name)
+                self.assertIs(result, filesystem)
+
+    def test_provider_transitive_import_error_is_preserved(self):
+        handler = S3StorageHandler()
+        with patch(
+            STORAGE_IMPORT_MODULE,
+            side_effect=ModuleNotFoundError(
+                "No module named 'botocore'", name="botocore"
+            ),
+        ):
+            with self.assertRaisesRegex(ModuleNotFoundError, "botocore"):
+                handler.get_filesystem("s3a://bucket/path")
diff --git a/docs/fileset-catalog-with-adls.md 
b/docs/fileset-catalog-with-adls.md
index 9c0a076300..b54189037d 100644
--- a/docs/fileset-catalog-with-adls.md
+++ b/docs/fileset-catalog-with-adls.md
@@ -419,7 +419,7 @@ ${HADOOP_HOME}/bin/hadoop fs -put /path/to/local/file 
gvfs://fileset/adls_catalo
 ### GVFS Python client
 
 ```bash
-pip install apache-gravitino==${GRAVITINO_VERSION}
+pip install "apache-gravitino[azure]==${GRAVITINO_VERSION}"
 ```
 
 On top of the [base GVFS configuration](./how-to-use-gvfs.md#configuration-1), 
pass the Azure Data Lake Storage
diff --git a/docs/fileset-catalog-with-gcs.md b/docs/fileset-catalog-with-gcs.md
index 68a8b41eb4..303c63372d 100644
--- a/docs/fileset-catalog-with-gcs.md
+++ b/docs/fileset-catalog-with-gcs.md
@@ -403,7 +403,7 @@ ${HADOOP_HOME}/bin/hadoop fs -put /path/to/local/file 
gvfs://fileset/gcs_catalog
 ### GVFS Python client
 
 ```bash
-pip install apache-gravitino==${GRAVITINO_VERSION}
+pip install "apache-gravitino[gcs]==${GRAVITINO_VERSION}"
 ```
 
 On top of the [base GVFS configuration](./how-to-use-gvfs.md#configuration-1), 
pass the Google Cloud Storage
diff --git a/docs/fileset-catalog-with-oss.md b/docs/fileset-catalog-with-oss.md
index c2fe45ef41..633a1f5eb3 100644
--- a/docs/fileset-catalog-with-oss.md
+++ b/docs/fileset-catalog-with-oss.md
@@ -427,7 +427,7 @@ ${HADOOP_HOME}/bin/hadoop fs -put /path/to/local/file 
gvfs://fileset/oss_catalog
 ### GVFS Python client
 
 ```bash
-pip install apache-gravitino==${GRAVITINO_VERSION}
+pip install "apache-gravitino[oss]==${GRAVITINO_VERSION}"
 ```
 
 On top of the [base GVFS configuration](./how-to-use-gvfs.md#configuration-1), 
pass the Alibaba Cloud OSS
diff --git a/docs/fileset-catalog-with-s3.md b/docs/fileset-catalog-with-s3.md
index b09fbdf2b2..21ef6f6d65 100644
--- a/docs/fileset-catalog-with-s3.md
+++ b/docs/fileset-catalog-with-s3.md
@@ -440,7 +440,7 @@ ${HADOOP_HOME}/bin/hadoop fs -put /path/to/local/file 
gvfs://fileset/s3_catalog/
 ### GVFS Python client
 
 ```bash
-pip install apache-gravitino==${GRAVITINO_VERSION}
+pip install "apache-gravitino[s3]==${GRAVITINO_VERSION}"
 ```
 
 On top of the [base GVFS configuration](./how-to-use-gvfs.md#configuration-1), 
pass the Amazon S3
diff --git a/docs/how-to-use-gvfs.md b/docs/how-to-use-gvfs.md
index 17cb874591..1b080cad1f 100644
--- a/docs/how-to-use-gvfs.md
+++ b/docs/how-to-use-gvfs.md
@@ -519,13 +519,20 @@ For fileset with multiple locations, you can specify 
which location to access us
 3. If neither is specified, the system will use the value of 
`default-location-name` from the fileset properties
 :::
 
-1. Make sure to obtain the Gravitino library.
-   You can get it by [pip](https://pip.pypa.io/en/stable/installation/):
+1. Install the Gravitino Python client with the extra for your storage backend 
using
+   [pip](https://pip.pypa.io/en/stable/installation/). The base installation 
provides only the
+   metadata client and does not install GVFS dependencies. The following HDFS 
example requires
+   the `hdfs` extra:
 
     ```shell
-    pip install apache-gravitino
+    pip install "apache-gravitino[hdfs]"
     ```
 
+   For local `file://` paths, use `apache-gravitino[gvfs]`. For cloud storage, 
choose
+   `apache-gravitino[s3]`, `apache-gravitino[gcs]`, `apache-gravitino[oss]`, or
+   `apache-gravitino[azure]` for the corresponding backend. Each backend extra 
includes the shared
+   GVFS dependencies. Use `apache-gravitino[storage]` to install all supported 
GVFS backends.
+
 2. Configuring the Hadoop environment.
    You should ensure that the Python client has Kerberos authentication 
information and
    configure Hadoop environments in the system environment:
diff --git a/docs/how-to-use-python-client.md b/docs/how-to-use-python-client.md
index ef84c623c8..c62e9a1071 100644
--- a/docs/how-to-use-python-client.md
+++ b/docs/how-to-use-python-client.md
@@ -29,6 +29,28 @@ install it in your local.
 pip install apache-gravitino
 ```
 
+The base installation provides the Gravitino metadata client. For GVFS with 
local `file://` paths
+and no remote backend, install only the shared GVFS dependencies:
+
+```shell
+pip install "apache-gravitino[gvfs]"
+```
+
+For a remote storage backend, install its extra, for example:
+
+```shell
+pip install "apache-gravitino[hdfs]"
+pip install "apache-gravitino[s3]"
+pip install "apache-gravitino[gcs]"
+pip install "apache-gravitino[oss]"
+pip install "apache-gravitino[azure]"
+```
+
+To preserve the previous installation profile that included all GVFS backends, 
install
+`apache-gravitino[storage]`. Ray Data applications that only use Gravitino for 
metadata can install
+the base Gravitino client alongside their Ray Data dependencies without 
installing these storage
+extras.
+
 1. [Manage metalake using Gravitino Python 
API](./manage-metalake-using-gravitino.md?language=python)
 2. [Manage fileset metadata using Gravitino Python 
API](./manage-fileset-metadata-using-gravitino.md?language=python)
 3. [Manage Semantic Model metadata using Gravitino Python 
API](./manage-semantic-model-metadata-using-gravitino.md?language=python)
@@ -58,7 +80,7 @@ contains the following code snippets:
 
 1. Install HDFS Python client.
 2. Create a HDFS client to connect HDFS and to do some test operations.
-3. Install Gravitino Python client.
+3. Install the Gravitino Python client with HDFS support: `pip install 
"apache-gravitino[hdfs]"`.
 4. Initialize Gravitino admin client and create a Gravitino metalake.
 5. Initialize Gravitino client and list metalakes.
 6. Create a Gravitino `Catalog` and special `type` is `Catalog.Type.FILESET` 
and `provider` is

Reply via email to