Skip to content

Commit a050b13

Browse files
committed
Make GCS filesystem lookup lazy to match S3 behavior
FileSystems.get_filesystem("gs://...") raised immediately when the gcp extra was not installed, because gcsfilesystem.py imported gcsio (and its google-cloud-storage dependency) at module load time. When that import failed, GCSFileSystem was never registered, unlike S3FileSystem whose s3io imports boto3 lazily. Import gcsio lazily so GCSFileSystem can still be looked up without the gcp extra, deferring the dependency error to usage time (matching S3). A single _get_gcsio_module() helper raises a clear ImportError when the module is unavailable; CHUNK_SIZE, _gcsIO and report_lineage go through it. Add a regression test that simulates the missing extra in a subprocess. Fixes #37445 Signed-off-by: wilmerdooley <wilmerdooley1@gmail.com>
1 parent c31f232 commit a050b13

2 files changed

Lines changed: 90 additions & 6 deletions

File tree

sdks/python/apache_beam/io/gcp/gcsfilesystem.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,16 @@
3434
from apache_beam.io.filesystem import CompressionTypes
3535
from apache_beam.io.filesystem import FileMetadata
3636
from apache_beam.io.filesystem import FileSystem
37-
from apache_beam.io.gcp import gcsio
37+
38+
try:
39+
from apache_beam.io.gcp import gcsio
40+
except ImportError:
41+
# The gcp extra (google-cloud-storage and friends, imported by gcsio) may not
42+
# be installed. Import it lazily so GCSFileSystem can still be looked up via
43+
# FileSystems.get_filesystem() without the gcp extra installed; the dependency
44+
# is only required when the filesystem is actually used, matching the behavior
45+
# of S3FileSystem. See https://github.com/apache/beam/issues/37445.
46+
gcsio = None # type: ignore[assignment]
3847

3948
__all__ = ['GCSFileSystem']
4049

@@ -43,13 +52,32 @@ class GCSFileSystem(FileSystem):
4352
"""A GCS ``FileSystem`` implementation for accessing files on GCS.
4453
"""
4554

46-
CHUNK_SIZE = gcsio.MAX_BATCH_OPERATION_SIZE # Chuck size in batch operations
4755
GCS_PREFIX = 'gs://'
4856

4957
def __init__(self, pipeline_options):
5058
super().__init__(pipeline_options)
5159
self._pipeline_options = pipeline_options
5260

61+
@staticmethod
62+
def _get_gcsio_module():
63+
"""Return the ``gcsio`` module, raising ImportError if it is unavailable.
64+
65+
``gcsio`` is imported lazily (see the module-level import) so that this
66+
filesystem can be looked up without the gcp extra installed. The dependency
67+
is only required when the filesystem is actually used.
68+
"""
69+
if gcsio is None:
70+
raise ImportError(
71+
'Could not import apache_beam.io.gcp.gcsio. This usually means the '
72+
'gcp dependencies are not installed. Install them with: '
73+
'pip install apache-beam[gcp]')
74+
return gcsio
75+
76+
@property
77+
def CHUNK_SIZE(self):
78+
"""Chunk size in batch operations."""
79+
return self._get_gcsio_module().MAX_BATCH_OPERATION_SIZE
80+
5381
@classmethod
5482
def scheme(cls):
5583
"""URI scheme for the FileSystem
@@ -139,7 +167,8 @@ def _list(self, dir_or_prefix):
139167
raise BeamIOError("List operation failed", {dir_or_prefix: e})
140168

141169
def _gcsIO(self):
142-
return gcsio.GcsIO(pipeline_options=self._pipeline_options)
170+
return self._get_gcsio_module().GcsIO(
171+
pipeline_options=self._pipeline_options)
143172

144173
def _path_open(
145174
self,
@@ -370,8 +399,9 @@ def delete(self, paths):
370399

371400
def report_lineage(self, path, lineage):
372401
try:
373-
components = gcsio.parse_gcs_path(path, object_optional=True)
374-
except ValueError:
402+
components = self._get_gcsio_module().parse_gcs_path(
403+
path, object_optional=True)
404+
except (ImportError, ValueError):
375405
# report lineage is fail-safe
376406
traceback.print_exc()
377407
return

sdks/python/apache_beam/io/gcp/gcsfilesystem_test.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
# pytype: skip-file
2222

2323
import logging
24+
import subprocess
25+
import sys
26+
import textwrap
2427
import unittest
2528

2629
import mock
@@ -38,7 +41,58 @@
3841
# pylint: enable=wrong-import-order, wrong-import-position
3942

4043

41-
@unittest.skipIf(gcsfilesystem is None, 'GCP dependencies are not installed')
44+
@unittest.skipIf(gcsfilesystem is None, 'GCSFileSystem could not be imported')
45+
class GCSFileSystemLazyImportTest(unittest.TestCase):
46+
def test_get_filesystem_without_gcp_extra(self):
47+
# Regression test for https://github.com/apache/beam/issues/37445.
48+
#
49+
# Without the gcp extra installed, gcsio cannot be imported. GCSFileSystem
50+
# must still import and register so FileSystems.get_filesystem('gs://...')
51+
# returns it (like S3FileSystem), deferring the dependency error to usage.
52+
#
53+
# This runs in a subprocess because the behavior is decided at import time
54+
# and this test environment has the gcp extra installed; we simulate its
55+
# absence by blocking the gcsio import in a fresh interpreter (reloading in
56+
# process would leave a second GCSFileSystem registered for the gs scheme).
57+
script = textwrap.dedent(
58+
"""
59+
import sys
60+
# Simulate the gcp extra not being installed.
61+
sys.modules['apache_beam.io.gcp.gcsio'] = None
62+
63+
from apache_beam.io.gcp import gcsfilesystem
64+
assert gcsfilesystem.gcsio is None, 'expected gcsio to be unavailable'
65+
66+
from apache_beam.io.filesystems import FileSystems
67+
fs = FileSystems.get_filesystem('gs://bucket/object')
68+
assert type(fs).__name__ == 'GCSFileSystem', type(fs).__name__
69+
assert fs.scheme() == 'gs'
70+
71+
# Using the filesystem raises a clear ImportError (deferred validation).
72+
for use in (lambda: fs.CHUNK_SIZE, fs._gcsIO):
73+
try:
74+
use()
75+
except ImportError:
76+
pass
77+
else:
78+
raise AssertionError('expected ImportError using GCS without gcp')
79+
print('OK')
80+
""")
81+
result = subprocess.run([sys.executable, '-c', script],
82+
capture_output=True,
83+
text=True,
84+
check=False)
85+
self.assertEqual(
86+
result.returncode,
87+
0,
88+
msg='subprocess failed:\nstdout=%s\nstderr=%s' %
89+
(result.stdout, result.stderr))
90+
self.assertIn('OK', result.stdout)
91+
92+
93+
@unittest.skipIf(
94+
gcsfilesystem is None or gcsfilesystem.gcsio is None,
95+
'GCP dependencies are not installed')
4296
class GCSFileSystemTest(unittest.TestCase):
4397
def setUp(self):
4498
pipeline_options = PipelineOptions()

0 commit comments

Comments
 (0)