From 603d5283a0a6d5b6c62703e9d64462f8a8274a63 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 18:14:53 +0000 Subject: [PATCH 01/10] fix: remove dependency on packaging and pkg_resources --- google/api_core/_python_package_support.py | 63 +++++++++++++++------- noxfile.py | 1 + pyproject.toml | 3 ++ tests/unit/test_python_package_support.py | 60 +++++++++------------ 4 files changed, 72 insertions(+), 55 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index cc805b8d7..41e775ba5 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,53 @@ 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. + """ + # Simple split and try to convert to int. Non-numeric parts are ignored + # or will raise an exception that is handled in the caller. + 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 + version_string = 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 +150,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, 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..9ba460a6e 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.0.0; python_version<'3.8'", ] dynamic = ["version"] diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index 569903658..a9c552cfc 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,25 @@ ) -# 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") +def test_get_dependency_version(mocker): + """Test get_dependency_version.""" + if sys.version_info >= (3, 8): + mock_importlib = mocker.patch( + "importlib.metadata.version", return_value="1.2.3" + ) + 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="1.2.3" + ) + expected = DependencyVersion(_parse_version_to_tuple("1.2.3"), "1.2.3") 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 +60,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 +78,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 +103,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( From 063b9f3822b99a700e9f609e90ae06e64a8ecdef Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 18:45:12 +0000 Subject: [PATCH 02/10] add test case --- tests/unit/test_python_package_support.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index a9c552cfc..c1ada4a2d 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -28,19 +28,20 @@ ) -def test_get_dependency_version(mocker): +@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="1.2.3" + "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="1.2.3" + "importlib_metadata.version", return_value=version_string_to_test ) - expected = DependencyVersion(_parse_version_to_tuple("1.2.3"), "1.2.3") + expected = DependencyVersion(_parse_version_to_tuple(version_string_to_test), version_string_to_test) assert get_dependency_version("some-package") == expected mock_importlib.assert_called_once_with("some-package") From 0ae538390d49744647612e536de82bc666c9085c Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 18:45:36 +0000 Subject: [PATCH 03/10] lint --- tests/unit/test_python_package_support.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index c1ada4a2d..f31c11a56 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -41,7 +41,9 @@ def test_get_dependency_version(mocker, version_string_to_test): 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) + expected = DependencyVersion( + _parse_version_to_tuple(version_string_to_test), version_string_to_test + ) assert get_dependency_version("some-package") == expected mock_importlib.assert_called_once_with("some-package") From 123aa6b86116dc146d88fdccd3165959764b4ad8 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 18:51:54 +0000 Subject: [PATCH 04/10] update docstring --- google/api_core/_python_package_support.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 41e775ba5..5e51f3a25 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -60,9 +60,13 @@ def _parse_version_to_tuple(version_string: str) -> ParsedVersion: 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. """ - # Simple split and try to convert to int. Non-numeric parts are ignored - # or will raise an exception that is handled in the caller. parts = [] for part in version_string.split("."): try: From 36dc153efb16fcf985f8c68d4ff49f096e93a65f Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 19:20:32 +0000 Subject: [PATCH 05/10] add constraint for importlib_metadata --- testing/constraints-3.7.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/constraints-3.7.txt b/testing/constraints-3.7.txt index 4ce1c8999..65fa029ae 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.0.0 From 858abdc67907e95e26c7182292f3d12c710127d8 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 19:21:52 +0000 Subject: [PATCH 06/10] address feedback --- pyproject.toml | 2 +- testing/constraints-3.7.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9ba460a6e..0132afe05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "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.0.0; python_version<'3.8'", + "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 65fa029ae..1a9b85d12 100644 --- a/testing/constraints-3.7.txt +++ b/testing/constraints-3.7.txt @@ -13,4 +13,4 @@ grpcio==1.33.2 grpcio-status==1.33.2 grpcio-gcp==0.2.2 proto-plus==1.22.3 -importlib_metadata==1.0.0 +importlib_metadata==1.4 From 35eedd62abf77e95207ef65ce36185b6e8093bf8 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 19:22:34 +0000 Subject: [PATCH 07/10] address feedback --- google/api_core/_python_package_support.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 5e51f3a25..37fb104de 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -99,8 +99,7 @@ def get_dependency_version( """ try: - version_string: str - version_string = metadata.version(dependency_name) + version_string: str = metadata.version(dependency_name) parsed_version = _parse_version_to_tuple(version_string) return DependencyVersion(parsed_version, version_string) except Exception: From 22b67ecc2d10159b3ae47e3eb6669ae1231e7c77 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 20:18:43 +0000 Subject: [PATCH 08/10] remove comment --- google/api_core/_python_package_support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 37fb104de..ab63ae578 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -157,7 +157,7 @@ def warn_deprecation_for_versions_less_than( dependency_version = get_dependency_version(dependency_import_package) if not dependency_version.version: return - # 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 From c0b73f556be6f400bbe6545a6d25317b2dfb7d0e Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 20:20:30 +0000 Subject: [PATCH 09/10] address feedback --- google/api_core/_python_package_support.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index ab63ae578..bf64be806 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -158,10 +158,9 @@ def warn_deprecation_for_versions_less_than( if not dependency_version.version: return - minimum_version_tuple = _parse_version_to_tuple(minimum_fully_supported_version) - - # Compare the version tuples directly - if dependency_version.version < minimum_version_tuple: + if dependency_version.version < _parse_version_to_tuple( + minimum_fully_supported_version + ): ( dependency_package, dependency_distribution_package, From 8146629494c50b049eaf156b22bf100fa871cb31 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 20:21:20 +0000 Subject: [PATCH 10/10] address feedback --- google/api_core/_python_package_support.py | 8 ++++---- tests/unit/test_python_package_support.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index bf64be806..06da2bb00 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -55,7 +55,7 @@ UNKNOWN_VERSION_STRING = "--" -def _parse_version_to_tuple(version_string: str) -> ParsedVersion: +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) @@ -100,11 +100,11 @@ def get_dependency_version( """ try: version_string: str = metadata.version(dependency_name) - parsed_version = _parse_version_to_tuple(version_string) + 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 + # or errors during parse_version_to_tuple return DependencyVersion(None, UNKNOWN_VERSION_STRING) @@ -158,7 +158,7 @@ def warn_deprecation_for_versions_less_than( if not dependency_version.version: return - if dependency_version.version < _parse_version_to_tuple( + if dependency_version.version < parse_version_to_tuple( minimum_fully_supported_version ): ( diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index f31c11a56..6a93e7154 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -19,7 +19,7 @@ import pytest from google.api_core._python_package_support import ( - _parse_version_to_tuple, + parse_version_to_tuple, get_dependency_version, warn_deprecation_for_versions_less_than, check_dependency_versions, @@ -42,7 +42,7 @@ def test_get_dependency_version(mocker, version_string_to_test): "importlib_metadata.version", return_value=version_string_to_test ) expected = DependencyVersion( - _parse_version_to_tuple(version_string_to_test), version_string_to_test + parse_version_to_tuple(version_string_to_test), version_string_to_test ) assert get_dependency_version("some-package") == expected @@ -64,7 +64,7 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack ] mock_get_version.return_value = DependencyVersion( - _parse_version_to_tuple("1.0.0"), "1.0.0" + 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") @@ -81,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_to_tuple("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_to_tuple("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") @@ -107,7 +107,7 @@ 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_to_tuple("1.0.0"), "1.0.0" + 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: