Skip to content

Commit 07e1aca

Browse files
partheapujawadare
authored andcommitted
fix: remove dependency on packaging and pkg_resources (googleapis#852)
* fix: remove dependency on packaging and pkg_resources * add test case * lint * update docstring * add constraint for importlib_metadata * address feedback * address feedback * remove comment * address feedback * address feedback
1 parent 6206ce0 commit 07e1aca

File tree

5 files changed

+78
-55
lines changed

5 files changed

+78
-55
lines changed

google/api_core/_python_package_support.py

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import warnings
1818
import sys
19-
from typing import Optional
19+
from typing import Optional, Tuple
2020

2121
from collections import namedtuple
2222

@@ -25,7 +25,14 @@
2525
_get_distribution_and_import_packages,
2626
)
2727

28-
from packaging.version import parse as parse_version
28+
if sys.version_info >= (3, 8):
29+
from importlib import metadata
30+
else:
31+
# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
32+
# this code path once we drop support for Python 3.7
33+
import importlib_metadata as metadata
34+
35+
ParsedVersion = Tuple[int, ...]
2936

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

5057

58+
def parse_version_to_tuple(version_string: str) -> ParsedVersion:
59+
"""Safely converts a semantic version string to a comparable tuple of integers.
60+
61+
Example: "4.25.8" -> (4, 25, 8)
62+
Ignores non-numeric parts and handles common version formats.
63+
64+
Args:
65+
version_string: Version string in the format "x.y.z" or "x.y.z<suffix>"
66+
67+
Returns:
68+
Tuple of integers for the parsed version string.
69+
"""
70+
parts = []
71+
for part in version_string.split("."):
72+
try:
73+
parts.append(int(part))
74+
except ValueError:
75+
# If it's a non-numeric part (e.g., '1.0.0b1' -> 'b1'), stop here.
76+
# This is a simplification compared to 'packaging.parse_version', but sufficient
77+
# for comparing strictly numeric semantic versions.
78+
break
79+
return tuple(parts)
80+
81+
5182
def get_dependency_version(
5283
dependency_name: str,
5384
) -> DependencyVersion:
5485
"""Get the parsed version of an installed package dependency.
5586
5687
This function checks for an installed package and returns its version
57-
as a `packaging.version.Version` object for safe comparison. It handles
88+
as a comparable tuple of integers object for safe comparison. It handles
5889
both modern (Python 3.8+) and legacy (Python 3.7) environments.
5990
6091
Args:
6192
dependency_name: The distribution name of the package (e.g., 'requests').
6293
6394
Returns:
64-
A DependencyVersion namedtuple with `version` and
95+
A DependencyVersion namedtuple with `version` (a tuple of integers) and
6596
`version_string` attributes, or `DependencyVersion(None,
6697
UNKNOWN_VERSION_STRING)` if the package is not found or
6798
another error occurs during version discovery.
6899
69100
"""
70101
try:
71-
if sys.version_info >= (3, 8):
72-
from importlib import metadata
73-
74-
version_string = metadata.version(dependency_name)
75-
return DependencyVersion(parse_version(version_string), version_string)
76-
77-
# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
78-
# this code path once we drop support for Python 3.7
79-
else: # pragma: NO COVER
80-
# Use pkg_resources, which is part of setuptools.
81-
import pkg_resources
82-
83-
version_string = pkg_resources.get_distribution(dependency_name).version
84-
return DependencyVersion(parse_version(version_string), version_string)
85-
102+
version_string: str = metadata.version(dependency_name)
103+
parsed_version = parse_version_to_tuple(version_string)
104+
return DependencyVersion(parsed_version, version_string)
86105
except Exception:
106+
# Catch exceptions from metadata.version() (e.g., PackageNotFoundError)
107+
# or errors during parse_version_to_tuple
87108
return DependencyVersion(None, UNKNOWN_VERSION_STRING)
88109

89110

