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