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