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.7",
75 python_beta=None,
76 python_start=datetime.date(2018, 6, 27),
77 python_eol=datetime.date(2023, 6, 27),
78 ),
79 VersionInfo(
80 version="3.8",
81 python_beta=None,
82 python_start=datetime.date(2019, 10, 14),
83 python_eol=datetime.date(2024, 10, 7),
84 ),
85 VersionInfo(
86 version="3.9",
87 python_beta=datetime.date(2020, 5, 18),
88 python_start=datetime.date(2020, 10, 5),
89 python_eol=datetime.date(2025, 10, 5),
90 gapic_end=datetime.date(2025, 10, 5) + datetime.timedelta(days=90),
91 ),
92 VersionInfo(
93 version="3.10",
94 python_beta=datetime.date(2021, 5, 3),
95 python_start=datetime.date(2021, 10, 4),
96 python_eol=datetime.date(2026, 10, 4), # TODO: specify day when announced
97 ),
98 VersionInfo(
99 version="3.11",
100 python_beta=datetime.date(2022, 5, 8),
101 python_start=datetime.date(2022, 10, 24),
102 python_eol=datetime.date(2027, 10, 24), # TODO: specify day when announced
103 ),
104 VersionInfo(
105 version="3.12",
106 python_beta=datetime.date(2023, 5, 22),
107 python_start=datetime.date(2023, 10, 2),
108 python_eol=datetime.date(2028, 10, 2), # TODO: specify day when announced
109 ),
110 VersionInfo(
111 version="3.13",
112 python_beta=datetime.date(2024, 5, 8),
113 python_start=datetime.date(2024, 10, 7),
114 python_eol=datetime.date(2029, 10, 7), # TODO: specify day when announced
115 ),
116 VersionInfo(
117 version="3.14",
118 python_beta=datetime.date(2025, 5, 7),
119 python_start=datetime.date(2025, 10, 7),
120 python_eol=datetime.date(2030, 10, 7), # TODO: specify day when announced
121 ),
122]
123
124PYTHON_VERSION_INFO: Dict[Tuple[int, int], VersionInfo] = {}
125for info in PYTHON_VERSIONS:
126 major, minor = map(int, info.version.split("."))
127 PYTHON_VERSION_INFO[(major, minor)] = info
128
129
130LOWEST_TRACKED_VERSION = min(PYTHON_VERSION_INFO.keys())
131_FAKE_PAST_DATE = datetime.date.min + datetime.timedelta(days=900)
132_FAKE_PAST_VERSION = VersionInfo(
133 version="0.0",
134 python_beta=_FAKE_PAST_DATE,
135 python_start=_FAKE_PAST_DATE,
136 python_eol=_FAKE_PAST_DATE,
137)
138_FAKE_FUTURE_DATE = datetime.date.max - datetime.timedelta(days=900)
139_FAKE_FUTURE_VERSION = VersionInfo(
140 version="999.0",
141 python_beta=_FAKE_FUTURE_DATE,
142 python_start=_FAKE_FUTURE_DATE,
143 python_eol=_FAKE_FUTURE_DATE,
144)
145DEPRECATION_WARNING_PERIOD = datetime.timedelta(days=365)
146EOL_GRACE_PERIOD = datetime.timedelta(weeks=1)
147
148
149def _flatten_message(text: str) -> str:
150 """Dedent a multi-line string and flatten it into a single line."""
151 return " ".join(textwrap.dedent(text).strip().split())
152
153
154# TODO(https://github.com/googleapis/python-api-core/issues/835):
155# Remove once we no longer support Python 3.9.
156# `importlib.metadata.packages_distributions()` is only supported in Python 3.10 and newer
157# https://docs.python.org/3/library/importlib.metadata.html#importlib.metadata.packages_distributions
158if sys.version_info < (3, 10):
159
160 def _get_pypi_package_name(module_name): # pragma: NO COVER
161 """Determine the PyPI package name for a given module name."""
162 return None
163
164else:
165 from importlib import metadata
166
167 def _get_pypi_package_name(module_name):
168 """Determine the PyPI package name for a given module name."""
169 try:
170 # Get the mapping of modules to distributions
171 module_to_distributions = metadata.packages_distributions()
172
173 # Check if the module is found in the mapping
174 if module_name in module_to_distributions: # pragma: NO COVER
175 # The value is a list of distribution names, take the first one
176 return module_to_distributions[module_name][0]
177 except Exception as e: # pragma: NO COVER
178 _LOGGER.info(
179 "An error occurred while determining PyPI package name for %s: %s",
180 module_name,
181 e,
182 )
183
184 return None
185
186
187def _get_distribution_and_import_packages(import_package: str) -> Tuple[str, Any]:
188 """Return a pretty string with distribution & import package names."""
189 distribution_package = _get_pypi_package_name(import_package)
190 dependency_distribution_and_import_packages = (
191 f"package {distribution_package} ({import_package})"
192 if distribution_package
193 else import_package
194 )
195 return dependency_distribution_and_import_packages, distribution_package
196
197
198def check_python_version(
199 package: str = "this package", today: Optional[datetime.date] = None
200) -> PythonVersionStatus:
201 """Check the running Python version and issue a support warning if needed.
202
203 Args:
204 today: The date to check against. Defaults to the current date.
205
206 Returns:
207 The support status of the current Python version.
208 """
209 today = today or datetime.date.today()
210 package_label, _ = _get_distribution_and_import_packages(package)
211
212 python_version = sys.version_info
213 version_tuple = (python_version.major, python_version.minor)
214 py_version_str = sys.version.split()[0]
215
216 version_info = PYTHON_VERSION_INFO.get(version_tuple)
217
218 if not version_info:
219 if version_tuple < LOWEST_TRACKED_VERSION:
220 version_info = _FAKE_PAST_VERSION
221 else:
222 version_info = _FAKE_FUTURE_VERSION
223
224 gapic_deprecation = version_info.gapic_deprecation or (
225 version_info.python_eol - DEPRECATION_WARNING_PERIOD
226 )
227 gapic_end = version_info.gapic_end or (version_info.python_eol + EOL_GRACE_PERIOD)
228
229 def min_python(date: datetime.date) -> str:
230 """Find the minimum supported Python version for a given date."""
231 for version, info in sorted(PYTHON_VERSION_INFO.items()):
232 if info.python_start <= date < info.python_eol:
233 return f"{version[0]}.{version[1]}"
234 return "at a currently supported version [https://devguide.python.org/versions]"
235
236 if gapic_end < today:
237 message = _flatten_message(
238 f"""
239 You are using a non-supported Python version ({py_version_str}).
240 Google will not post any further updates to {package_label}
241 supporting this Python version. Please upgrade to the latest Python
242 version, or at least Python {min_python(today)}, and then update
243 {package_label}.
244 """
245 )
246 warnings.warn(message, FutureWarning)
247 return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED
248
249 eol_date = version_info.python_eol + EOL_GRACE_PERIOD
250 if eol_date <= today <= gapic_end:
251 message = _flatten_message(
252 f"""
253 You are using a Python version ({py_version_str})
254 past its end of life. Google will update {package_label}
255 with critical bug fixes on a best-effort basis, but not
256 with any other fixes or features. Please upgrade
257 to the latest Python version, or at least Python
258 {min_python(today)}, and then update {package_label}.
259 """
260 )
261 warnings.warn(message, FutureWarning)
262 return PythonVersionStatus.PYTHON_VERSION_EOL
263
264 if gapic_deprecation <= today <= gapic_end:
265 message = _flatten_message(
266 f"""
267 You are using a Python version ({py_version_str}) which Google will
268 stop supporting in new releases of {package_label} once it reaches
269 its end of life ({version_info.python_eol}). Please upgrade to the
270 latest Python version, or at least Python
271 {min_python(version_info.python_eol)}, to continue receiving updates
272 for {package_label} past that date.
273 """
274 )
275 warnings.warn(message, FutureWarning)
276 return PythonVersionStatus.PYTHON_VERSION_DEPRECATED
277
278 return PythonVersionStatus.PYTHON_VERSION_SUPPORTED