Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 46 additions & 20 deletions google/api_core/_python_package_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import warnings
import sys
from typing import Optional
from typing import Optional, Tuple

from collections import namedtuple

Expand All @@ -25,7 +25,14 @@
_get_distribution_and_import_packages,
)

from packaging.version import parse as parse_version
if sys.version_info >= (3, 8):
from importlib import metadata
else:
# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
# this code path once we drop support for Python 3.7
import importlib_metadata as metadata

ParsedVersion = Tuple[int, ...]

# Here we list all the packages for which we want to issue warnings
# about deprecated and unsupported versions.
Expand All @@ -48,42 +55,56 @@
UNKNOWN_VERSION_STRING = "--"


def _parse_version_to_tuple(version_string: str) -> ParsedVersion:
"""Safely converts a semantic version string to a comparable tuple of integers.

Example: "4.25.8" -> (4, 25, 8)
Ignores non-numeric parts and handles common version formats.

Args:
version_string: Version string in the format "x.y.z" or "x.y.z<suffix>"

Returns:
Tuple of integers for the parsed version string.
"""
parts = []
for part in version_string.split("."):
try:
parts.append(int(part))
except ValueError:
# If it's a non-numeric part (e.g., '1.0.0b1' -> 'b1'), stop here.
# This is a simplification compared to 'packaging.parse_version', but sufficient
# for comparing strictly numeric semantic versions.
break
return tuple(parts)


def get_dependency_version(
dependency_name: str,
) -> DependencyVersion:
"""Get the parsed version of an installed package dependency.

This function checks for an installed package and returns its version
as a `packaging.version.Version` object for safe comparison. It handles
as a comparable tuple of integers object for safe comparison. It handles
both modern (Python 3.8+) and legacy (Python 3.7) environments.

Args:
dependency_name: The distribution name of the package (e.g., 'requests').

Returns:
A DependencyVersion namedtuple with `version` and
A DependencyVersion namedtuple with `version` (a tuple of integers) and
`version_string` attributes, or `DependencyVersion(None,
UNKNOWN_VERSION_STRING)` if the package is not found or
another error occurs during version discovery.

"""
try:
if sys.version_info >= (3, 8):
from importlib import metadata

version_string = metadata.version(dependency_name)
return DependencyVersion(parse_version(version_string), version_string)

# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
# this code path once we drop support for Python 3.7
else: # pragma: NO COVER
# Use pkg_resources, which is part of setuptools.
import pkg_resources

version_string = pkg_resources.get_distribution(dependency_name).version
return DependencyVersion(parse_version(version_string), version_string)

version_string: str = metadata.version(dependency_name)
parsed_version = _parse_version_to_tuple(version_string)
return DependencyVersion(parsed_version, version_string)
except Exception:
# Catch exceptions from metadata.version() (e.g., PackageNotFoundError)
# or errors during _parse_version_to_tuple
return DependencyVersion(None, UNKNOWN_VERSION_STRING)


Expand Down Expand Up @@ -132,10 +153,15 @@ def warn_deprecation_for_versions_less_than(
or not minimum_fully_supported_version
): # pragma: NO COVER
return

dependency_version = get_dependency_version(dependency_import_package)
if not dependency_version.version:
return
if dependency_version.version < parse_version(minimum_fully_supported_version):
# Parse the minimum required version using the new custom function
minimum_version_tuple = _parse_version_to_tuple(minimum_fully_supported_version)

