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