Skip to content

Commit 2207ca6

Browse files
feat: add pandas-gbq capability helper (#17957)
Introduces `PandasGBQVersions` in `_versions_helpers.py` to support backend delegation and telemetry: 1. **Capability Detection:** Adds `PandasGBQVersions` with `installed_version`, `delegation_api_version`, and `is_delegation_supported` properties. 2. **Safe Fallback:** Handles missing, outdated, or corrupted `pandas-gbq` installations gracefully by defaulting to version `0.0.0` / delegation version `0`. 3. **Unit Tests:** Adds full unit test coverage for cached access, valid versions, uninstalled modules, and corrupted environments in `test__versions_helpers.py`. Fixes #<540939659> 🦕 --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 6167e41 commit 2207ca6

2 files changed

Lines changed: 229 additions & 26 deletions

File tree

packages/google-cloud-bigquery/google/cloud/bigquery/_versions_helpers.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,16 @@
1616
from typing import Any
1717

1818
import packaging.version
19-
2019
from google.cloud.bigquery import exceptions
2120

22-
2321
_MIN_PYARROW_VERSION = packaging.version.Version("3.0.0")
2422
_MIN_BQ_STORAGE_VERSION = packaging.version.Version("2.0.0")
2523
_BQ_STORAGE_OPTIONAL_READ_SESSION_VERSION = packaging.version.Version("2.6.0")
2624
_MIN_PANDAS_VERSION = packaging.version.Version("1.1.0")
2725

2826
_MIN_PANDAS_VERSION_RANGE = packaging.version.Version("1.5.0")
2927
_MIN_PYARROW_VERSION_RANGE = packaging.version.Version("10.0.1")
28+
_MIN_PANDAS_GBQ_DELEGATION_VERSION = packaging.version.Version("1.0.0")
3029

3130

3231
class PyarrowVersions:
@@ -247,3 +246,52 @@ def try_import(self, raise_if_error: bool = False) -> Any:
247246
and PYARROW_VERSIONS.try_import() is not None
248247
and PYARROW_VERSIONS.installed_version >= _MIN_PYARROW_VERSION_RANGE
249248
)
249+
250+
251+
class PandasGBQVersions:
252+
"""Version and delegation comparisons for pandas-gbq package."""
253+
254+
def __init__(self):
255+
self._installed_version = None
256+
self._delegation_api_version = None
257+
258+
@property
259+
def installed_version(self) -> packaging.version.Version:
260+
"""Return the parsed version of pandas-gbq."""
261+
if self._installed_version is not None:
262+
return self._installed_version
263+
264+
try:
265+
import pandas_gbq # type: ignore
266+
267+
self._installed_version = packaging.version.parse(
268+
getattr(pandas_gbq, "__version__", "0.0.0")
269+
)
270+
except Exception:
271+
self._installed_version = packaging.version.parse("0.0.0")
272+
return self._installed_version
273+
274+
@property
275+
def delegation_api_version(self) -> packaging.version.Version:
276+
"""Return the delegation API version of pandas-gbq if installed, otherwise 0.0.0."""
277+
if self._delegation_api_version is not None:
278+
return self._delegation_api_version
279+
280+
try:
281+
import pandas_gbq # type: ignore
282+
283+
raw_version = getattr(
284+
pandas_gbq, "_internal_delegation_api_version", "0.0.0"
285+
)
286+
self._delegation_api_version = packaging.version.parse(str(raw_version))
287+
except Exception:
288+
self._delegation_api_version = packaging.version.parse("0.0.0")
289+
return self._delegation_api_version
290+
291+
@property
292+
def is_delegation_supported(self) -> bool:
293+
"""True if the installed pandas-gbq version supports query delegation API."""
294+
return self.delegation_api_version >= _MIN_PANDAS_GBQ_DELEGATION_VERSION
295+
296+
297+
PANDAS_GBQ_VERSIONS = PandasGBQVersions()

packages/google-cloud-bigquery/tests/unit/test__versions_helpers.py

Lines changed: 179 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import sys
16+
import types
1517
from unittest import mock
1618

19+
import packaging.version
1720
import pytest
1821

1922
try:
@@ -31,8 +34,8 @@
3134
except ImportError:
3235
pandas = None
3336

34-
from google.cloud.bigquery import _versions_helpers
35-
from google.cloud.bigquery import exceptions
37+
from google import cloud
38+
from google.cloud.bigquery import _versions_helpers, exceptions
3639

3740

3841
@pytest.mark.skipif(pyarrow is None, reason="pyarrow is not installed")
@@ -59,20 +62,19 @@ def test_try_import_raises_error_w_legacy_pyarrow():
5962
versions.try_import(raise_if_error=True)
6063

6164