# Compare the version tuples directly
if dependency_version.version < minimum_version_tuple:
(
dependency_package,
dependency_distribution_package,
Expand Down
1 change: 1 addition & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ def default(session, install_grpc=True, prerelease=False, install_async_rest=Fal
"mock; python_version=='3.7'",
"pytest",
"pytest-cov",
"pytest-mock",
"pytest-xdist",
)

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ dependencies = [
"proto-plus >= 1.25.0, < 2.0.0; python_version >= '3.13'",
"google-auth >= 2.14.1, < 3.0.0",
"requests >= 2.18.0, < 3.0.0",
# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
# `importlib_metadata` once we drop support for Python 3.7
"importlib_metadata>=1.4; python_version<'3.8'",
]
dynamic = ["version"]

Expand Down
1 change: 1 addition & 0 deletions testing/constraints-3.7.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ grpcio==1.33.2
grpcio-status==1.33.2
grpcio-gcp==0.2.2
proto-plus==1.22.3
importlib_metadata==1.4
63 changes: 28 additions & 35 deletions tests/unit/test_python_package_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@

import sys
import warnings
from unittest.mock import patch, MagicMock
from unittest.mock import patch

import pytest
from packaging.version import parse as parse_version

from google.api_core._python_package_support import (
_parse_version_to_tuple,
get_dependency_version,
warn_deprecation_for_versions_less_than,
check_dependency_versions,
Expand All @@ -28,39 +28,28 @@
)


# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
# this mark once we drop support for Python 3.7
@pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python3.8 or higher")
@patch("importlib.metadata.version")
def test_get_dependency_version_py38_plus(mock_version):
"""Test get_dependency_version on Python 3.8+."""
mock_version.return_value = "1.2.3"
expected = DependencyVersion(parse_version("1.2.3"), "1.2.3")
@pytest.mark.parametrize("version_string_to_test", ["1.2.3", "1.2.3b1"])
def test_get_dependency_version(mocker, version_string_to_test):
"""Test get_dependency_version."""
if sys.version_info >= (3, 8):
mock_importlib = mocker.patch(
"importlib.metadata.version", return_value=version_string_to_test
)
else:
# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
# `importlib_metadata` once we drop support for Python 3.7
mock_importlib = mocker.patch(
"importlib_metadata.version", return_value=version_string_to_test
)
expected = DependencyVersion(
_parse_version_to_tuple(version_string_to_test), version_string_to_test
)
assert get_dependency_version("some-package") == expected
mock_version.assert_called_once_with("some-package")

# Test package not found
mock_version.side_effect = ImportError
assert get_dependency_version("not-a-package") == DependencyVersion(None, "--")


# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
# this test function once we drop support for Python 3.7
@pytest.mark.skipif(sys.version_info >= (3, 8), reason="requires python3.7")
@patch("pkg_resources.get_distribution")
def test_get_dependency_version_py37(mock_get_distribution):
"""Test get_dependency_version on Python 3.7."""
mock_dist = MagicMock()
mock_dist.version = "4.5.6"
mock_get_distribution.return_value = mock_dist
expected = DependencyVersion(parse_version("4.5.6"), "4.5.6")
assert get_dependency_version("another-package") == expected
mock_get_distribution.assert_called_once_with("another-package")
mock_importlib.assert_called_once_with("some-package")

# Test package not found
mock_get_distribution.side_effect = (
Exception # pkg_resources has its own exception types
)
mock_importlib.side_effect = ImportError
assert get_dependency_version("not-a-package") == DependencyVersion(None, "--")


Expand All @@ -74,7 +63,9 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack
("my-package (my.package)", "my-package"),
]

mock_get_version.return_value = DependencyVersion(parse_version("1.0.0"), "1.0.0")
mock_get_version.return_value = DependencyVersion(
_parse_version_to_tuple("1.0.0"), "1.0.0"
)
with pytest.warns(FutureWarning) as record:
warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0")
assert len(record) == 1
Expand All @@ -90,14 +81,14 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack
# Case 2: Installed version is equal to required, should not warn.
mock_get_packages.reset_mock()
mock_get_version.return_value = DependencyVersion(
parse_version("2.0.0"), "2.0.0"
_parse_version_to_tuple("2.0.0"), "2.0.0"
)
warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0")

# Case 3: Installed version is greater than required, should not warn.
mock_get_packages.reset_mock()
mock_get_version.return_value = DependencyVersion(
parse_version("3.0.0"), "3.0.0"
_parse_version_to_tuple("3.0.0"), "3.0.0"
)
warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0")

Expand All @@ -115,7 +106,9 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack
("dep-package (dep.package)", "dep-package"),
("my-package (my.package)", "my-package"),
]
mock_get_version.return_value = DependencyVersion(parse_version("1.0.0"), "1.0.0")
mock_get_version.return_value = DependencyVersion(
_parse_version_to_tuple("1.0.0"), "1.0.0"
)
template = "Custom warning for {dependency_package} used by {consumer_package}."
with pytest.warns(FutureWarning) as record:
warn_deprecation_for_versions_less_than(
Expand Down