diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index cc805b8d7..06da2bb00 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -16,7 +16,7 @@ import warnings import sys -from typing import Optional +from typing import Optional, Tuple from collections import namedtuple @@ -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. @@ -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" + + 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) @@ -132,10 +153,14 @@ 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): + + if dependency_version.version < parse_version_to_tuple( + minimum_fully_supported_version + ): ( dependency_package, dependency_distribution_package, diff --git a/noxfile.py b/noxfile.py index ac21330ef..04347a4f3 100644 --- a/noxfile.py +++ b/noxfile.py @@ -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", ) diff --git a/pyproject.toml b/pyproject.toml index 71ce72245..0132afe05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/testing/constraints-3.7.txt b/testing/constraints-3.7.txt index 4ce1c8999..1a9b85d12 100644 --- a/testing/constraints-3.7.txt +++ b/testing/constraints-3.7.txt @@ -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 diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index 569903658..6a93e7154 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -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, @@ -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, "--") @@ -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 @@ -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") @@ -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(