1# Copyright 2025 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Code to check versions of dependencies used by Google Cloud Client Libraries."""
16
17import warnings
18import sys
19from typing import Optional, Tuple
20
21from collections import namedtuple
22
23from ._python_version_support import (
24 _flatten_message,
25 _get_distribution_and_import_packages,
26)
27
28if sys.version_info >= (3, 8):
29 from importlib import metadata
30else:
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
35ParsedVersion = Tuple[int, ...]
36
37# Here we list all the packages for which we want to issue warnings
38# about deprecated and unsupported versions.
39DependencyConstraint = namedtuple(
40 "DependencyConstraint",
41 ["package_name", "minimum_fully_supported_version", "recommended_version"],
42)
43_PACKAGE_DEPENDENCY_WARNINGS = [
44 DependencyConstraint(
45 "google.protobuf",
46 minimum_fully_supported_version="4.25.8",
47 recommended_version="6.x",
48 )
49]
50
51
52DependencyVersion = namedtuple("DependencyVersion", ["version", "version_string"])
53# Version string we provide in a DependencyVersion when we can't determine the version of a
54# package.
55UNKNOWN_VERSION_STRING = "--"
56
57
58def 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
82def get_dependency_version(
83 dependency_name: str,
84) -> DependencyVersion:
85 """Get the parsed version of an installed package dependency.
86
87 This function checks for an installed package and returns its version
88 as a comparable tuple of integers object for safe comparison. It handles
89 both modern (Python 3.8+) and legacy (Python 3.7) environments.
90
91 Args:
92 dependency_name: The distribution name of the package (e.g., 'requests').
93
94 Returns:
95 A DependencyVersion namedtuple with `version` (a tuple of integers) and
96 `version_string` attributes, or `DependencyVersion(None,
97 UNKNOWN_VERSION_STRING)` if the package is not found or
98 another error occurs during version discovery.
99
100 """
101 try:
102 version_string: str = metadata.version(dependency_name)
103 parsed_version = parse_version_to_tuple(version_string)
104 return DependencyVersion(parsed_version, version_string)
105 except Exception:
106 # Catch exceptions from metadata.version() (e.g., PackageNotFoundError)
107 # or errors during parse_version_to_tuple
108 return DependencyVersion(None, UNKNOWN_VERSION_STRING)
109
110
111def warn_deprecation_for_versions_less_than(
112 consumer_import_package: str,
113 dependency_import_package: str,
114 minimum_fully_supported_version: str,
115 recommended_version: Optional[str] = None,
116 message_template: Optional[str] = None,
117):
118 """Issue any needed deprecation warnings for `dependency_import_package`.
119
120 If `dependency_import_package` is installed at a version less than
121 `minimum_fully_supported_version`, this issues a warning using either a
122 default `message_template` or one provided by the user. The
123 default `message_template` informs the user that they will not receive
124 future updates for `consumer_import_package` if
125 `dependency_import_package` is somehow pinned to a version lower
126 than `minimum_fully_supported_version`.
127
128 Args:
129 consumer_import_package: The import name of the package that
130 needs `dependency_import_package`.
131 dependency_import_package: The import name of the dependency to check.
132 minimum_fully_supported_version: The dependency_import_package version number
133 below which a deprecation warning will be logged.
134 recommended_version: If provided, the recommended next version, which
135 could be higher than `minimum_fully_supported_version`.
136 message_template: A custom default message template to replace
137 the default. This `message_template` is treated as an
138 f-string, where the following variables are defined:
139 `dependency_import_package`, `consumer_import_package` and
140 `dependency_distribution_package` and
141 `consumer_distribution_package` and `dependency_package`,
142 `consumer_package` , which contain the import packages, the
143 distribution packages, and pretty string with both the
144 distribution and import packages for the dependency and the
145 consumer, respectively; and `minimum_fully_supported_version`,
146 `version_used`, and `version_used_string`, which refer to supported
147 and currently-used versions of the dependency.
148
149 """
150 if (
151 not consumer_import_package
152 or not dependency_import_package
153 or not minimum_fully_supported_version
154 ): # pragma: NO COVER
155 return
156
157 dependency_version = get_dependency_version(dependency_import_package)
158 if not dependency_version.version:
159 return
160
161 if dependency_version.version < parse_version_to_tuple(
162 minimum_fully_supported_version
163 ):
164 (
165 dependency_package,
166 dependency_distribution_package,
167 ) = _get_distribution_and_import_packages(dependency_import_package)
168 (
169 consumer_package,
170 consumer_distribution_package,
171 ) = _get_distribution_and_import_packages(consumer_import_package)
172
173 recommendation = (
174 " (we recommend {recommended_version})" if recommended_version else ""
175 )
176 message_template = message_template or _flatten_message(
177 """
178 DEPRECATION: Package {consumer_package} depends on
179 {dependency_package}, currently installed at version
180 {version_used_string}. Future updates to
181 {consumer_package} will require {dependency_package} at
182 version {minimum_fully_supported_version} or
183 higher{recommendation}. Please ensure that either (a) your
184 Python environment doesn't pin the version of
185 {dependency_package}, so that updates to
186 {consumer_package} can require the higher version, or (b)
187 you manually update your Python environment to use at
188 least version {minimum_fully_supported_version} of
189 {dependency_package}.
190 """
191 )
192 warnings.warn(
193 message_template.format(
194 consumer_import_package=consumer_import_package,
195 dependency_import_package=dependency_import_package,
196 consumer_distribution_package=consumer_distribution_package,
197 dependency_distribution_package=dependency_distribution_package,
198 dependency_package=dependency_package,
199 consumer_package=consumer_package,
200 minimum_fully_supported_version=minimum_fully_supported_version,
201 recommendation=recommendation,
202 version_used=dependency_version.version,
203 version_used_string=dependency_version.version_string,
204 ),
205 FutureWarning,
206 )
207
208
209def check_dependency_versions(
210 consumer_import_package: str, *package_dependency_warnings: DependencyConstraint
211):
212 """Bundle checks for all package dependencies.
213
214 This function can be called by all consumers of google.api_core,
215 to emit needed deprecation warnings for any of their
216 dependencies. The dependencies to check can be passed as arguments, or if
217 none are provided, it will default to the list in
218 `_PACKAGE_DEPENDENCY_WARNINGS`.
219
220 Args:
221 consumer_import_package: The distribution name of the calling package, whose
222 dependencies we're checking.
223 *package_dependency_warnings: A variable number of DependencyConstraint
224 objects, each specifying a dependency to check.
225 """
226 if not package_dependency_warnings:
227 package_dependency_warnings = tuple(_PACKAGE_DEPENDENCY_WARNINGS)
228 for package_info in package_dependency_warnings:
229 warn_deprecation_for_versions_less_than(
230 consumer_import_package,
231 package_info.package_name,
232 package_info.minimum_fully_supported_version,
233 recommended_version=package_info.recommended_version,
234 )