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 Python versions supported by Google Cloud Client Libraries."""
16
17import datetime
18import enum
19import functools
20import logging
21import warnings
22import sys
23import textwrap
24from typing import Any, List, NamedTuple, Optional, Dict, Tuple
25
26
27_LOGGER = logging.getLogger(__name__)
28
29
30class PythonVersionStatus(enum.Enum):
31 """Support status of a Python version in this client library artifact release.
32
33 "Support", in this context, means that this release of a client library
34 artifact is configured to run on the currently configured version of
35 Python.
36 """
37
38 PYTHON_VERSION_STATUS_UNSPECIFIED = "PYTHON_VERSION_STATUS_UNSPECIFIED"
39
40 PYTHON_VERSION_SUPPORTED = "PYTHON_VERSION_SUPPORTED"
41 """This Python version is fully supported, so the artifact running on this
42 version will have all features and bug fixes."""
43
44 PYTHON_VERSION_DEPRECATED = "PYTHON_VERSION_DEPRECATED"
45 """This Python version is still supported, but support will end within a
46 year. At that time, there will be no more releases for this artifact
47 running under this Python version."""
48
49 PYTHON_VERSION_EOL = "PYTHON_VERSION_EOL"
50 """This Python version has reached its end of life in the Python community
51 (see https://devguide.python.org/versions/), and this artifact will cease
52 supporting this Python version within the next few releases."""
53
54 PYTHON_VERSION_UNSUPPORTED = "PYTHON_VERSION_UNSUPPORTED"
55 """This release of the client library artifact may not be the latest, since
56 current releases no longer support this Python version."""
57
58
59class VersionInfo(NamedTuple):
60 """Hold release and support date information for a Python version."""
61
62 version: str
63 python_beta: Optional[datetime.date]
64 python_start: datetime.date
65 python_eol: datetime.date
66 gapic_start: Optional[datetime.date] = None # unused
67 gapic_deprecation: Optional[datetime.date] = None
68 gapic_end: Optional[datetime.date] = None
69 dep_unpatchable_cve: Optional[datetime.date] = None # unused
70
71
72PYTHON_VERSIONS: List[VersionInfo] = [
73 # Refer to https://devguide.python.org/versions/ and the PEPs linked therefrom.
74 VersionInfo(
75 version="3.9",
76 python_beta=datetime.date(2020, 5, 18),
77 python_start=datetime.date(2020, 10, 5),
78 python_eol=datetime.date(2025, 10, 5),
79 gapic_end=datetime.date(2025, 10, 5) + datetime.timedelta(days=90),
80 ),
81 VersionInfo(
82 version="3.10",
83 python_beta=datetime.date(2021, 5, 3),
84 python_start=datetime.date(2021, 10, 4),
85 python_eol=datetime.date(2026, 10, 4), # TODO: specify day when announced
86 ),
87 VersionInfo(
88 version="3.11",
89 python_beta=datetime.date(2022, 5, 8),
90 python_start=datetime.date(2022, 10, 24),
91 python_eol=datetime.date(2027, 10, 24), # TODO: specify day when announced
92 ),
93 VersionInfo(
94 version="3.12",
95 python_beta=datetime.date(2023, 5, 22),
96 python_start=datetime.date(2023, 10, 2),
97 python_eol=datetime.date(2028, 10, 2), # TODO: specify day when announced
98 ),
99 VersionInfo(
100 version="3.13",
101 python_beta=datetime.date(2024, 5, 8),
102 python_start=datetime.date(2024, 10, 7),
103 python_eol=datetime.date(2029, 10, 7), # TODO: specify day when announced
104 ),
105 VersionInfo(
106 version="3.14",
107 python_beta=datetime.date(2025, 5, 7),
108 python_start=datetime.date(2025, 10, 7),
109 python_eol=datetime.date(2030, 10, 7), # TODO: specify day when announced
110 ),
111]
112
113PYTHON_VERSION_INFO: Dict[Tuple[int, int], VersionInfo] = {}
114for info in PYTHON_VERSIONS:
115 major, minor = map(int, info.version.split("."))
116 PYTHON_VERSION_INFO[(major, minor)] = info
117
118
119LOWEST_TRACKED_VERSION = min(PYTHON_VERSION_INFO.keys())
120_FAKE_PAST_DATE = datetime.date.min + datetime.timedelta(days=900)
121_FAKE_PAST_VERSION = VersionInfo(
122 version="0.0",
123 python_beta=_FAKE_PAST_DATE,
124 python_start=_FAKE_PAST_DATE,
125 python_eol=_FAKE_PAST_DATE,
126)
127_FAKE_FUTURE_DATE = datetime.date.max - datetime.timedelta(days=900)
128_FAKE_FUTURE_VERSION = VersionInfo(
129 version="999.0",
130 python_beta=_FAKE_FUTURE_DATE,
131 python_start=_FAKE_FUTURE_DATE,
132 python_eol=_FAKE_FUTURE_DATE,
133)
134DEPRECATION_WARNING_PERIOD = datetime.timedelta(days=365)
135EOL_GRACE_PERIOD = datetime.timedelta(weeks=1)
136
137
138def _flatten_message(text: str) -> str:
139 """Dedent a multi-line string and flatten it into a single line."""
140 return " ".join(textwrap.dedent(text).strip().split())
141
142
143# TODO(https://github.com/googleapis/python-api-core/issues/835):
144# Remove once we no longer support Python 3.9.
145# `importlib.metadata.packages_distributions()` is only supported in Python 3.10 and newer
146# https://docs.python.org/3/library/importlib.metadata.html#importlib.metadata.packages_distributions
147if sys.version_info < (3, 10):
148
149 def _get_pypi_package_name(module_name): # pragma: NO COVER
150 """Determine the PyPI package name for a given module name."""
151 return None
152
153else:
154 from importlib import metadata
155
156 @functools.cache
157 def _cached_packages_distributions():
158 return metadata.packages_distributions()
159
160 def _get_pypi_package_name(module_name):
161 """Determine the PyPI package name for a given module name."""
162 try:
163 module_to_distributions = _cached_packages_distributions()
164
165 if module_name in module_to_distributions: # pragma: NO COVER
166 return module_to_distributions[module_name][0]
167 except Exception as e: # pragma: NO COVER
168 _LOGGER.info(
169 "An error occurred while determining PyPI package name for %s: %s",
170 module_name,
171 e,
172 )
173
174 return None
175
176
177def _get_distribution_and_import_packages(import_package: str) -> Tuple[str, Any]:
178 """Return a pretty string with distribution & import package names."""
179 distribution_package = _get_pypi_package_name(import_package)
180 dependency_distribution_and_import_packages = (
181 f"package {distribution_package} ({import_package})"
182 if distribution_package
183 else import_package
184 )
185 return dependency_distribution_and_import_packages, distribution_package
186
187
188def check_python_version(
189 package: str = "this package", today: Optional[datetime.date] = None
190) -> PythonVersionStatus:
191 """Check the running Python version and issue a support warning if needed.
192
193 Args:
194 today: The date to check against. Defaults to the current date.
195
196 Returns:
197 The support status of the current Python version.
198 """
199 today = today or datetime.date.today()
200
201 python_version = sys.version_info
202 version_tuple = (python_version.major, python_version.minor)
203 py_version_str = sys.version.split()[0]
204
205 version_info = PYTHON_VERSION_INFO.get(version_tuple)
206
207 if not version_info:
208 if version_tuple < LOWEST_TRACKED_VERSION:
209 version_info = _FAKE_PAST_VERSION
210 else:
211 version_info = _FAKE_FUTURE_VERSION
212
213 gapic_deprecation = version_info.gapic_deprecation or (
214 version_info.python_eol - DEPRECATION_WARNING_PERIOD
215 )
216 gapic_end = version_info.gapic_end or (version_info.python_eol + EOL_GRACE_PERIOD)
217
218 def min_python(date: datetime.date) -> str:
219 """Find the minimum supported Python version for a given date."""
220 for version, info in sorted(PYTHON_VERSION_INFO.items()):
221 if info.python_start <= date < info.python_eol:
222 return f"{version[0]}.{version[1]}"
223 return "at a currently supported version [https://devguide.python.org/versions]"
224
225 # Resolve the pretty package label lazily so we avoid any work on
226 # the happy path (supported Python version, no warning needed).
227 def get_package_label():
228 label, _ = _get_distribution_and_import_packages(package)
229 return label
230
231 if gapic_end < today:
232 package_label = get_package_label()
233 message = _flatten_message(
234 f"""
235 You are using a non-supported Python version ({py_version_str}).
236 Google will not post any further updates to {package_label}
237 supporting this Python version. Please upgrade to the latest Python
238 version, or at least Python {min_python(today)}, and then update
239 {package_label}.
240 """
241 )
242 warnings.warn(message, FutureWarning)
243 return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED
244
245 eol_date = version_info.python_eol + EOL_GRACE_PERIOD
246 if eol_date <= today <= gapic_end:
247 package_label = get_package_label()
248 message = _flatten_message(
249 f"""
250 You are using a Python version ({py_version_str})
251 past its end of life. Google will update {package_label}
252 with critical bug fixes on a best-effort basis, but not
253 with any other fixes or features. Please upgrade
254 to the latest Python version, or at least Python
255 {min_python(today)}, and then update {package_label}.
256 """
257 )
258 warnings.warn(message, FutureWarning)
259 return PythonVersionStatus.PYTHON_VERSION_EOL
260
261 if gapic_deprecation <= today <= gapic_end:
262 package_label = get_package_label()
263 message = _flatten_message(
264 f"""
265 You are using a Python version ({py_version_str}) which Google will
266 stop supporting in new releases of {package_label} once it reaches
267 its end of life ({version_info.python_eol}). Please upgrade to the
268 latest Python version, or at least Python
269 {min_python(version_info.python_eol)}, to continue receiving updates
270 for {package_label} past that date.
271 """
272 )
273 warnings.warn(message, FutureWarning)
274 return PythonVersionStatus.PYTHON_VERSION_DEPRECATED
275
276 return PythonVersionStatus.PYTHON_VERSION_SUPPORTED