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