62-
@pytest.mark.skipif(
63-
pyarrow is not None,
64-
reason="pyarrow is installed, but this test needs it not to be",
65-
)
6665
def test_try_import_raises_error_w_no_pyarrow():
6766
versions = _versions_helpers.PyarrowVersions()
68-
with pytest.raises(exceptions.LegacyPyarrowError):
69-
versions.try_import(raise_if_error=True)
67+
with mock.patch.dict(sys.modules, {"pyarrow": None}):
68+
assert versions.try_import(raise_if_error=False) is None
69+
with pytest.raises(exceptions.LegacyPyarrowError):
70+
versions.try_import(raise_if_error=True)
7071

7172

7273
@pytest.mark.skipif(pyarrow is None, reason="pyarrow is not installed")
7374
def test_installed_pyarrow_version_returns_cached():
7475
versions = _versions_helpers.PyarrowVersions()
7576
versions._installed_version = object()
77+
7678
assert versions.installed_version is versions._installed_version
7779

7880

@@ -122,17 +124,27 @@ def test_returns_none_with_legacy_bqstorage():
122124
assert bq_storage is None
123125

124126

125-
@pytest.mark.skipif(
126-
bigquery_storage is not None,
127-
reason="Tests behavior when `google-cloud-bigquery-storage` isn't installed",
128-
)
129127
def test_returns_none_with_bqstorage_uninstalled():
130-
try:
131-
bqstorage_versions = _versions_helpers.BQStorageVersions()
132-
bq_storage = bqstorage_versions.try_import()
133-
except exceptions.LegacyBigQueryStorageError: # pragma: NO COVER
134-
raise ("NotFound error raised when raise_if_error == False.")
135-
assert bq_storage is None
128+
versions = _versions_helpers.BQStorageVersions()
129+
with (
130+
mock.patch.dict(sys.modules, {"google.cloud.bigquery_storage": None}),
131+
mock.patch.dict(cloud.__dict__),
132+
):
133+
cloud.__dict__.pop("bigquery_storage", None)
134+
135+
assert versions.try_import() is None
136+
137+
138+
def test_raises_error_with_bqstorage_uninstalled():
139+
versions = _versions_helpers.BQStorageVersions()
140+
with (
141+
mock.patch.dict(sys.modules, {"google.cloud.bigquery_storage": None}),
142+
mock.patch.dict(cloud.__dict__),
143+
):
144+
cloud.__dict__.pop("bigquery_storage", None)
145+
146+
with pytest.raises(exceptions.BigQueryStorageNotFoundError):
147+
versions.try_import(raise_if_error=True)
136148

137149

138150
@pytest.mark.skipif(
@@ -153,6 +165,7 @@ def test_raises_error_w_unknown_bqstorage_version():
153165
def test_installed_bqstorage_version_returns_cached():
154166
bqstorage_versions = _versions_helpers.BQStorageVersions()
155167
bqstorage_versions._installed_version = object()
168+
156169
assert bqstorage_versions.installed_version is bqstorage_versions._installed_version
157170

158171

@@ -220,20 +233,19 @@ def test_try_import_raises_error_w_legacy_pandas():
220233
versions.try_import(raise_if_error=True)
221234

222235

223-
@pytest.mark.skipif(
224-
pandas is not None,
225-
reason="pandas is installed, but this test needs it not to be",
226-
)
227236
def test_try_import_raises_error_w_no_pandas():
228237
versions = _versions_helpers.PandasVersions()
229-
with pytest.raises(exceptions.LegacyPandasError):
230-
versions.try_import(raise_if_error=True)
238+
with mock.patch.dict(sys.modules, {"pandas": None}):
239+
assert versions.try_import(raise_if_error=False) is None
240+
with pytest.raises(exceptions.LegacyPandasError):
241+
versions.try_import(raise_if_error=True)
231242

232243

233244
@pytest.mark.skipif(pandas is None, reason="pandas is not installed")
234245
def test_installed_pandas_version_returns_cached():
235246
versions = _versions_helpers.PandasVersions()
236247
versions._installed_version = object()
248+
237249
assert versions.installed_version is versions._installed_version
238250

239251

@@ -246,3 +258,146 @@ def test_installed_pandas_version_returns_parsed_version():
246258
assert version.major == 1
247259
assert version.minor == 1
248260
assert version.micro == 0
261+
262+
263+
def test_installed_pandas_gbq_version_returns_cached():
264+
versions = _versions_helpers.PandasGBQVersions()
265+
versions._installed_version = object()
266+
267+
assert versions.installed_version is versions._installed_version
268+
269+
270+
def test_installed_pandas_gbq_version_returns_parsed_version():
271+
mock_pandas_gbq = mock.Mock()
272+
mock_pandas_gbq.__version__ = "1.2.3"
273+
versions = _versions_helpers.PandasGBQVersions()
274+
with mock.patch.dict(sys.modules, {"pandas_gbq": mock_pandas_gbq}):
275+
version = versions.installed_version
276+
277+
assert version.major == 1
278+
assert version.minor == 2
279+
assert version.micro == 3
280+
281+
282+
def test_installed_pandas_gbq_version_falls_back_on_import_error():
283+
versions = _versions_helpers.PandasGBQVersions()
284+
with mock.patch.dict(sys.modules, {"pandas_gbq": None}):
285+
version = versions.installed_version
286+
287+
assert version.major == 0
288+
assert version.minor == 0
289+
assert version.micro == 0
290+
291+
292+
def test_installed_pandas_gbq_version_falls_back_on_missing_version_attribute():
293+
mock_pandas_gbq = types.ModuleType("pandas_gbq")
294+
versions = _versions_helpers.PandasGBQVersions()
295+
with mock.patch.dict(sys.modules, {"pandas_gbq": mock_pandas_gbq}):
296+
version = versions.installed_version
297+
298+
assert version.major == 0
299+
assert version.minor == 0
300+
assert version.micro == 0
301+
302+
303+
def test_installed_pandas_gbq_version_falls_back_on_other_error():
304+
class CorruptPandasGBQ:
305+
@property
306+
def __version__(self):
307+
raise TypeError("Corrupted package")
308+
309+
versions = _versions_helpers.PandasGBQVersions()
310+
with mock.patch.dict(sys.modules, {"pandas_gbq": CorruptPandasGBQ()}):
311+
version = versions.installed_version
312+
313+
assert version.major == 0
314+
assert version.minor == 0
315+
assert version.micro == 0
316+
317+
318+
def test_pandas_gbq_delegation_api_version_returns_cached():
319+
versions = _versions_helpers.PandasGBQVersions()
320+
versions._delegation_api_version = object()
321+
322+
assert versions.delegation_api_version is versions._delegation_api_version
323+
324+
325+
def test_pandas_gbq_delegation_api_version_returns_value():
326+
mock_pandas_gbq = mock.Mock()
327+
mock_pandas_gbq._internal_delegation_api_version = "1.2.3"
328+
versions = _versions_helpers.PandasGBQVersions()
329+
with mock.patch.dict(sys.modules, {"pandas_gbq": mock_pandas_gbq}):
330+
version = versions.delegation_api_version
331+
332+
assert version.major == 1
333+
assert version.minor == 2
334+
assert version.micro == 3
335+
336+
337+
def test_pandas_gbq_delegation_api_version_supports_integer_value():
338+
mock_pandas_gbq = mock.Mock()
339+
mock_pandas_gbq._internal_delegation_api_version = 1
340+
versions = _versions_helpers.PandasGBQVersions()
341+
with mock.patch.dict(sys.modules, {"pandas_gbq": mock_pandas_gbq}):
342+
version = versions.delegation_api_version
343+
344+
assert version.major == 1
345+
assert version.minor == 0
346+
assert version.micro == 0
347+
348+
349+
def test_pandas_gbq_delegation_api_version_falls_back_on_missing_attribute():
350+
mock_pandas_gbq = types.ModuleType("pandas_gbq")
351+
versions = _versions_helpers.PandasGBQVersions()
352+
with mock.patch.dict(sys.modules, {"pandas_gbq": mock_pandas_gbq}):
353+
version = versions.delegation_api_version
354+
355+
assert version.major == 0
356+
assert version.minor == 0
357+
assert version.micro == 0
358+
359+
360+
def test_pandas_gbq_delegation_api_version_falls_back_on_import_error():
361+
versions = _versions_helpers.PandasGBQVersions()
362+
with mock.patch.dict(sys.modules, {"pandas_gbq": None}):
363+
version = versions.delegation_api_version
364+
365+
assert version.major == 0
366+
assert version.minor == 0
367+
assert version.micro == 0
368+
369+
370+
def test_pandas_gbq_delegation_api_version_falls_back_on_other_error():
371+
class CorruptPandasGBQ:
372+
@property
373+
def _internal_delegation_api_version(self):
374+
raise TypeError("Corrupted package")
375+
376+
versions = _versions_helpers.PandasGBQVersions()
377+
with mock.patch.dict(sys.modules, {"pandas_gbq": CorruptPandasGBQ()}):
378+
version = versions.delegation_api_version
379+
380+
assert version.major == 0
381+
assert version.minor == 0
382+
assert version.micro == 0
383+
384+
385+
def test_pandas_gbq_is_delegation_supported_true():
386+
versions = _versions_helpers.PandasGBQVersions()
387+
versions._delegation_api_version = packaging.version.Version("1.0.0")
388+
389+
assert versions.is_delegation_supported is True
390+
391+
392+
def test_pandas_gbq_is_delegation_supported_with_sub_minor_version():
393+
versions = _versions_helpers.PandasGBQVersions()
394+
versions._delegation_api_version = packaging.version.Version("1.2.0")
395+
396+
assert versions.is_delegation_supported is True
397+
398+
399+
def test_pandas_gbq_is_delegation_supported_false():
400+
versions = _versions_helpers.PandasGBQVersions()
401+
versions._delegation_api_version = packaging.version.Version("0.9.9")
402+
403+
assert versions.is_delegation_supported is False

0 commit comments

Comments
 (0)