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