@@ -132,10 +153,14 @@ def warn_deprecation_for_versions_less_than(
132153
or not minimum_fully_supported_version
133154
): # pragma: NO COVER
134155
return
156+
135157
dependency_version = get_dependency_version(dependency_import_package)
136158
if not dependency_version.version:
137159
return
138-
if dependency_version.version < parse_version(minimum_fully_supported_version):
160+
161+
if dependency_version.version < parse_version_to_tuple(
162+
minimum_fully_supported_version
163+
):
139164
(
140165
dependency_package,
141166
dependency_distribution_package,

noxfile.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ def default(session, install_grpc=True, prerelease=False, install_async_rest=Fal
127127
"mock; python_version=='3.7'",
128128
"pytest",
129129
"pytest-cov",
130+
"pytest-mock",
130131
"pytest-xdist",
131132
)
132133

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ dependencies = [
5151
"proto-plus >= 1.25.0, < 2.0.0; python_version >= '3.13'",
5252
"google-auth >= 2.14.1, < 3.0.0",
5353
"requests >= 2.18.0, < 3.0.0",
54+
# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
55+
# `importlib_metadata` once we drop support for Python 3.7
56+
"importlib_metadata>=1.4; python_version<'3.8'",
5457
]
5558
dynamic = ["version"]
5659

testing/constraints-3.7.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ grpcio==1.33.2
1313
grpcio-status==1.33.2
1414
grpcio-gcp==0.2.2
1515
proto-plus==1.22.3
16+
importlib_metadata==1.4

tests/unit/test_python_package_support.py

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414

1515
import sys
1616
import warnings
17-
from unittest.mock import patch, MagicMock
17+
from unittest.mock import patch
1818

1919
import pytest
20-
from packaging.version import parse as parse_version
2120

2221
from google.api_core._python_package_support import (
22+
parse_version_to_tuple,
2323
get_dependency_version,
2424
warn_deprecation_for_versions_less_than,
2525
check_dependency_versions,
@@ -28,39 +28,28 @@
2828
)
2929

3030

31-
# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
32-
# this mark once we drop support for Python 3.7
33-
@pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python3.8 or higher")
34-
@patch("importlib.metadata.version")
35-
def test_get_dependency_version_py38_plus(mock_version):
36-
"""Test get_dependency_version on Python 3.8+."""
37-
mock_version.return_value = "1.2.3"
38-
expected = DependencyVersion(parse_version("1.2.3"), "1.2.3")
31+
@pytest.mark.parametrize("version_string_to_test", ["1.2.3", "1.2.3b1"])
32+
def test_get_dependency_version(mocker, version_string_to_test):
33+
"""Test get_dependency_version."""
34+
if sys.version_info >= (3, 8):
35+
mock_importlib = mocker.patch(
36+
"importlib.metadata.version", return_value=version_string_to_test
37+
)
38+
else:
39+
# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove
40+
# `importlib_metadata` once we drop support for Python 3.7
41+
mock_importlib = mocker.patch(
42+
"importlib_metadata.version", return_value=version_string_to_test
43+
)
44+
expected = DependencyVersion(
45+
parse_version_to_tuple(version_string_to_test), version_string_to_test
46+
)
3947
assert get_dependency_version("some-package") == expected
40-
mock_version.assert_called_once_with("some-package")
41-
42-
# Test package not found
43-
mock_version.side_effect = ImportError
44-
assert get_dependency_version("not-a-package") == DependencyVersion(None, "--")
45-
4648

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

6051
# Test package not found
61-
mock_get_distribution.side_effect = (
62-
Exception # pkg_resources has its own exception types
63-
)
52+
mock_importlib.side_effect = ImportError
6453
assert get_dependency_version("not-a-package") == DependencyVersion(None, "--")
6554

6655

@@ -74,7 +63,9 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack
7463
("my-package (my.package)", "my-package"),
7564
]
7665

77-
mock_get_version.return_value = DependencyVersion(parse_version("1.0.0"), "1.0.0")
66+
mock_get_version.return_value = DependencyVersion(
67+
parse_version_to_tuple("1.0.0"), "1.0.0"
68+
)
7869
with pytest.warns(FutureWarning) as record:
7970
warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0")
8071
assert len(record) == 1
@@ -90,14 +81,14 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack
9081
# Case 2: Installed version is equal to required, should not warn.
9182
mock_get_packages.reset_mock()
9283
mock_get_version.return_value = DependencyVersion(
93-
parse_version("2.0.0"), "2.0.0"
84+
parse_version_to_tuple("2.0.0"), "2.0.0"
9485
)
9586
warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0")
9687

9788
# Case 3: Installed version is greater than required, should not warn.
9889
mock_get_packages.reset_mock()
9990
mock_get_version.return_value = DependencyVersion(
100-
parse_version("3.0.0"), "3.0.0"
91+
parse_version_to_tuple("3.0.0"), "3.0.0"
10192
)
10293
warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0")
10394

@@ -115,7 +106,9 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack
115106
("dep-package (dep.package)", "dep-package"),
116107
("my-package (my.package)", "my-package"),
117108
]
118-
mock_get_version.return_value = DependencyVersion(parse_version("1.0.0"), "1.0.0")
109+
mock_get_version.return_value = DependencyVersion(
110+
parse_version_to_tuple("1.0.0"), "1.0.0"
111+
)
119112
template = "Custom warning for {dependency_package} used by {consumer_package}."
120113
with pytest.warns(FutureWarning) as record:
121114
warn_deprecation_for_versions_less_than(

0 commit comments

Comments
 (0)