From 6316d70fe469db82e77e651aa18af6366366ca69 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 28 Jul 2025 18:34:26 -0700 Subject: [PATCH 01/58] feat: provide Python run-time version support This checks the runtime versions. Note that the defaults mean - we get the correct behavior for 3.7 and 3.8: both of these became immediately unsupported; - we get the most conservative behavior for 3.9: it becomes immediately deprecated (desired, since Python 3.9 reaches its end of life in 2025-10), and will become unsupported once it reaches its end of life (which is a conservative policy that we may or may not want to relax in a follow-up) Still todo: echo the package name in the warning message. --- google/api_core/__init__.py | 2 + google/api_core/_python_version_support.py | 186 +++++++++++++++++++ tests/unit/test_python_version_support.py | 206 +++++++++++++++++++++ 3 files changed, 394 insertions(+) create mode 100644 google/api_core/_python_version_support.py create mode 100644 tests/unit/test_python_version_support.py diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index b80ea372..2adbdc2d 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -17,6 +17,8 @@ This package contains common code and utilities used by Google client libraries. """ +from google.api_core import _python_version_support from google.api_core import version as api_core_version __version__ = api_core_version.__version__ +check_python_version = _python_version_support.check_python_version diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py new file mode 100644 index 00000000..9f953914 --- /dev/null +++ b/google/api_core/_python_version_support.py @@ -0,0 +1,186 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Code to check Python versions supported by Google Cloud Client Libraries.""" + +import datetime +import enum +import logging +import sys +import textwrap +from typing import NamedTuple, Optional, Dict, Tuple + + +class PythonVersionStatus(enum.Enum): + """Represent the support status of a Python version.""" + + PYTHON_VERSION_UNSUPPORTED = "PYTHON_VERSION_UNSUPPORTED" + PYTHON_VERSION_EOL = "PYTHON_VERSION_EOL" + PYTHON_VERSION_DEPRECATED = "PYTHON_VERSION_DEPRECATED" + PYTHON_VERSION_SUPPORTED = "PYTHON_VERSION_SUPPORTED" + + +class VersionInfo(NamedTuple): + """Hold release and support date information for a Python version.""" + + python_beta: Optional[datetime.date] + python_start: datetime.date + python_eol: datetime.date + gapic_start: Optional[datetime.date] = None + gapic_deprecation: Optional[datetime.date] = None + gapic_end: Optional[datetime.date] = None + dep_unpatchable_cve: Optional[datetime.date] = None + + +PYTHON_VERSION_INFO: Dict[Tuple[int, int], VersionInfo] = { + # Refer to https://devguide.python.org/versions/ and the PEPs linked therefrom. + (3, 7): VersionInfo( + python_beta=None, + python_start=datetime.date(2018, 6, 27), + python_eol=datetime.date(2023, 6, 27), + ), + (3, 8): VersionInfo( + python_beta=None, + python_start=datetime.date(2019, 10, 14), + python_eol=datetime.date(2024, 10, 7), + ), + (3, 9): VersionInfo( + python_beta=datetime.date(2020, 5, 18), + python_start=datetime.date(2020, 10, 5), + python_eol=datetime.date(2025, 10, 5), # TODO: specify day when announced + ), + (3, 10): VersionInfo( + python_beta=datetime.date(2021, 5, 3), + python_start=datetime.date(2021, 10, 4), + python_eol=datetime.date(2026, 10, 4), # TODO: specify day when announced + ), + (3, 11): VersionInfo( + python_beta=datetime.date(2022, 5, 8), + python_start=datetime.date(2022, 10, 24), + python_eol=datetime.date(2027, 10, 24), # TODO: specify day when announced + ), + (3, 12): VersionInfo( + python_beta=datetime.date(2023, 5, 22), + python_start=datetime.date(2023, 10, 2), + python_eol=datetime.date(2028, 10, 2), # TODO: specify day when announced + ), + (3, 13): VersionInfo( + python_beta=datetime.date(2024, 5, 8), + python_start=datetime.date(2024, 10, 7), + python_eol=datetime.date(2029, 10, 7), # TODO: specify day when announced + ), + (3, 14): VersionInfo( + python_beta=datetime.date(2025, 5, 7), + python_start=datetime.date(2025, 10, 7), + python_eol=datetime.date(2030, 10, 7), # TODO: specify day when announced + ), +} + +LOWEST_TRACKED_VERSION = min(PYTHON_VERSION_INFO.keys()) +FAKE_PAST_DATE = datetime.date(1970, 1, 1) +FAKE_FUTURE_DATE = datetime.date(9000, 1, 1) + + +def _flatten_message(text: str) -> str: + """Dedent a multi-line string and flattens it into a single line.""" + return textwrap.dedent(text).strip().replace("\n", " ") + + +def check_python_version(today: Optional[datetime.date] = None) -> PythonVersionStatus: + """Check the running Python version and issue a support warning if needed. + + Args: + today: The date to check against. Defaults to the current date. + + Returns: + The support status of the current Python version. + """ + today = today or datetime.date.today() + + python_version = sys.version_info + version_tuple = (python_version.major, python_version.minor) + py_version_str = f"{python_version.major}.{python_version.minor}" + + version_info = PYTHON_VERSION_INFO.get(version_tuple) + + if not version_info: + if version_tuple < LOWEST_TRACKED_VERSION: + version_info = VersionInfo( + python_beta=FAKE_PAST_DATE, + python_start=FAKE_PAST_DATE, + python_eol=FAKE_PAST_DATE, + ) + else: + version_info = VersionInfo( + python_beta=FAKE_FUTURE_DATE, + python_start=FAKE_FUTURE_DATE, + python_eol=FAKE_FUTURE_DATE, + ) + + gapic_deprecation = version_info.gapic_deprecation or ( + version_info.python_eol - datetime.timedelta(days=365) + ) + gapic_end = version_info.gapic_end or ( + version_info.python_eol + datetime.timedelta(weeks=1) + ) + + def min_python(date: datetime.date) -> str: + """Find the minimum supported Python version for a given date.""" + for version, info in sorted(PYTHON_VERSION_INFO.items()): + if info.python_start <= date < info.python_eol: + return f"{version[0]}.{version[1]}" + return "at a supported version" + + if gapic_end < today: + message = _flatten_message( + f""" + You are using a non-supported Python version ({py_version_str}). + You will receive no updates to this client library. We suggest + you upgrade to the latest Python version, or at least Python + {min_python(today)}, and then update this library. + """ + ) + logging.warning(message) + return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + + eol_date = version_info.python_eol + datetime.timedelta(weeks=1) + if eol_date <= today <= gapic_end: + message = _flatten_message( + f""" + You are using a Python version ({py_version_str}) past its end + of life. This client library will continue receiving critical + bug fixes on a best-effort basis, but not any other fixes or + features. We suggest you upgrade to the latest Python version, + or at least Python {min_python(today)}, and then update this + library. + """ + ) + logging.warning(message) + return PythonVersionStatus.PYTHON_VERSION_EOL + + if gapic_deprecation <= today <= gapic_end: + message = _flatten_message( + f""" + You are using a Python version ({py_version_str}), which new + releases of this client library will stop supporting when it + reaches its end of life ({version_info.python_eol}). We + suggest you upgrade to the latest Python version, or at least + Python {min_python(version_info.python_eol)}, and then update + this library. + """ + ) + logging.warning(message) + return PythonVersionStatus.PYTHON_VERSION_DEPRECATED + + return PythonVersionStatus.PYTHON_VERSION_SUPPORTED diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py new file mode 100644 index 00000000..43a8c014 --- /dev/null +++ b/tests/unit/test_python_version_support.py @@ -0,0 +1,206 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import datetime +import textwrap +from collections import namedtuple + +from unittest.mock import patch + +# Code to be tested +from google.api_core._python_version_support import ( + check_python_version, + PythonVersionStatus, + PYTHON_VERSION_INFO, +) + +# Helper object for mocking sys.version_info +VersionInfoMock = namedtuple("VersionInfoMock", ["major", "minor"]) + + +def _create_failure_message(expected, result, py_version, date, + gapic_dep, py_eol, eol_warn, gapic_end): + """Create a detailed failure message for a test.""" + return textwrap.dedent( + f""" + --- Test Failed --- + Expected status: {expected.name} + Received status: {result.name} + --------------------- + Context: + - Mocked Python Version: {py_version} + - Mocked Today's Date: {date} + Calculated Dates: + - gapic_deprecation: {gapic_dep} + - python_eol: {py_eol} + - eol_warning_starts: {eol_warn} + - gapic_end: {gapic_end} + """ + ) + + +def generate_tracked_version_test_cases(): + """ + Yields test parameters for all tracked versions and boundary conditions. + """ + for version_tuple, version_info in PYTHON_VERSION_INFO.items(): + py_version_str = f"{version_tuple[0]}.{version_tuple[1]}" + gapic_dep = version_info.gapic_deprecation or ( + version_info.python_eol - datetime.timedelta(days=365) + ) + gapic_end = version_info.gapic_end or ( + version_info.python_eol + datetime.timedelta(weeks=1) + ) + eol_warning_starts = version_info.python_eol + datetime.timedelta(weeks=1) + + test_cases = { + "supported_before_deprecation_date": { + "date": gapic_dep - datetime.timedelta(days=1), + "expected": PythonVersionStatus.PYTHON_VERSION_SUPPORTED, + }, + "deprecated_on_deprecation_date": { + "date": gapic_dep, + "expected": PythonVersionStatus.PYTHON_VERSION_DEPRECATED, + }, + "deprecated_on_eol_date": { + "date": version_info.python_eol, + "expected": PythonVersionStatus.PYTHON_VERSION_DEPRECATED, + }, + "deprecated_before_eol_warning_starts": { + "date": eol_warning_starts - datetime.timedelta(days=1), + "expected": PythonVersionStatus.PYTHON_VERSION_DEPRECATED, + }, + "eol_on_eol_warning_date": { + "date": eol_warning_starts, + "expected": PythonVersionStatus.PYTHON_VERSION_EOL, + }, + "eol_on_gapic_end_date": { + "date": gapic_end, + "expected": PythonVersionStatus.PYTHON_VERSION_EOL, + }, + "unsupported_after_gapic_end_date": { + "date": gapic_end + datetime.timedelta(days=1), + "expected": PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED, + }, + } + + for name, params in test_cases.items(): + yield pytest.param( + version_tuple, params["date"], params["expected"], + gapic_dep, gapic_end, eol_warning_starts, + id=f"{py_version_str}-{name}" + ) + + +@pytest.mark.parametrize( + "version_tuple, mock_date, expected_status, gapic_dep, gapic_end, eol_warning_starts", + generate_tracked_version_test_cases() +) +def test_all_tracked_versions_and_date_scenarios( + version_tuple, mock_date, expected_status, gapic_dep, gapic_end, + eol_warning_starts +): + """Test all outcomes for each tracked version using parametrization.""" + mock_py_v = VersionInfoMock( + major=version_tuple[0], minor=version_tuple[1] + ) + + with patch("google.api_core._python_version_support.sys.version_info", mock_py_v): + with patch("google.api_core._python_version_support.logging.warning") as mock_log: + result = check_python_version(today=mock_date) + + if ((result != expected_status) or + (result != PythonVersionStatus.PYTHON_VERSION_SUPPORTED) and mock_log.call_count != 1): + py_version_str = f"{version_tuple[0]}.{version_tuple[1]}" + version_info = PYTHON_VERSION_INFO[version_tuple] + + fail_msg = _create_failure_message( + expected_status, result, py_version_str, + mock_date, gapic_dep, version_info.python_eol, + eol_warning_starts, gapic_end + ) + pytest.fail(fail_msg, pytrace=False) + + +def test_override_gapic_end_only(): + """Test behavior when only gapic_end is manually overridden.""" + version_tuple = (3, 9) + original_info = PYTHON_VERSION_INFO[version_tuple] + mock_py_version = VersionInfoMock( + major=version_tuple[0], minor=version_tuple[1] + ) + + custom_gapic_end = original_info.python_eol + datetime.timedelta(days=212) + overridden_info = original_info._replace(gapic_end=custom_gapic_end) + + with patch("google.api_core._python_version_support.sys.version_info", mock_py_version): + with patch.dict('google.api_core._python_version_support.PYTHON_VERSION_INFO', {version_tuple: overridden_info}): + result_at_boundary = check_python_version(today=custom_gapic_end) + assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_EOL + + result_after_boundary = check_python_version( + today=custom_gapic_end + datetime.timedelta(days=1) + ) + assert result_after_boundary == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + + +def test_override_gapic_deprecation_only(): + """Test behavior when only gapic_deprecation is manually overridden.""" + version_tuple = (3, 9) + original_info = PYTHON_VERSION_INFO[version_tuple] + mock_py_version = VersionInfoMock( + major=version_tuple[0], minor=version_tuple[1] + ) + + custom_gapic_dep = original_info.python_eol - datetime.timedelta(days=120) + overridden_info = original_info._replace(gapic_deprecation=custom_gapic_dep) + + with patch("google.api_core._python_version_support.sys.version_info", mock_py_version): + with patch.dict('google.api_core._python_version_support.PYTHON_VERSION_INFO', {version_tuple: overridden_info}): + result_before_boundary = check_python_version( + today=custom_gapic_dep - datetime.timedelta(days=1) + ) + assert result_before_boundary == PythonVersionStatus.PYTHON_VERSION_SUPPORTED + + result_at_boundary = check_python_version(today=custom_gapic_dep) + assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_DEPRECATED + + +def test_untracked_older_version_is_unsupported(): + """Test that an old, untracked version is unsupported and logs.""" + mock_py_version = VersionInfoMock(major=3, minor=6) + + with patch("google.api_core._python_version_support.sys.version_info", mock_py_version): + with patch("google.api_core._python_version_support.logging.warning") as mock_log: + mock_date = datetime.date(2025, 1, 15) + result = check_python_version(today=mock_date) + + assert result == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + mock_log.assert_called_once() + call_args = mock_log.call_args[0][0] + assert "non-supported" in call_args + + +def test_untracked_newer_version_is_supported(): + """Test that a new, untracked version is supported and does not log.""" + mock_py_version = VersionInfoMock(major=4, minor=0) + + with patch("google.api_core._python_version_support.sys.version_info", mock_py_version): + with patch("google.api_core._python_version_support.logging.warning") as mock_log: + mock_date = datetime.date(2025, 1, 15) + result = check_python_version(today=mock_date) + + assert result == PythonVersionStatus.PYTHON_VERSION_SUPPORTED + mock_log.assert_not_called() From a136ad731c15eae1cdbf04cf7b91939b774e38dd Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Tue, 29 Jul 2025 14:29:39 -0700 Subject: [PATCH 02/58] feat: apply Python version suport warnings to api_core --- google/api_core/__init__.py | 1 + google/api_core/_python_version_support.py | 28 +++++++++++----------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index 2adbdc2d..b1f1c951 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -22,3 +22,4 @@ __version__ = api_core_version.__version__ check_python_version = _python_version_support.check_python_version +check_python_version(package="package google-api-core (google.api_core)") diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 9f953914..84523e4b 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -37,10 +37,10 @@ class VersionInfo(NamedTuple): python_beta: Optional[datetime.date] python_start: datetime.date python_eol: datetime.date - gapic_start: Optional[datetime.date] = None + gapic_start: Optional[datetime.date] = None # unused gapic_deprecation: Optional[datetime.date] = None gapic_end: Optional[datetime.date] = None - dep_unpatchable_cve: Optional[datetime.date] = None + dep_unpatchable_cve: Optional[datetime.date] = None # unused PYTHON_VERSION_INFO: Dict[Tuple[int, int], VersionInfo] = { @@ -97,7 +97,8 @@ def _flatten_message(text: str) -> str: return textwrap.dedent(text).strip().replace("\n", " ") -def check_python_version(today: Optional[datetime.date] = None) -> PythonVersionStatus: +def check_python_version(package: Optional[str] = "this package", + today: Optional[datetime.date] = None) -> PythonVersionStatus: """Check the running Python version and issue a support warning if needed. Args: @@ -146,9 +147,9 @@ def min_python(date: datetime.date) -> str: message = _flatten_message( f""" You are using a non-supported Python version ({py_version_str}). - You will receive no updates to this client library. We suggest + Google will not post any further updates to {package}. We suggest you upgrade to the latest Python version, or at least Python - {min_python(today)}, and then update this library. + {min_python(today)}, and then update {package}. """ ) logging.warning(message) @@ -159,11 +160,10 @@ def min_python(date: datetime.date) -> str: message = _flatten_message( f""" You are using a Python version ({py_version_str}) past its end - of life. This client library will continue receiving critical - bug fixes on a best-effort basis, but not any other fixes or + of life. Google will update {package} with critical + bug fixes on a best-effort basis, but not with any other fixes or features. We suggest you upgrade to the latest Python version, - or at least Python {min_python(today)}, and then update this - library. + or at least Python {min_python(today)}, and then update {package}. """ ) logging.warning(message) @@ -172,12 +172,12 @@ def min_python(date: datetime.date) -> str: if gapic_deprecation <= today <= gapic_end: message = _flatten_message( f""" - You are using a Python version ({py_version_str}), which new - releases of this client library will stop supporting when it + You are using a Python version ({py_version_str}), + which Google will stop supporting in {package} when it reaches its end of life ({version_info.python_eol}). We - suggest you upgrade to the latest Python version, or at least - Python {min_python(version_info.python_eol)}, and then update - this library. + suggest you upgrade to the latest Python version, or at + least Python {min_python(version_info.python_eol)}, and + then update {package}. """ ) logging.warning(message) From 45cd64786e7562b954dede73ec29215d6be9f670 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 30 Jul 2025 13:26:58 -0700 Subject: [PATCH 03/58] feat: add deprecation check for the protobuf package --- google/api_core/__init__.py | 5 ++ google/api_core/_python_package_support.py | 89 ++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 google/api_core/_python_package_support.py diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index b1f1c951..a605109c 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -17,9 +17,14 @@ This package contains common code and utilities used by Google client libraries. """ +from google.api_core import _python_package_support from google.api_core import _python_version_support from google.api_core import version as api_core_version __version__ = api_core_version.__version__ + check_python_version = _python_version_support.check_python_version check_python_version(package="package google-api-core (google.api_core)") + +check_dependency_versions = _python_package_support.check_dependency_versions +check_dependency_versions("google-api-core (google.api_core)") diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py new file mode 100644 index 00000000..d3d07e7c --- /dev/null +++ b/google/api_core/_python_package_support.py @@ -0,0 +1,89 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Code to check versions of dependencies used by Google Cloud Client Libraries.""" + +import logging +import sys +from typing import Optional +from ._python_version_support import _flatten_message + +# It is a good practice to alias the Version class for clarity in type hints. +from packaging.version import parse as parse_version, Version as PackagingVersion + + +def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: + """Get the parsed version of an installed package dependency. + + This function checks for an installed package and returns its version + as a `packaging.version.Version` object for safe comparison. It handles + both modern (Python 3.8+) and legacy (Python 3.7) environments. + + Args: + dependency_name: The distribution name of the package (e.g., 'requests'). + + Returns: + A `packaging.version.Version` object, or `None` if the package + is not found or another error occurs during version discovery. + """ + try: + if sys.version_info >= (3, 8): + from importlib import metadata + version_string = metadata.version(dependency_name) + return parse_version(version_string) + + # TODO: Remove this code path once we drop support for Python 3.7 + else: + # Use pkg_resources, which is part of setuptools. + import pkg_resources + version_string = pkg_resources.get_distribution(dependency_name).version + return parse_version(version_string) + + except: + return None + + +def warn_deprecation_for_versions_less_than(dependent_package:str, + dependency_name:str, + next_supported_version:str, + message_template: Optional[str] = None): + if not dependent_package or not dependency_name or not next_supported_version: + return + version_used = get_dependency_version(dependency_name) + if not version_used: + return + if version_used < parse_version(next_supported_version): + message_template = message_template or _flatten_message( + """DEPRECATION: Package {dependent_package} depends on + {dependency_name}, currently installed at version + {version_used.__str__}. Future updates to + {dependent_package} will require {dependency_name} at + version {next_supported_version} or higher. Please ensure + that either (a) your Python environment doesn't pin the + version of {dependency_name}, so that updates to + {dependent_package} can require the higher version, or (b) + you manually update your Python environment to use at + least version {next_supported_version} of + {dependency_name}.""" + ) + logging.warning( + message_template.format( + dependent_package=dependent_package, + dependency_name=dependency_name, + next_supported_version=next_supported_version, + version_used=version_used, + )) + +def check_dependency_versions(dependent_package: str): + warn_deprecation_for_versions_less_than(dependent_package, "protobuf (google.protobuf)", "4.25.8") From 25225fa517ce4dcb98647f6f287d2710644291b1 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 30 Jul 2025 15:11:48 -0700 Subject: [PATCH 04/58] format files --- google/api_core/_python_package_support.py | 20 +++-- google/api_core/_python_version_support.py | 5 +- tests/unit/test_python_version_support.py | 96 ++++++++++++++-------- 3 files changed, 80 insertions(+), 41 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index d3d07e7c..8c0b2706 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -40,6 +40,7 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: try: if sys.version_info >= (3, 8): from importlib import metadata + version_string = metadata.version(dependency_name) return parse_version(version_string) @@ -47,6 +48,7 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: else: # Use pkg_resources, which is part of setuptools. import pkg_resources + version_string = pkg_resources.get_distribution(dependency_name).version return parse_version(version_string) @@ -54,10 +56,12 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: return None -def warn_deprecation_for_versions_less_than(dependent_package:str, - dependency_name:str, - next_supported_version:str, - message_template: Optional[str] = None): +def warn_deprecation_for_versions_less_than( + dependent_package: str, + dependency_name: str, + next_supported_version: str, + message_template: Optional[str] = None, +): if not dependent_package or not dependency_name or not next_supported_version: return version_used = get_dependency_version(dependency_name) @@ -83,7 +87,11 @@ def warn_deprecation_for_versions_less_than(dependent_package:str, dependency_name=dependency_name, next_supported_version=next_supported_version, version_used=version_used, - )) + ) + ) + def check_dependency_versions(dependent_package: str): - warn_deprecation_for_versions_less_than(dependent_package, "protobuf (google.protobuf)", "4.25.8") + warn_deprecation_for_versions_less_than( + dependent_package, "protobuf (google.protobuf)", "4.25.8" + ) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 84523e4b..be22ae16 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -97,8 +97,9 @@ def _flatten_message(text: str) -> str: return textwrap.dedent(text).strip().replace("\n", " ") -def check_python_version(package: Optional[str] = "this package", - today: Optional[datetime.date] = None) -> PythonVersionStatus: +def check_python_version( + package: Optional[str] = "this package", today: Optional[datetime.date] = None +) -> PythonVersionStatus: """Check the running Python version and issue a support warning if needed. Args: diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py index 43a8c014..9f609744 100644 --- a/tests/unit/test_python_version_support.py +++ b/tests/unit/test_python_version_support.py @@ -30,8 +30,9 @@ VersionInfoMock = namedtuple("VersionInfoMock", ["major", "minor"]) -def _create_failure_message(expected, result, py_version, date, - gapic_dep, py_eol, eol_warn, gapic_end): +def _create_failure_message( + expected, result, py_version, date, gapic_dep, py_eol, eol_warn, gapic_end +): """Create a detailed failure message for a test.""" return textwrap.dedent( f""" @@ -98,38 +99,49 @@ def generate_tracked_version_test_cases(): for name, params in test_cases.items(): yield pytest.param( - version_tuple, params["date"], params["expected"], - gapic_dep, gapic_end, eol_warning_starts, - id=f"{py_version_str}-{name}" + version_tuple, + params["date"], + params["expected"], + gapic_dep, + gapic_end, + eol_warning_starts, + id=f"{py_version_str}-{name}", ) @pytest.mark.parametrize( "version_tuple, mock_date, expected_status, gapic_dep, gapic_end, eol_warning_starts", - generate_tracked_version_test_cases() + generate_tracked_version_test_cases(), ) def test_all_tracked_versions_and_date_scenarios( - version_tuple, mock_date, expected_status, gapic_dep, gapic_end, - eol_warning_starts + version_tuple, mock_date, expected_status, gapic_dep, gapic_end, eol_warning_starts ): """Test all outcomes for each tracked version using parametrization.""" - mock_py_v = VersionInfoMock( - major=version_tuple[0], minor=version_tuple[1] - ) + mock_py_v = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) with patch("google.api_core._python_version_support.sys.version_info", mock_py_v): - with patch("google.api_core._python_version_support.logging.warning") as mock_log: + with patch( + "google.api_core._python_version_support.logging.warning" + ) as mock_log: result = check_python_version(today=mock_date) - if ((result != expected_status) or - (result != PythonVersionStatus.PYTHON_VERSION_SUPPORTED) and mock_log.call_count != 1): + if ( + (result != expected_status) + or (result != PythonVersionStatus.PYTHON_VERSION_SUPPORTED) + and mock_log.call_count != 1 + ): py_version_str = f"{version_tuple[0]}.{version_tuple[1]}" version_info = PYTHON_VERSION_INFO[version_tuple] fail_msg = _create_failure_message( - expected_status, result, py_version_str, - mock_date, gapic_dep, version_info.python_eol, - eol_warning_starts, gapic_end + expected_status, + result, + py_version_str, + mock_date, + gapic_dep, + version_info.python_eol, + eol_warning_starts, + gapic_end, ) pytest.fail(fail_msg, pytrace=False) @@ -138,41 +150,51 @@ def test_override_gapic_end_only(): """Test behavior when only gapic_end is manually overridden.""" version_tuple = (3, 9) original_info = PYTHON_VERSION_INFO[version_tuple] - mock_py_version = VersionInfoMock( - major=version_tuple[0], minor=version_tuple[1] - ) + mock_py_version = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) custom_gapic_end = original_info.python_eol + datetime.timedelta(days=212) overridden_info = original_info._replace(gapic_end=custom_gapic_end) - with patch("google.api_core._python_version_support.sys.version_info", mock_py_version): - with patch.dict('google.api_core._python_version_support.PYTHON_VERSION_INFO', {version_tuple: overridden_info}): + with patch( + "google.api_core._python_version_support.sys.version_info", mock_py_version + ): + with patch.dict( + "google.api_core._python_version_support.PYTHON_VERSION_INFO", + {version_tuple: overridden_info}, + ): result_at_boundary = check_python_version(today=custom_gapic_end) assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_EOL result_after_boundary = check_python_version( today=custom_gapic_end + datetime.timedelta(days=1) ) - assert result_after_boundary == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + assert ( + result_after_boundary == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + ) def test_override_gapic_deprecation_only(): """Test behavior when only gapic_deprecation is manually overridden.""" version_tuple = (3, 9) original_info = PYTHON_VERSION_INFO[version_tuple] - mock_py_version = VersionInfoMock( - major=version_tuple[0], minor=version_tuple[1] - ) + mock_py_version = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) custom_gapic_dep = original_info.python_eol - datetime.timedelta(days=120) overridden_info = original_info._replace(gapic_deprecation=custom_gapic_dep) - with patch("google.api_core._python_version_support.sys.version_info", mock_py_version): - with patch.dict('google.api_core._python_version_support.PYTHON_VERSION_INFO', {version_tuple: overridden_info}): + with patch( + "google.api_core._python_version_support.sys.version_info", mock_py_version + ): + with patch.dict( + "google.api_core._python_version_support.PYTHON_VERSION_INFO", + {version_tuple: overridden_info}, + ): result_before_boundary = check_python_version( today=custom_gapic_dep - datetime.timedelta(days=1) ) - assert result_before_boundary == PythonVersionStatus.PYTHON_VERSION_SUPPORTED + assert ( + result_before_boundary == PythonVersionStatus.PYTHON_VERSION_SUPPORTED + ) result_at_boundary = check_python_version(today=custom_gapic_dep) assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_DEPRECATED @@ -182,8 +204,12 @@ def test_untracked_older_version_is_unsupported(): """Test that an old, untracked version is unsupported and logs.""" mock_py_version = VersionInfoMock(major=3, minor=6) - with patch("google.api_core._python_version_support.sys.version_info", mock_py_version): - with patch("google.api_core._python_version_support.logging.warning") as mock_log: + with patch( + "google.api_core._python_version_support.sys.version_info", mock_py_version + ): + with patch( + "google.api_core._python_version_support.logging.warning" + ) as mock_log: mock_date = datetime.date(2025, 1, 15) result = check_python_version(today=mock_date) @@ -197,8 +223,12 @@ def test_untracked_newer_version_is_supported(): """Test that a new, untracked version is supported and does not log.""" mock_py_version = VersionInfoMock(major=4, minor=0) - with patch("google.api_core._python_version_support.sys.version_info", mock_py_version): - with patch("google.api_core._python_version_support.logging.warning") as mock_log: + with patch( + "google.api_core._python_version_support.sys.version_info", mock_py_version + ): + with patch( + "google.api_core._python_version_support.logging.warning" + ) as mock_log: mock_date = datetime.date(2025, 1, 15) result = check_python_version(today=mock_date) From 1d567c08cd39c0c981152799e1b4c3732e4b4627 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 30 Jul 2025 15:26:03 -0700 Subject: [PATCH 05/58] fix lint warning --- google/api_core/_python_package_support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 8c0b2706..926aba8b 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -52,7 +52,7 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: version_string = pkg_resources.get_distribution(dependency_name).version return parse_version(version_string) - except: + except Exception: return None From f1dbbb473fa778552a02ae9d91f9649c9f649e34 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 30 Jul 2025 15:26:12 -0700 Subject: [PATCH 06/58] add docstring to `warn_deprecation_for_versions_less_than` --- google/api_core/_python_package_support.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 926aba8b..b8602dfa 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -62,6 +62,25 @@ def warn_deprecation_for_versions_less_than( next_supported_version: str, message_template: Optional[str] = None, ): + """Issue a deprecation warning for outdated versions of `dependency_name`. + + If `dependency_name` is installed at a version less than + `next_supported_versions`, this issues a warning using either a + default `message_template` or one provided by the user. The + default `message_template informs users that they will not receive + future updates `dependent_package` if `dependency_name` is somehow + pinned to a version lower than `next_supported_version`. + + Args: + dependent_package: The distribution name of the package that needs `dependency_name`. + dependency_name: The distribution name oft he dependency to check. + next_supported_version: The version number below which a deprecation warning will be logged. + message_template: A custom default message template to replace + the default. This `message_template` is treated as an + f-string, where the following variables are defined: + `dependency_name`, `dependent_package`, + `next_supported_version`, and `version_used`. + """ if not dependent_package or not dependency_name or not next_supported_version: return version_used = get_dependency_version(dependency_name) From e3fd56f230475dd7d17ff504f5ed5af58b037a82 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 30 Jul 2025 15:29:32 -0700 Subject: [PATCH 07/58] Add/fix docstrings --- google/api_core/_python_package_support.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index b8602dfa..e45afdff 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -72,9 +72,11 @@ def warn_deprecation_for_versions_less_than( pinned to a version lower than `next_supported_version`. Args: - dependent_package: The distribution name of the package that needs `dependency_name`. + dependent_package: The distribution name of the package that + needs `dependency_name`. dependency_name: The distribution name oft he dependency to check. - next_supported_version: The version number below which a deprecation warning will be logged. + next_supported_version: The version number below which a deprecation + warning will be logged. message_template: A custom default message template to replace the default. This `message_template` is treated as an f-string, where the following variables are defined: @@ -111,6 +113,17 @@ def warn_deprecation_for_versions_less_than( def check_dependency_versions(dependent_package: str): + """Bundle checks for all package dependencies. + + This function can be called by all depndents of google.api_core, + to emit needed deprecation warnings for any of their + dependencies. The dependencies to check should be updated here. + + Args: + dependent_package: The distribution name of the calling package, whose + dependencies we're checking. + + """ warn_deprecation_for_versions_less_than( dependent_package, "protobuf (google.protobuf)", "4.25.8" ) From 8b1dfb14d5d699d61339b765c7e20a7a623c10ed Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Thu, 31 Jul 2025 13:23:58 -0700 Subject: [PATCH 08/58] fix typo --- google/api_core/_python_package_support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index e45afdff..1a94ad23 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -115,7 +115,7 @@ def warn_deprecation_for_versions_less_than( def check_dependency_versions(dependent_package: str): """Bundle checks for all package dependencies. - This function can be called by all depndents of google.api_core, + This function can be called by all dependents of google.api_core, to emit needed deprecation warnings for any of their dependencies. The dependencies to check should be updated here. From 4b9208e322a10ec8a0983a87a7a98ffcaa5ddf2a Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Thu, 31 Jul 2025 13:43:04 -0700 Subject: [PATCH 09/58] add test for _python_package_support.py --- tests/unit/test_python_package_support.py | 99 +++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/unit/test_python_package_support.py diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py new file mode 100644 index 00000000..f7167842 --- /dev/null +++ b/tests/unit/test_python_package_support.py @@ -0,0 +1,99 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from unittest.mock import patch, MagicMock + +import pytest +from packaging.version import parse as parse_version + +from google.api_core._python_package_support import ( + get_dependency_version, + warn_deprecation_for_versions_less_than, +) + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python3.8 or higher") +@patch("importlib.metadata.version") +def test_get_dependency_version_py38_plus(mock_version): + """Test get_dependency_version on Python 3.8+.""" + mock_version.return_value = "1.2.3" + assert get_dependency_version("some-package") == parse_version("1.2.3") + mock_version.assert_called_once_with("some-package") + + # Test package not found + mock_version.side_effect = ImportError + assert get_dependency_version("not-a-package") is None + + +@pytest.mark.skipif(sys.version_info >= (3, 8), reason="requires python3.7") +@patch("pkg_resources.get_distribution") +def test_get_dependency_version_py37(mock_get_distribution): + """Test get_dependency_version on Python 3.7.""" + mock_dist = MagicMock() + mock_dist.version = "4.5.6" + mock_get_distribution.return_value = mock_dist + assert get_dependency_version("another-package") == parse_version("4.5.6") + mock_get_distribution.assert_called_once_with("another-package") + + # Test package not found + mock_get_distribution.side_effect = Exception # pkg_resources has its own exception types + assert get_dependency_version("not-a-package") is None + + +@patch("google.api_core._python_package_support.get_dependency_version") +@patch("google.api_core._python_package_support.logging.warning") +def test_warn_deprecation_for_versions_less_than(mock_log_warning, mock_get_version): + """Test the deprecation warning logic.""" + # Case 1: Installed version is less than required, should warn. + mock_get_version.return_value = parse_version("1.0.0") + warn_deprecation_for_versions_less_than( + "my-package", "dep-package", "2.0.0" + ) + mock_log_warning.assert_called_once() + assert "DEPRECATION: Package my-package depends on dep-package" in mock_log_warning.call_args[0][0] + + # Case 2: Installed version is equal to required, should not warn. + mock_log_warning.reset_mock() + mock_get_version.return_value = parse_version("2.0.0") + warn_deprecation_for_versions_less_than( + "my-package", "dep-package", "2.0.0" + ) + mock_log_warning.assert_not_called() + + # Case 3: Installed version is greater than required, should not warn. + mock_log_warning.reset_mock() + mock_get_version.return_value = parse_version("3.0.0") + warn_deprecation_for_versions_less_than( + "my-package", "dep-package", "2.0.0" + ) + mock_log_warning.assert_not_called() + + # Case 4: Dependency not found, should not warn. + mock_log_warning.reset_mock() + mock_get_version.return_value = None + warn_deprecation_for_versions_less_than( + "my-package", "dep-package", "2.0.0" + ) + mock_log_warning.assert_not_called() + + # Case 5: Custom message template. + mock_log_warning.reset_mock() + mock_get_version.return_value = parse_version("1.0.0") + template = "Custom warning for {dependency_name} used by {dependent_package}." + warn_deprecation_for_versions_less_than( + "my-package", "dep-package", "2.0.0", message_template=template + ) + mock_log_warning.assert_called_once() + assert "Custom warning for dep-package used by my-package." in mock_log_warning.call_args[0][0] From 5cf8652e8e7ec4299330fa096bf6408eda06cfa8 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Thu, 31 Jul 2025 13:43:32 -0700 Subject: [PATCH 10/58] add constants for various buffer periods --- google/api_core/_python_version_support.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index be22ae16..6ef6b28f 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -90,6 +90,8 @@ class VersionInfo(NamedTuple): LOWEST_TRACKED_VERSION = min(PYTHON_VERSION_INFO.keys()) FAKE_PAST_DATE = datetime.date(1970, 1, 1) FAKE_FUTURE_DATE = datetime.date(9000, 1, 1) +DEPRECATION_WARNING_PERIOD = datetime.timedelta(days=365) +EOL_GRACE_PERIOD = datetime.timedelta(weeks=1) def _flatten_message(text: str) -> str: @@ -131,10 +133,10 @@ def check_python_version( ) gapic_deprecation = version_info.gapic_deprecation or ( - version_info.python_eol - datetime.timedelta(days=365) + version_info.python_eol - DEPRECATION_WARNING_PERIOD ) gapic_end = version_info.gapic_end or ( - version_info.python_eol + datetime.timedelta(weeks=1) + version_info.python_eol + EOL_GRACE_PERIOD ) def min_python(date: datetime.date) -> str: @@ -156,7 +158,7 @@ def min_python(date: datetime.date) -> str: logging.warning(message) return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED - eol_date = version_info.python_eol + datetime.timedelta(weeks=1) + eol_date = version_info.python_eol + EOL_GRACE_PERIOD if eol_date <= today <= gapic_end: message = _flatten_message( f""" @@ -184,4 +186,4 @@ def min_python(date: datetime.date) -> str: logging.warning(message) return PythonVersionStatus.PYTHON_VERSION_DEPRECATED - return PythonVersionStatus.PYTHON_VERSION_SUPPORTED + return PythonVersionStatus.PYTHON_VERSION_SUPPORTED \ No newline at end of file From 7d8b1c739d5b67ab7069cc0ea90bb5b41ac06a4b Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Thu, 31 Jul 2025 15:03:03 -0700 Subject: [PATCH 11/58] Update warning code to only require import package names We automatically get the distribution package names under the hood. --- google/api_core/__init__.py | 4 +- google/api_core/_python_package_support.py | 72 +++++++++++++------- google/api_core/_python_version_support.py | 79 +++++++++++++++------- tests/unit/test_python_package_support.py | 30 ++++---- 4 files changed, 119 insertions(+), 66 deletions(-) diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index a605109c..78d11424 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -24,7 +24,7 @@ __version__ = api_core_version.__version__ check_python_version = _python_version_support.check_python_version -check_python_version(package="package google-api-core (google.api_core)") +check_python_version(package="google.api_core") check_dependency_versions = _python_package_support.check_dependency_versions -check_dependency_versions("google-api-core (google.api_core)") +check_dependency_versions("google.api_core") diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 1a94ad23..42878a45 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -17,7 +17,10 @@ import logging import sys from typing import Optional -from ._python_version_support import _flatten_message +from ._python_version_support import ( + _flatten_message, + _get_distribution_and_import_packages, +) # It is a good practice to alias the Version class for clarity in type hints. from packaging.version import parse as parse_version, Version as PackagingVersion @@ -57,62 +60,79 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: def warn_deprecation_for_versions_less_than( - dependent_package: str, - dependency_name: str, + dependent_import_package: str, + dependency_import_package: str, next_supported_version: str, message_template: Optional[str] = None, ): - """Issue a deprecation warning for outdated versions of `dependency_name`. + """Issue any needed deprecation warnings for `dependency_import_package`. - If `dependency_name` is installed at a version less than + If `dependency_import_package` is installed at a version less than `next_supported_versions`, this issues a warning using either a default `message_template` or one provided by the user. The default `message_template informs users that they will not receive - future updates `dependent_package` if `dependency_name` is somehow - pinned to a version lower than `next_supported_version`. + future updates `dependent_import_package` if + `dependency_import_package` is somehow pinned to a version lower + than `next_supported_version`. Args: - dependent_package: The distribution name of the package that - needs `dependency_name`. - dependency_name: The distribution name oft he dependency to check. + dependent_import_package: The import name of the package that + needs `dependency_import_package`. + dependency_import_package: The import name of the dependency to check. next_supported_version: The version number below which a deprecation warning will be logged. message_template: A custom default message template to replace the default. This `message_template` is treated as an f-string, where the following variables are defined: - `dependency_name`, `dependent_package`, - `next_supported_version`, and `version_used`. + `dependency_import_package`, `dependent_import_package`; + `dependency_packages` and `dependent_packages`, which contain both the + distribution and import packages for the dependency and the dependent, + respectively; and `next_supported_version`, and `version_used`, which + refer to supported and currently-used versions of the dependency. + """ - if not dependent_package or not dependency_name or not next_supported_version: + if ( + not dependent_import_package + or not dependency_import_package + or not next_supported_version + ): return - version_used = get_dependency_version(dependency_name) + version_used = get_dependency_version(dependency_import_package) if not version_used: return if version_used < parse_version(next_supported_version): + ( + dependency_packages, + dependency_distribution_package, + ) = _get_distribution_and_import_packages(dependency_import_package) + ( + dependent_packages, + dependent_distribution_package, + ) = _get_distribution_and_import_packages(dependent_import_package) message_template = message_template or _flatten_message( - """DEPRECATION: Package {dependent_package} depends on - {dependency_name}, currently installed at version + """DEPRECATION: Package {dependent_packages} depends on + {dependency_packages}, currently installed at version {version_used.__str__}. Future updates to - {dependent_package} will require {dependency_name} at + {dependent_packages} will require {dependency_packages} at version {next_supported_version} or higher. Please ensure that either (a) your Python environment doesn't pin the - version of {dependency_name}, so that updates to - {dependent_package} can require the higher version, or (b) - you manually update your Python environment to use at + version of {dependency_packages}, so that updates to + {dependent_packages} can require the higher version, or + (b) you manually update your Python environment to use at least version {next_supported_version} of - {dependency_name}.""" + {dependency_packages}.""" ) logging.warning( message_template.format( - dependent_package=dependent_package, - dependency_name=dependency_name, + dependent_import_package=dependent_import_package, + dependency_import_package=dependency_import_package, next_supported_version=next_supported_version, version_used=version_used, ) ) -def check_dependency_versions(dependent_package: str): +def check_dependency_versions(dependent_import_package: str): """Bundle checks for all package dependencies. This function can be called by all dependents of google.api_core, @@ -120,10 +140,10 @@ def check_dependency_versions(dependent_package: str): dependencies. The dependencies to check should be updated here. Args: - dependent_package: The distribution name of the calling package, whose + dependent_import_package: The distribution name of the calling package, whose dependencies we're checking. """ warn_deprecation_for_versions_less_than( - dependent_package, "protobuf (google.protobuf)", "4.25.8" + dependent_import_package, "google.protobuf", "4.25.8" ) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 6ef6b28f..3a915789 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -99,6 +99,44 @@ def _flatten_message(text: str) -> str: return textwrap.dedent(text).strip().replace("\n", " ") +# TODO: Remove once we no longer support Python3.7 +if sys.version_info < (3, 8): + + def _get_pypi_package_name(module_name): + """Determine the PyPI package name for a given module name.""" + return None + +else: + from importlib import metadata + + def _get_pypi_package_name(module_name): + """Determine the PyPI package name for a given module name.""" + try: + # Get the mapping of modules to distributions + module_to_distributions = metadata.packages_distributions() + + # Check if the module is found in the mapping + if module_name in module_to_distributions: + # The value is a list of distribution names, take the first one + return module_to_distributions[module_name][0] + else: + return None # Module not found in the mapping + except Exception as e: + print(f"An error occurred: {e}") + return None + + +def _get_distribution_and_import_packages(import_package: str) -> Optional[str]: + """Return a pretty string with distribution & import package names.""" + distribution_package = _get_pypi_package_name(import_package) + dependency_distribution_and_import_packages = ( + f"package {distribution_package} ({import_package})" + if distribution_package + else import_package + ) + return dependency_distribution_and_import_packages, distribution_package + + def check_python_version( package: Optional[str] = "this package", today: Optional[datetime.date] = None ) -> PythonVersionStatus: @@ -111,6 +149,7 @@ def check_python_version( The support status of the current Python version. """ today = today or datetime.date.today() + package_label, _ = _get_distribution_and_import_packages(package) python_version = sys.version_info version_tuple = (python_version.major, python_version.minor) @@ -135,9 +174,7 @@ def check_python_version( gapic_deprecation = version_info.gapic_deprecation or ( version_info.python_eol - DEPRECATION_WARNING_PERIOD ) - gapic_end = version_info.gapic_end or ( - version_info.python_eol + EOL_GRACE_PERIOD - ) + gapic_end = version_info.gapic_end or (version_info.python_eol + EOL_GRACE_PERIOD) def min_python(date: datetime.date) -> str: """Find the minimum supported Python version for a given date.""" @@ -148,12 +185,11 @@ def min_python(date: datetime.date) -> str: if gapic_end < today: message = _flatten_message( - f""" - You are using a non-supported Python version ({py_version_str}). - Google will not post any further updates to {package}. We suggest - you upgrade to the latest Python version, or at least Python - {min_python(today)}, and then update {package}. - """ + f"""You are using a non-supported Python version + ({py_version_str}). Google will not post any further + updates to {package_label}. We suggest you upgrade to the + latest Python version, or at least Python + {min_python(today)}, and then update {package_label}. """ ) logging.warning(message) return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED @@ -161,29 +197,26 @@ def min_python(date: datetime.date) -> str: eol_date = version_info.python_eol + EOL_GRACE_PERIOD if eol_date <= today <= gapic_end: message = _flatten_message( - f""" - You are using a Python version ({py_version_str}) past its end - of life. Google will update {package} with critical - bug fixes on a best-effort basis, but not with any other fixes or - features. We suggest you upgrade to the latest Python version, - or at least Python {min_python(today)}, and then update {package}. - """ + f"""You are using a Python version ({py_version_str}) + past its end of life. Google will update {package_label} + with critical bug fixes on a best-effort basis, but not + with any other fixes or features. We suggest you upgrade + to the latest Python version, or at least Python + {min_python(today)}, and then update {package_label}.""" ) logging.warning(message) return PythonVersionStatus.PYTHON_VERSION_EOL if gapic_deprecation <= today <= gapic_end: message = _flatten_message( - f""" - You are using a Python version ({py_version_str}), - which Google will stop supporting in {package} when it - reaches its end of life ({version_info.python_eol}). We + f"""You are using a Python version ({py_version_str}), + which Google will stop supporting in {package_label} when + it reaches its end of life ({version_info.python_eol}). We suggest you upgrade to the latest Python version, or at least Python {min_python(version_info.python_eol)}, and - then update {package}. - """ + then update {package_label}.""" ) logging.warning(message) return PythonVersionStatus.PYTHON_VERSION_DEPRECATED - return PythonVersionStatus.PYTHON_VERSION_SUPPORTED \ No newline at end of file + return PythonVersionStatus.PYTHON_VERSION_SUPPORTED diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index f7167842..d32b336c 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -48,7 +48,9 @@ def test_get_dependency_version_py37(mock_get_distribution): mock_get_distribution.assert_called_once_with("another-package") # Test package not found - mock_get_distribution.side_effect = Exception # pkg_resources has its own exception types + mock_get_distribution.side_effect = ( + Exception # pkg_resources has its own exception types + ) assert get_dependency_version("not-a-package") is None @@ -58,34 +60,29 @@ def test_warn_deprecation_for_versions_less_than(mock_log_warning, mock_get_vers """Test the deprecation warning logic.""" # Case 1: Installed version is less than required, should warn. mock_get_version.return_value = parse_version("1.0.0") - warn_deprecation_for_versions_less_than( - "my-package", "dep-package", "2.0.0" - ) + warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0") mock_log_warning.assert_called_once() - assert "DEPRECATION: Package my-package depends on dep-package" in mock_log_warning.call_args[0][0] + assert ( + "DEPRECATION: Package my-package depends on dep-package" + in mock_log_warning.call_args[0][0] + ) # Case 2: Installed version is equal to required, should not warn. mock_log_warning.reset_mock() mock_get_version.return_value = parse_version("2.0.0") - warn_deprecation_for_versions_less_than( - "my-package", "dep-package", "2.0.0" - ) + warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0") mock_log_warning.assert_not_called() # Case 3: Installed version is greater than required, should not warn. mock_log_warning.reset_mock() mock_get_version.return_value = parse_version("3.0.0") - warn_deprecation_for_versions_less_than( - "my-package", "dep-package", "2.0.0" - ) + warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0") mock_log_warning.assert_not_called() # Case 4: Dependency not found, should not warn. mock_log_warning.reset_mock() mock_get_version.return_value = None - warn_deprecation_for_versions_less_than( - "my-package", "dep-package", "2.0.0" - ) + warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0") mock_log_warning.assert_not_called() # Case 5: Custom message template. @@ -96,4 +93,7 @@ def test_warn_deprecation_for_versions_less_than(mock_log_warning, mock_get_vers "my-package", "dep-package", "2.0.0", message_template=template ) mock_log_warning.assert_called_once() - assert "Custom warning for dep-package used by my-package." in mock_log_warning.call_args[0][0] + assert ( + "Custom warning for dep-package used by my-package." + in mock_log_warning.call_args[0][0] + ) From cda8e2709cb6f247547a998c5aa2d370a332fa9f Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Thu, 31 Jul 2025 15:15:49 -0700 Subject: [PATCH 12/58] Fix messaegs and test --- google/api_core/_python_package_support.py | 8 +++-- google/api_core/_python_version_support.py | 18 +++++++---- tests/unit/test_python_package_support.py | 35 ++++++++++++++++------ 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 42878a45..9cf86baf 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -110,7 +110,8 @@ def warn_deprecation_for_versions_less_than( dependent_distribution_package, ) = _get_distribution_and_import_packages(dependent_import_package) message_template = message_template or _flatten_message( - """DEPRECATION: Package {dependent_packages} depends on + """ + DEPRECATION: Package {dependent_packages} depends on {dependency_packages}, currently installed at version {version_used.__str__}. Future updates to {dependent_packages} will require {dependency_packages} at @@ -120,12 +121,15 @@ def warn_deprecation_for_versions_less_than( {dependent_packages} can require the higher version, or (b) you manually update your Python environment to use at least version {next_supported_version} of - {dependency_packages}.""" + {dependency_packages}. + """ ) logging.warning( message_template.format( dependent_import_package=dependent_import_package, dependency_import_package=dependency_import_package, + dependency_packages=dependency_packages, + dependent_packages=dependent_packages, next_supported_version=next_supported_version, version_used=version_used, ) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 3a915789..1b77fe04 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -185,11 +185,13 @@ def min_python(date: datetime.date) -> str: if gapic_end < today: message = _flatten_message( - f"""You are using a non-supported Python version + f""" + You are using a non-supported Python version ({py_version_str}). Google will not post any further updates to {package_label}. We suggest you upgrade to the latest Python version, or at least Python - {min_python(today)}, and then update {package_label}. """ + {min_python(today)}, and then update {package_label}. + """ ) logging.warning(message) return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED @@ -197,24 +199,28 @@ def min_python(date: datetime.date) -> str: eol_date = version_info.python_eol + EOL_GRACE_PERIOD if eol_date <= today <= gapic_end: message = _flatten_message( - f"""You are using a Python version ({py_version_str}) + f""" + You are using a Python version ({py_version_str}) past its end of life. Google will update {package_label} with critical bug fixes on a best-effort basis, but not with any other fixes or features. We suggest you upgrade to the latest Python version, or at least Python - {min_python(today)}, and then update {package_label}.""" + {min_python(today)}, and then update {package_label}. + """ ) logging.warning(message) return PythonVersionStatus.PYTHON_VERSION_EOL if gapic_deprecation <= today <= gapic_end: message = _flatten_message( - f"""You are using a Python version ({py_version_str}), + f""" + You are using a Python version ({py_version_str}), which Google will stop supporting in {package_label} when it reaches its end of life ({version_info.python_eol}). We suggest you upgrade to the latest Python version, or at least Python {min_python(version_info.python_eol)}, and - then update {package_label}.""" + then update {package_label}. + """ ) logging.warning(message) return PythonVersionStatus.PYTHON_VERSION_DEPRECATED diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index d32b336c..b037a9d5 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -54,46 +54,63 @@ def test_get_dependency_version_py37(mock_get_distribution): assert get_dependency_version("not-a-package") is None +@patch("google.api_core._python_package_support._get_distribution_and_import_packages") @patch("google.api_core._python_package_support.get_dependency_version") @patch("google.api_core._python_package_support.logging.warning") -def test_warn_deprecation_for_versions_less_than(mock_log_warning, mock_get_version): +def test_warn_deprecation_for_versions_less_than( + mock_log_warning, mock_get_version, mock_get_packages +): """Test the deprecation warning logic.""" + # Mock the helper function to return predictable package strings + mock_get_packages.side_effect = [ + ("dep-package (dep.package)", "dep-package"), + ("my-package (my.package)", "my-package"), + ] + # Case 1: Installed version is less than required, should warn. mock_get_version.return_value = parse_version("1.0.0") - warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0") + warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") mock_log_warning.assert_called_once() assert ( - "DEPRECATION: Package my-package depends on dep-package" + "DEPRECATION: Package my-package (my.package) depends on dep-package (dep.package)" in mock_log_warning.call_args[0][0] ) # Case 2: Installed version is equal to required, should not warn. mock_log_warning.reset_mock() + mock_get_packages.reset_mock() mock_get_version.return_value = parse_version("2.0.0") - warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0") + warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") mock_log_warning.assert_not_called() # Case 3: Installed version is greater than required, should not warn. mock_log_warning.reset_mock() + mock_get_packages.reset_mock() mock_get_version.return_value = parse_version("3.0.0") - warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0") + warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") mock_log_warning.assert_not_called() # Case 4: Dependency not found, should not warn. mock_log_warning.reset_mock() + mock_get_packages.reset_mock() mock_get_version.return_value = None - warn_deprecation_for_versions_less_than("my-package", "dep-package", "2.0.0") + warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") mock_log_warning.assert_not_called() # Case 5: Custom message template. mock_log_warning.reset_mock() + mock_get_packages.reset_mock() + mock_get_packages.side_effect = [ + ("dep-package (dep.package)", "dep-package"), + ("my-package (my.package)", "my-package"), + ] mock_get_version.return_value = parse_version("1.0.0") - template = "Custom warning for {dependency_name} used by {dependent_package}." + template = "Custom warning for {dependency_packages} used by {dependent_packages}." warn_deprecation_for_versions_less_than( - "my-package", "dep-package", "2.0.0", message_template=template + "my.package", "dep.package", "2.0.0", message_template=template ) mock_log_warning.assert_called_once() assert ( - "Custom warning for dep-package used by my-package." + "Custom warning for dep-package (dep.package) used by my-package (my.package)." in mock_log_warning.call_args[0][0] ) From db92faced4621646ff6a1b794181b23358c8ba61 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Thu, 31 Jul 2025 15:18:07 -0700 Subject: [PATCH 13/58] Add TODO: provide the functionality in previous versions of api_core We have to add this functionality manually if the user has not upgraded to this version of api_copre --- google/api_core/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index 78d11424..4f6c9bd3 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -23,6 +23,10 @@ __version__ = api_core_version.__version__ +# TODO: Until dependent artifacts require this version of +# google.api_core, the functionality below must be made available +# manually in those artifacts. + check_python_version = _python_version_support.check_python_version check_python_version(package="google.api_core") From 118a7c819bc5658adf2ca9c8d327f1196f38e9bd Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Tue, 12 Aug 2025 10:02:59 -0700 Subject: [PATCH 14/58] Fix mypy failures --- google/api_core/_python_version_support.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 1b77fe04..f7d2d565 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -19,7 +19,7 @@ import logging import sys import textwrap -from typing import NamedTuple, Optional, Dict, Tuple +from typing import Any, NamedTuple, Optional, Dict, Tuple class PythonVersionStatus(enum.Enum): @@ -126,7 +126,7 @@ def _get_pypi_package_name(module_name): return None -def _get_distribution_and_import_packages(import_package: str) -> Optional[str]: +def _get_distribution_and_import_packages(import_package: str) -> Tuple[str, Any]: """Return a pretty string with distribution & import package names.""" distribution_package = _get_pypi_package_name(import_package) dependency_distribution_and_import_packages = ( @@ -138,7 +138,7 @@ def _get_distribution_and_import_packages(import_package: str) -> Optional[str]: def check_python_version( - package: Optional[str] = "this package", today: Optional[datetime.date] = None + package: str = "this package", today: Optional[datetime.date] = None ) -> PythonVersionStatus: """Check the running Python version and issue a support warning if needed. @@ -188,7 +188,7 @@ def min_python(date: datetime.date) -> str: f""" You are using a non-supported Python version ({py_version_str}). Google will not post any further - updates to {package_label}. We suggest you upgrade to the + updates to {package_label}. Please upgrade to the latest Python version, or at least Python {min_python(today)}, and then update {package_label}. """ @@ -203,7 +203,7 @@ def min_python(date: datetime.date) -> str: You are using a Python version ({py_version_str}) past its end of life. Google will update {package_label} with critical bug fixes on a best-effort basis, but not - with any other fixes or features. We suggest you upgrade + with any other fixes or features. Please upgrade to the latest Python version, or at least Python {min_python(today)}, and then update {package_label}. """ @@ -214,10 +214,10 @@ def min_python(date: datetime.date) -> str: if gapic_deprecation <= today <= gapic_end: message = _flatten_message( f""" - You are using a Python version ({py_version_str}), + You are using a Python version ({py_version_str}) which Google will stop supporting in {package_label} when - it reaches its end of life ({version_info.python_eol}). We - suggest you upgrade to the latest Python version, or at + it reaches its end of life ({version_info.python_eol}). Please + upgrade to the latest Python version, or at least Python {min_python(version_info.python_eol)}, and then update {package_label}. """ From f1c46aa67cad30af2956530449a31a4fae27e725 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Tue, 12 Aug 2025 12:10:15 -0700 Subject: [PATCH 15/58] Try to remove a round-off error causing a test mock failure --- tests/unit/gapic/test_method.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/gapic/test_method.py b/tests/unit/gapic/test_method.py index c27de64e..5aa10240 100644 --- a/tests/unit/gapic/test_method.py +++ b/tests/unit/gapic/test_method.py @@ -198,7 +198,8 @@ def test_wrap_method_with_overriding_timeout_as_a_number(): method, default_retry, default_timeout ) - result = wrapped_method(timeout=22) + specified_timeout = 22 + result = wrapped_method(timeout=specified_timeout) assert result == 42 From 474ec0dcd53fddb8f590d25ad21b83e22538516b Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Tue, 12 Aug 2025 15:18:01 -0700 Subject: [PATCH 16/58] Remove more potential test failures/warnings Removed @pytest.mark.asyncio Defined literl timeouts as a specifie_timeout local variable --- tests/unit/gapic/test_method.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/unit/gapic/test_method.py b/tests/unit/gapic/test_method.py index 5aa10240..302f648e 100644 --- a/tests/unit/gapic/test_method.py +++ b/tests/unit/gapic/test_method.py @@ -177,16 +177,19 @@ def test_wrap_method_with_overriding_retry_timeout_compression(unused_sleep): method, default_retry, default_timeout, default_compression ) + specified_timeout = 22 result = wrapped_method( retry=retry.Retry(retry.if_exception_type(exceptions.NotFound)), - timeout=timeout.ConstantTimeout(22), + timeout=timeout.ConstantTimeout(specified_timeout), compression=grpc.Compression.Deflate, ) assert result == 42 assert method.call_count == 2 method.assert_called_with( - timeout=22, compression=grpc.Compression.Deflate, metadata=mock.ANY + timeout=specified_timeout, + compression=grpc.Compression.Deflate, + metadata=mock.ANY ) From 2083b49cec7738530de88b0ea8c0024db0a03a57 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Tue, 12 Aug 2025 15:22:41 -0700 Subject: [PATCH 17/58] Format --- tests/unit/gapic/test_method.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/gapic/test_method.py b/tests/unit/gapic/test_method.py index 302f648e..46634cf3 100644 --- a/tests/unit/gapic/test_method.py +++ b/tests/unit/gapic/test_method.py @@ -189,7 +189,7 @@ def test_wrap_method_with_overriding_retry_timeout_compression(unused_sleep): method.assert_called_with( timeout=specified_timeout, compression=grpc.Compression.Deflate, - metadata=mock.ANY + metadata=mock.ANY, ) From 4ea124e7e17257ea05c43ba43ac3863f5629556a Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Tue, 12 Aug 2025 15:52:36 -0700 Subject: [PATCH 18/58] Try making the specified_timeout a float --- tests/unit/gapic/test_method.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/gapic/test_method.py b/tests/unit/gapic/test_method.py index 46634cf3..c901c040 100644 --- a/tests/unit/gapic/test_method.py +++ b/tests/unit/gapic/test_method.py @@ -177,7 +177,7 @@ def test_wrap_method_with_overriding_retry_timeout_compression(unused_sleep): method, default_retry, default_timeout, default_compression ) - specified_timeout = 22 + specified_timeout = 22.0 result = wrapped_method( retry=retry.Retry(retry.if_exception_type(exceptions.NotFound)), timeout=timeout.ConstantTimeout(specified_timeout), @@ -201,7 +201,7 @@ def test_wrap_method_with_overriding_timeout_as_a_number(): method, default_retry, default_timeout ) - specified_timeout = 22 + specified_timeout = 22.0 result = wrapped_method(timeout=specified_timeout) assert result == 42 From c5949c4b201ef147fc0080245ee2aae0024eb50b Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 3 Sep 2025 13:46:19 -0700 Subject: [PATCH 19/58] fix: tweak message parameter names --- google/api_core/_python_package_support.py | 32 +++++++++++----------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 9cf86baf..f8eeffdf 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -68,9 +68,9 @@ def warn_deprecation_for_versions_less_than( """Issue any needed deprecation warnings for `dependency_import_package`. If `dependency_import_package` is installed at a version less than - `next_supported_versions`, this issues a warning using either a + `next_supported_version`, this issues a warning using either a default `message_template` or one provided by the user. The - default `message_template informs users that they will not receive + default `message_template` informs the user that they will not receive future updates `dependent_import_package` if `dependency_import_package` is somehow pinned to a version lower than `next_supported_version`. @@ -84,10 +84,10 @@ def warn_deprecation_for_versions_less_than( message_template: A custom default message template to replace the default. This `message_template` is treated as an f-string, where the following variables are defined: - `dependency_import_package`, `dependent_import_package`; - `dependency_packages` and `dependent_packages`, which contain both the - distribution and import packages for the dependency and the dependent, - respectively; and `next_supported_version`, and `version_used`, which + `dependency_import_package`, `dependent_import_package` and + `dependency_package`, `dependent_package`, which contain both the + import and distribution packages for the dependency and the dependent, + respectively; and `next_supported_version` and `version_used`, which refer to supported and currently-used versions of the dependency. """ @@ -102,34 +102,34 @@ def warn_deprecation_for_versions_less_than( return if version_used < parse_version(next_supported_version): ( - dependency_packages, + dependency_package, dependency_distribution_package, ) = _get_distribution_and_import_packages(dependency_import_package) ( - dependent_packages, + dependent_package, dependent_distribution_package, ) = _get_distribution_and_import_packages(dependent_import_package) message_template = message_template or _flatten_message( """ - DEPRECATION: Package {dependent_packages} depends on - {dependency_packages}, currently installed at version + DEPRECATION: Package {dependent_package} depends on + {dependency_package}, currently installed at version {version_used.__str__}. Future updates to - {dependent_packages} will require {dependency_packages} at + {dependent_package} will require {dependency_package} at version {next_supported_version} or higher. Please ensure that either (a) your Python environment doesn't pin the - version of {dependency_packages}, so that updates to - {dependent_packages} can require the higher version, or + version of {dependency_package}, so that updates to + {dependent_package} can require the higher version, or (b) you manually update your Python environment to use at least version {next_supported_version} of - {dependency_packages}. + {dependency_package}. """ ) logging.warning( message_template.format( dependent_import_package=dependent_import_package, dependency_import_package=dependency_import_package, - dependency_packages=dependency_packages, - dependent_packages=dependent_packages, + dependency_package=dependency_package, + dependent_package=dependent_package, next_supported_version=next_supported_version, version_used=version_used, ) From 6fc1473cccc5a826737148e2fd7bd372352a335c Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 3 Sep 2025 13:46:51 -0700 Subject: [PATCH 20/58] fix: add PYTHON_VERSION_STATUS_UNSPECIFIED enum value --- google/api_core/_python_version_support.py | 1 + 1 file changed, 1 insertion(+) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index f7d2d565..d62be7c9 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -25,6 +25,7 @@ class PythonVersionStatus(enum.Enum): """Represent the support status of a Python version.""" + PYTHON_VERSION_STATUS_UNSPECIFIED = "PYTHON_VERSION_STATUS_UNSPECIFIED" PYTHON_VERSION_UNSUPPORTED = "PYTHON_VERSION_UNSUPPORTED" PYTHON_VERSION_EOL = "PYTHON_VERSION_EOL" PYTHON_VERSION_DEPRECATED = "PYTHON_VERSION_DEPRECATED" From 8829e200f916fd04791a1787f432cea75c14cd45 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 3 Sep 2025 13:47:32 -0700 Subject: [PATCH 21/58] docs: tweak TODOs --- google/api_core/__init__.py | 2 +- google/api_core/_python_package_support.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index 4f6c9bd3..b41dcb82 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -23,7 +23,7 @@ __version__ = api_core_version.__version__ -# TODO: Until dependent artifacts require this version of +# NOTE: Until dependent artifacts require this version of # google.api_core, the functionality below must be made available # manually in those artifacts. diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index f8eeffdf..a4fdf51b 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -47,7 +47,8 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: version_string = metadata.version(dependency_name) return parse_version(version_string) - # TODO: Remove this code path once we drop support for Python 3.7 + # TODO(https://github.com/googleapis/python-api-core/issues/835): Remove + # this code path once we drop support for Python 3.7 else: # Use pkg_resources, which is part of setuptools. import pkg_resources From bdb62607dde2b20992f6bc13ca6fd5bb88296b96 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 3 Sep 2025 15:42:13 -0700 Subject: [PATCH 22/58] fix test to match code changes --- tests/unit/test_python_package_support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index b037a9d5..c9b9493e 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -105,7 +105,7 @@ def test_warn_deprecation_for_versions_less_than( ("my-package (my.package)", "my-package"), ] mock_get_version.return_value = parse_version("1.0.0") - template = "Custom warning for {dependency_packages} used by {dependent_packages}." + template = "Custom warning for {dependency_package} used by {dependent_package}." warn_deprecation_for_versions_less_than( "my.package", "dep.package", "2.0.0", message_template=template ) From 7896664c48864e95ded5c5a3ad2e6561ced9872e Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Thu, 4 Sep 2025 11:50:57 -0700 Subject: [PATCH 23/58] fix: restore asyncio designator for async tests --- tests/asyncio/test_operation_async.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/asyncio/test_operation_async.py b/tests/asyncio/test_operation_async.py index 5b2f012b..37536836 100644 --- a/tests/asyncio/test_operation_async.py +++ b/tests/asyncio/test_operation_async.py @@ -83,7 +83,6 @@ async def test_constructor(): assert future.metadata is None assert await future.running() - @pytest.mark.asyncio async def test_metadata(): expected_metadata = struct_pb2.Struct() From 54a5611c71f3f166659df09d3393c8f96d90997b Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Fri, 5 Sep 2025 11:00:15 -0700 Subject: [PATCH 24/58] Remove some workarounds trying to fix presubmit errors (since fixed) --- tests/asyncio/test_operation_async.py | 1 + tests/unit/gapic/test_method.py | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/asyncio/test_operation_async.py b/tests/asyncio/test_operation_async.py index 37536836..5b2f012b 100644 --- a/tests/asyncio/test_operation_async.py +++ b/tests/asyncio/test_operation_async.py @@ -83,6 +83,7 @@ async def test_constructor(): assert future.metadata is None assert await future.running() + @pytest.mark.asyncio async def test_metadata(): expected_metadata = struct_pb2.Struct() diff --git a/tests/unit/gapic/test_method.py b/tests/unit/gapic/test_method.py index c901c040..927902bf 100644 --- a/tests/unit/gapic/test_method.py +++ b/tests/unit/gapic/test_method.py @@ -177,17 +177,16 @@ def test_wrap_method_with_overriding_retry_timeout_compression(unused_sleep): method, default_retry, default_timeout, default_compression ) - specified_timeout = 22.0 result = wrapped_method( retry=retry.Retry(retry.if_exception_type(exceptions.NotFound)), - timeout=timeout.ConstantTimeout(specified_timeout), + timeout=timeout.ConstantTimeout(22), compression=grpc.Compression.Deflate, ) assert result == 42 assert method.call_count == 2 method.assert_called_with( - timeout=specified_timeout, + timeout=22, compression=grpc.Compression.Deflate, metadata=mock.ANY, ) @@ -201,8 +200,7 @@ def test_wrap_method_with_overriding_timeout_as_a_number(): method, default_retry, default_timeout ) - specified_timeout = 22.0 - result = wrapped_method(timeout=specified_timeout) + result = wrapped_method(timeout=22) assert result == 42 From 2e2990b0f0ab3783d433a66590fd6d0bdcc0d45c Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Fri, 5 Sep 2025 12:53:11 -0700 Subject: [PATCH 25/58] Additional test tweaks to prevent non-significant failures Surprisingly, these tweaks are not needed without the other changes in this PR. --- tests/unit/test_bidi.py | 4 ++++ tests/unit/test_timeout.py | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_bidi.py b/tests/unit/test_bidi.py index 7640367c..58156aa5 100644 --- a/tests/unit/test_bidi.py +++ b/tests/unit/test_bidi.py @@ -828,6 +828,10 @@ def test_rpc_callback_fires_when_consumer_start_fails(self): bidi_rpc._start_rpc.side_effect = expected_exception consumer = bidi.BackgroundConsumer(bidi_rpc, on_response=None) + # Wait for the consumer's thread to exit. + while consumer.is_active: + pass + consumer.start() assert callback.call_args.args[0] == grpc.StatusCode.INVALID_ARGUMENT diff --git a/tests/unit/test_timeout.py b/tests/unit/test_timeout.py index 2c20202b..8ce143f3 100644 --- a/tests/unit/test_timeout.py +++ b/tests/unit/test_timeout.py @@ -14,6 +14,7 @@ import datetime import itertools +import pytest from unittest import mock from google.api_core import timeout as timeouts @@ -121,7 +122,15 @@ def test_apply_passthrough(self): wrapped(1, 2, meep="moop") - target.assert_called_once_with(1, 2, meep="moop", timeout=42.0) + actual_arg_0 = target.call_args[0][0] + actual_arg_1 = target.call_args[0][1] + actual_arg_meep = target.call_args[1]["meep"] + actual_arg_timeuut = target.call_args[1]["timeout"] + + assert actual_arg_0 == 1 + assert actual_arg_1 == 2 + assert actual_arg_meep == "moop" + assert actual_arg_timeuut == pytest.approx(42.0, abs=0.01) class TestConstantTimeout(object): From 35a207477a04140000af7c4d98f87ee801d40b1e Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Fri, 5 Sep 2025 14:47:27 -0700 Subject: [PATCH 26/58] chore: fix test that was waiting before initiating operation --- tests/unit/test_bidi.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_bidi.py b/tests/unit/test_bidi.py index 58156aa5..602608a3 100644 --- a/tests/unit/test_bidi.py +++ b/tests/unit/test_bidi.py @@ -828,11 +828,13 @@ def test_rpc_callback_fires_when_consumer_start_fails(self): bidi_rpc._start_rpc.side_effect = expected_exception consumer = bidi.BackgroundConsumer(bidi_rpc, on_response=None) + + consumer.start() + # Wait for the consumer's thread to exit. while consumer.is_active: pass - consumer.start() assert callback.call_args.args[0] == grpc.StatusCode.INVALID_ARGUMENT def test_consumer_expected_error(self, caplog): From cd6483202de4d47b7eea70e80c8e2c3483929b55 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Fri, 5 Sep 2025 14:48:17 -0700 Subject: [PATCH 27/58] fix: skip coverage checks for code specific to Pyton 3.7 Since we'll be dropping support for 3.7 soon. --- google/api_core/_python_package_support.py | 2 +- google/api_core/_python_version_support.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index a4fdf51b..e4949ffd 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -49,7 +49,7 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: # TODO(https://github.com/googleapis/python-api-core/issues/835): Remove # this code path once we drop support for Python 3.7 - else: + else: # pragma: NO COVER # Use pkg_resources, which is part of setuptools. import pkg_resources diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index d62be7c9..568487a6 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -100,10 +100,10 @@ def _flatten_message(text: str) -> str: return textwrap.dedent(text).strip().replace("\n", " ") -# TODO: Remove once we no longer support Python3.7 +# TODO: Remove once we no longer support Python 3.7 if sys.version_info < (3, 8): - def _get_pypi_package_name(module_name): + def _get_pypi_package_name(module_name): # pragma: NO COVER """Determine the PyPI package name for a given module name.""" return None From e36414ac0767c4b70d444a9be278fbb9ec581390 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 8 Sep 2025 11:50:05 -0700 Subject: [PATCH 28/58] chore: try to address coverage failures --- google/api_core/_python_package_support.py | 2 +- google/api_core/_python_version_support.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index e4949ffd..172a9bdb 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -96,7 +96,7 @@ def warn_deprecation_for_versions_less_than( not dependent_import_package or not dependency_import_package or not next_supported_version - ): + ): # pragma: NO COVER return version_used = get_dependency_version(dependency_import_package) if not version_used: diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 568487a6..1338cb6c 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -117,7 +117,7 @@ def _get_pypi_package_name(module_name): module_to_distributions = metadata.packages_distributions() # Check if the module is found in the mapping - if module_name in module_to_distributions: + if module_name in module_to_distributions: # pragma: NO COVER # The value is a list of distribution names, take the first one return module_to_distributions[module_name][0] else: From b6245ca66ade85765f7022342852b6d0b8a9a286 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 8 Sep 2025 12:27:01 -0700 Subject: [PATCH 29/58] chore: try to address more coverage failures --- tests/unit/test_bidi.py | 2 +- tests/unit/test_python_version_support.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_bidi.py b/tests/unit/test_bidi.py index 602608a3..b51db249 100644 --- a/tests/unit/test_bidi.py +++ b/tests/unit/test_bidi.py @@ -833,7 +833,7 @@ def test_rpc_callback_fires_when_consumer_start_fails(self): # Wait for the consumer's thread to exit. while consumer.is_active: - pass + pass # pragma: NO COVER assert callback.call_args.args[0] == grpc.StatusCode.INVALID_ARGUMENT diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py index 9f609744..ca8f9e65 100644 --- a/tests/unit/test_python_version_support.py +++ b/tests/unit/test_python_version_support.py @@ -34,7 +34,7 @@ def _create_failure_message( expected, result, py_version, date, gapic_dep, py_eol, eol_warn, gapic_end ): """Create a detailed failure message for a test.""" - return textwrap.dedent( + return textwrap.dedent( # pragma: NO COVER f""" --- Test Failed --- Expected status: {expected.name} @@ -129,7 +129,7 @@ def test_all_tracked_versions_and_date_scenarios( (result != expected_status) or (result != PythonVersionStatus.PYTHON_VERSION_SUPPORTED) and mock_log.call_count != 1 - ): + ): # pragma: NO COVER py_version_str = f"{version_tuple[0]}.{version_tuple[1]}" version_info = PYTHON_VERSION_INFO[version_tuple] From 3a897ba1af3006113bb3661e7f79c305a39614a7 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 8 Sep 2025 17:11:13 -0700 Subject: [PATCH 30/58] chore: fix comments and exported function --- google/api_core/__init__.py | 2 ++ google/api_core/_python_package_support.py | 21 +++++++++++++-------- google/api_core/_python_version_support.py | 5 +++-- tests/unit/test_python_package_support.py | 4 ++++ tests/unit/test_python_version_support.py | 6 +++++- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index b41dcb82..be8af872 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -32,3 +32,5 @@ check_dependency_versions = _python_package_support.check_dependency_versions check_dependency_versions("google.api_core") + +warn_deprecation_for_versions_less_than = _python_package_support.warn_deprecation_for_versions_less_than diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 172a9bdb..6cff75bb 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -22,7 +22,6 @@ _get_distribution_and_import_packages, ) -# It is a good practice to alias the Version class for clarity in type hints. from packaging.version import parse as parse_version, Version as PackagingVersion @@ -72,7 +71,7 @@ def warn_deprecation_for_versions_less_than( `next_supported_version`, this issues a warning using either a default `message_template` or one provided by the user. The default `message_template` informs the user that they will not receive - future updates `dependent_import_package` if + future updates for `dependent_import_package` if `dependency_import_package` is somehow pinned to a version lower than `next_supported_version`. @@ -80,16 +79,20 @@ def warn_deprecation_for_versions_less_than( dependent_import_package: The import name of the package that needs `dependency_import_package`. dependency_import_package: The import name of the dependency to check. - next_supported_version: The version number below which a deprecation - warning will be logged. + next_supported_version: The dependency_import_package version number + below which a deprecation warning will be logged. message_template: A custom default message template to replace the default. This `message_template` is treated as an f-string, where the following variables are defined: `dependency_import_package`, `dependent_import_package` and - `dependency_package`, `dependent_package`, which contain both the - import and distribution packages for the dependency and the dependent, - respectively; and `next_supported_version` and `version_used`, which - refer to supported and currently-used versions of the dependency. + `dependency_distribution_package` and + `dependent_distribution_package` and `dependency_package`, + `dependent_package` , which contain the import packages, the + distribution packages, and pretty string with both the + distribution and import packages for the dependency and the + dependent, respectively; and `next_supported_version` and + `version_used`, which refer to supported and currently-used + versions of the dependency. """ if ( @@ -129,6 +132,8 @@ def warn_deprecation_for_versions_less_than( message_template.format( dependent_import_package=dependent_import_package, dependency_import_package=dependency_import_package, + dependent_distribution_package=dependent_distribution_package, + dependency_distribution_package=dependency_distribution_package, dependency_package=dependency_package, dependent_package=dependent_package, next_supported_version=next_supported_version, diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 1338cb6c..e1578ab0 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -96,11 +96,12 @@ class VersionInfo(NamedTuple): def _flatten_message(text: str) -> str: - """Dedent a multi-line string and flattens it into a single line.""" + """Dedent a multi-line string and flatten it into a single line.""" return textwrap.dedent(text).strip().replace("\n", " ") -# TODO: Remove once we no longer support Python 3.7 +# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove once we +# no longer support Python 3.7 if sys.version_info < (3, 8): def _get_pypi_package_name(module_name): # pragma: NO COVER diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index c9b9493e..b4b01dfd 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -24,6 +24,8 @@ ) +# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove +# this mark once we drop support for Python 3.7 @pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python3.8 or higher") @patch("importlib.metadata.version") def test_get_dependency_version_py38_plus(mock_version): @@ -37,6 +39,8 @@ def test_get_dependency_version_py38_plus(mock_version): assert get_dependency_version("not-a-package") is None +# TODO(https://github.com/googleapis/python-api-core/issues/835): Remove +# this test function once we drop support for Python 3.7 @pytest.mark.skipif(sys.version_info >= (3, 8), reason="requires python3.7") @patch("pkg_resources.get_distribution") def test_get_dependency_version_py37(mock_get_distribution): diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py index ca8f9e65..9b005e9d 100644 --- a/tests/unit/test_python_version_support.py +++ b/tests/unit/test_python_version_support.py @@ -162,6 +162,10 @@ def test_override_gapic_end_only(): "google.api_core._python_version_support.PYTHON_VERSION_INFO", {version_tuple: overridden_info}, ): + result_before_boundary = check_python_version( + today=custom_gapic_end + datetime.timedelta(days=-1)) + assert result_before_boundary == PythonVersionStatus.PYTHON_VERSION_DEPRECATED + result_at_boundary = check_python_version(today=custom_gapic_end) assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_EOL @@ -221,7 +225,7 @@ def test_untracked_older_version_is_unsupported(): def test_untracked_newer_version_is_supported(): """Test that a new, untracked version is supported and does not log.""" - mock_py_version = VersionInfoMock(major=4, minor=0) + mock_py_version = VersionInfoMock(major=40, minor=0) with patch( "google.api_core._python_version_support.sys.version_info", mock_py_version From 814ea634f1b92ee7bcebf528ebd6771222f698e9 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 8 Sep 2025 17:15:28 -0700 Subject: [PATCH 31/58] fix lint --- google/api_core/__init__.py | 4 +++- tests/unit/test_python_version_support.py | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index be8af872..9286373c 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -33,4 +33,6 @@ check_dependency_versions = _python_package_support.check_dependency_versions check_dependency_versions("google.api_core") -warn_deprecation_for_versions_less_than = _python_package_support.warn_deprecation_for_versions_less_than +warn_deprecation_for_versions_less_than = ( + _python_package_support.warn_deprecation_for_versions_less_than +) diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py index 9b005e9d..2d0fa478 100644 --- a/tests/unit/test_python_version_support.py +++ b/tests/unit/test_python_version_support.py @@ -163,8 +163,11 @@ def test_override_gapic_end_only(): {version_tuple: overridden_info}, ): result_before_boundary = check_python_version( - today=custom_gapic_end + datetime.timedelta(days=-1)) - assert result_before_boundary == PythonVersionStatus.PYTHON_VERSION_DEPRECATED + today=custom_gapic_end + datetime.timedelta(days=-1) + ) + assert ( + result_before_boundary == PythonVersionStatus.PYTHON_VERSION_DEPRECATED + ) result_at_boundary = check_python_version(today=custom_gapic_end) assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_EOL From 8dee04bdd16797c5faf609eaba35fb248aaf27c8 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Tue, 9 Sep 2025 10:12:56 -0700 Subject: [PATCH 32/58] chore: fix unit test --- tests/unit/test_python_version_support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py index 2d0fa478..cf55bc0f 100644 --- a/tests/unit/test_python_version_support.py +++ b/tests/unit/test_python_version_support.py @@ -166,7 +166,7 @@ def test_override_gapic_end_only(): today=custom_gapic_end + datetime.timedelta(days=-1) ) assert ( - result_before_boundary == PythonVersionStatus.PYTHON_VERSION_DEPRECATED + result_before_boundary == PythonVersionStatus.PYTHON_VERSION_EOL ) result_at_boundary = check_python_version(today=custom_gapic_end) From 95f4777b1307d004e82172f7cea94610905ba762 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Tue, 9 Sep 2025 10:20:42 -0700 Subject: [PATCH 33/58] chore: lint --- tests/unit/test_python_version_support.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py index cf55bc0f..a14b9510 100644 --- a/tests/unit/test_python_version_support.py +++ b/tests/unit/test_python_version_support.py @@ -165,9 +165,7 @@ def test_override_gapic_end_only(): result_before_boundary = check_python_version( today=custom_gapic_end + datetime.timedelta(days=-1) ) - assert ( - result_before_boundary == PythonVersionStatus.PYTHON_VERSION_EOL - ) + assert result_before_boundary == PythonVersionStatus.PYTHON_VERSION_EOL result_at_boundary = check_python_version(today=custom_gapic_end) assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_EOL From a34ec1588954386827179a92ac7ec53056ee05d7 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 10 Sep 2025 15:43:59 -0700 Subject: [PATCH 34/58] Use warnings module instead of logging module --- google/api_core/_python_package_support.py | 4 +- google/api_core/_python_version_support.py | 8 +-- tests/unit/test_python_package_support.py | 60 ++++++++++--------- tests/unit/test_python_version_support.py | 68 +++++++++++----------- 4 files changed, 71 insertions(+), 69 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 6cff75bb..651fd81e 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -14,7 +14,7 @@ """Code to check versions of dependencies used by Google Cloud Client Libraries.""" -import logging +import warnings import sys from typing import Optional from ._python_version_support import ( @@ -128,7 +128,7 @@ def warn_deprecation_for_versions_less_than( {dependency_package}. """ ) - logging.warning( + warnings.warn( message_template.format( dependent_import_package=dependent_import_package, dependency_import_package=dependency_import_package, diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index e1578ab0..7e0bc814 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -16,7 +16,7 @@ import datetime import enum -import logging +import warnings import sys import textwrap from typing import Any, NamedTuple, Optional, Dict, Tuple @@ -195,7 +195,7 @@ def min_python(date: datetime.date) -> str: {min_python(today)}, and then update {package_label}. """ ) - logging.warning(message) + warnings.warn(message) return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED eol_date = version_info.python_eol + EOL_GRACE_PERIOD @@ -210,7 +210,7 @@ def min_python(date: datetime.date) -> str: {min_python(today)}, and then update {package_label}. """ ) - logging.warning(message) + warnings.warn(message) return PythonVersionStatus.PYTHON_VERSION_EOL if gapic_deprecation <= today <= gapic_end: @@ -224,7 +224,7 @@ def min_python(date: datetime.date) -> str: then update {package_label}. """ ) - logging.warning(message) + warnings.warn(message) return PythonVersionStatus.PYTHON_VERSION_DEPRECATED return PythonVersionStatus.PYTHON_VERSION_SUPPORTED diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index b4b01dfd..6cb1934d 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -13,6 +13,7 @@ # limitations under the License. import sys +import warnings from unittest.mock import patch, MagicMock import pytest @@ -60,9 +61,8 @@ def test_get_dependency_version_py37(mock_get_distribution): @patch("google.api_core._python_package_support._get_distribution_and_import_packages") @patch("google.api_core._python_package_support.get_dependency_version") -@patch("google.api_core._python_package_support.logging.warning") def test_warn_deprecation_for_versions_less_than( - mock_log_warning, mock_get_version, mock_get_packages + mock_get_version, mock_get_packages ): """Test the deprecation warning logic.""" # Mock the helper function to return predictable package strings @@ -73,36 +73,37 @@ def test_warn_deprecation_for_versions_less_than( # Case 1: Installed version is less than required, should warn. mock_get_version.return_value = parse_version("1.0.0") - warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") - mock_log_warning.assert_called_once() + with pytest.warns(UserWarning) as record: + warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") + assert len(record) == 1 assert ( "DEPRECATION: Package my-package (my.package) depends on dep-package (dep.package)" - in mock_log_warning.call_args[0][0] + in str(record[0].message) ) - # Case 2: Installed version is equal to required, should not warn. - mock_log_warning.reset_mock() - mock_get_packages.reset_mock() - mock_get_version.return_value = parse_version("2.0.0") - warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") - mock_log_warning.assert_not_called() + # Cases where no warning should be issued + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") # Capture all warnings - # Case 3: Installed version is greater than required, should not warn. - mock_log_warning.reset_mock() - mock_get_packages.reset_mock() - mock_get_version.return_value = parse_version("3.0.0") - warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") - mock_log_warning.assert_not_called() + # Case 2: Installed version is equal to required, should not warn. + mock_get_packages.reset_mock() + mock_get_version.return_value = parse_version("2.0.0") + warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") - # Case 4: Dependency not found, should not warn. - mock_log_warning.reset_mock() - mock_get_packages.reset_mock() - mock_get_version.return_value = None - warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") - mock_log_warning.assert_not_called() + # Case 3: Installed version is greater than required, should not warn. + mock_get_packages.reset_mock() + mock_get_version.return_value = parse_version("3.0.0") + warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") + + # Case 4: Dependency not found, should not warn. + mock_get_packages.reset_mock() + mock_get_version.return_value = None + warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") + + # Assert that no warnings were recorded + assert len(w) == 0 # Case 5: Custom message template. - mock_log_warning.reset_mock() mock_get_packages.reset_mock() mock_get_packages.side_effect = [ ("dep-package (dep.package)", "dep-package"), @@ -110,11 +111,12 @@ def test_warn_deprecation_for_versions_less_than( ] mock_get_version.return_value = parse_version("1.0.0") template = "Custom warning for {dependency_package} used by {dependent_package}." - warn_deprecation_for_versions_less_than( - "my.package", "dep.package", "2.0.0", message_template=template - ) - mock_log_warning.assert_called_once() + with pytest.warns(UserWarning) as record: + warn_deprecation_for_versions_less_than( + "my.package", "dep.package", "2.0.0", message_template=template + ) + assert len(record) == 1 assert ( "Custom warning for dep-package (dep.package) used by my-package (my.package)." - in mock_log_warning.call_args[0][0] + in str(record[0].message) ) diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py index a14b9510..5bbd676e 100644 --- a/tests/unit/test_python_version_support.py +++ b/tests/unit/test_python_version_support.py @@ -15,6 +15,7 @@ import pytest import datetime import textwrap +import warnings from collections import namedtuple from unittest.mock import patch @@ -120,30 +121,33 @@ def test_all_tracked_versions_and_date_scenarios( mock_py_v = VersionInfoMock(major=version_tuple[0], minor=version_tuple[1]) with patch("google.api_core._python_version_support.sys.version_info", mock_py_v): - with patch( - "google.api_core._python_version_support.logging.warning" - ) as mock_log: - result = check_python_version(today=mock_date) - - if ( - (result != expected_status) - or (result != PythonVersionStatus.PYTHON_VERSION_SUPPORTED) - and mock_log.call_count != 1 - ): # pragma: NO COVER - py_version_str = f"{version_tuple[0]}.{version_tuple[1]}" - version_info = PYTHON_VERSION_INFO[version_tuple] - - fail_msg = _create_failure_message( - expected_status, - result, - py_version_str, - mock_date, - gapic_dep, - version_info.python_eol, - eol_warning_starts, - gapic_end, - ) - pytest.fail(fail_msg, pytrace=False) + # Supported versions should not issue warnings + if expected_status == PythonVersionStatus.PYTHON_VERSION_SUPPORTED: + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = check_python_version(today=mock_date) + assert len(w) == 0 + # All other statuses should issue a warning + else: + with pytest.warns(UserWarning) as record: + result = check_python_version(today=mock_date) + assert len(record) == 1 + + if result != expected_status: # pragma: NO COVER + py_version_str = f"{version_tuple[0]}.{version_tuple[1]}" + version_info = PYTHON_VERSION_INFO[version_tuple] + + fail_msg = _create_failure_message( + expected_status, + result, + py_version_str, + mock_date, + gapic_dep, + version_info.python_eol, + eol_warning_starts, + gapic_end, + ) + pytest.fail(fail_msg, pytrace=False) def test_override_gapic_end_only(): @@ -212,16 +216,13 @@ def test_untracked_older_version_is_unsupported(): with patch( "google.api_core._python_version_support.sys.version_info", mock_py_version ): - with patch( - "google.api_core._python_version_support.logging.warning" - ) as mock_log: + with pytest.warns(UserWarning) as record: mock_date = datetime.date(2025, 1, 15) result = check_python_version(today=mock_date) assert result == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED - mock_log.assert_called_once() - call_args = mock_log.call_args[0][0] - assert "non-supported" in call_args + assert len(record) == 1 + assert "non-supported" in str(record[0].message) def test_untracked_newer_version_is_supported(): @@ -231,11 +232,10 @@ def test_untracked_newer_version_is_supported(): with patch( "google.api_core._python_version_support.sys.version_info", mock_py_version ): - with patch( - "google.api_core._python_version_support.logging.warning" - ) as mock_log: + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") mock_date = datetime.date(2025, 1, 15) result = check_python_version(today=mock_date) assert result == PythonVersionStatus.PYTHON_VERSION_SUPPORTED - mock_log.assert_not_called() + assert len(w) == 0 From a04e2e69f07a1cf932b092aa24de0f7868e4ba6f Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 15 Sep 2025 12:58:08 -0700 Subject: [PATCH 35/58] fix: print the current package versions correctly --- google/api_core/_python_package_support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 651fd81e..c28b38a1 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -117,7 +117,7 @@ def warn_deprecation_for_versions_less_than( """ DEPRECATION: Package {dependent_package} depends on {dependency_package}, currently installed at version - {version_used.__str__}. Future updates to + {version_used.__str__()}. Future updates to {dependent_package} will require {dependency_package} at version {next_supported_version} or higher. Please ensure that either (a) your Python environment doesn't pin the From 8b3f337dfd2d4bc6fdd6cffdee2c389b644491bb Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 15 Sep 2025 16:11:32 -0700 Subject: [PATCH 36/58] wip: before gemini doc fixes --- google/api_core/_python_package_support.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index c28b38a1..7e6498fb 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -44,7 +44,7 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: from importlib import metadata version_string = metadata.version(dependency_name) - return parse_version(version_string) + return (parse_version(version_string), version_string) # TODO(https://github.com/googleapis/python-api-core/issues/835): Remove # this code path once we drop support for Python 3.7 @@ -53,10 +53,10 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: import pkg_resources version_string = pkg_resources.get_distribution(dependency_name).version - return parse_version(version_string) + return (parse_version(version_string), version_string) except Exception: - return None + return (None, "--") def warn_deprecation_for_versions_less_than( @@ -101,7 +101,7 @@ def warn_deprecation_for_versions_less_than( or not next_supported_version ): # pragma: NO COVER return - version_used = get_dependency_version(dependency_import_package) + (version_used, version_used_string) = get_dependency_version(dependency_import_package) if not version_used: return if version_used < parse_version(next_supported_version): @@ -117,7 +117,7 @@ def warn_deprecation_for_versions_less_than( """ DEPRECATION: Package {dependent_package} depends on {dependency_package}, currently installed at version - {version_used.__str__()}. Future updates to + {version_used_string}. Future updates to {dependent_package} will require {dependency_package} at version {next_supported_version} or higher. Please ensure that either (a) your Python environment doesn't pin the From 29896cf83ab8bc8a432604f4c9ee4d1f5210824f Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 15 Sep 2025 17:08:50 -0700 Subject: [PATCH 37/58] fix: return tuple of version tuple and version string, fix tests --- google/api_core/_python_package_support.py | 21 ++++++++++++-------- google/api_core/_python_version_support.py | 6 +++--- tests/unit/test_python_package_support.py | 23 +++++++++++----------- tests/unit/test_python_version_support.py | 4 ++-- 4 files changed, 29 insertions(+), 25 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 7e6498fb..3d239779 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -16,7 +16,7 @@ import warnings import sys -from typing import Optional +from typing import Optional, Tuple from ._python_version_support import ( _flatten_message, _get_distribution_and_import_packages, @@ -25,7 +25,9 @@ from packaging.version import parse as parse_version, Version as PackagingVersion -def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: +def get_dependency_version( + dependency_name: str, +) -> Tuple[Optional[PackagingVersion], str]: """Get the parsed version of an installed package dependency. This function checks for an installed package and returns its version @@ -36,8 +38,9 @@ def get_dependency_version(dependency_name: str) -> Optional[PackagingVersion]: dependency_name: The distribution name of the package (e.g., 'requests'). Returns: - A `packaging.version.Version` object, or `None` if the package - is not found or another error occurs during version discovery. + A tuple containing the `packaging.version.Version` object and the + version string, or `(None, '--')` if the package is not found or + another error occurs during version discovery. """ try: if sys.version_info >= (3, 8): @@ -90,9 +93,9 @@ def warn_deprecation_for_versions_less_than( `dependent_package` , which contain the import packages, the distribution packages, and pretty string with both the distribution and import packages for the dependency and the - dependent, respectively; and `next_supported_version` and - `version_used`, which refer to supported and currently-used - versions of the dependency. + dependent, respectively; and `next_supported_version`, + `version_used`, and `version_used_string`, which refer to supported + and currently-used versions of the dependency. """ if ( @@ -138,7 +141,9 @@ def warn_deprecation_for_versions_less_than( dependent_package=dependent_package, next_supported_version=next_supported_version, version_used=version_used, - ) + version_used_string=version_used_string + ), + FutureWarning ) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 7e0bc814..3a9ea915 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -195,7 +195,7 @@ def min_python(date: datetime.date) -> str: {min_python(today)}, and then update {package_label}. """ ) - warnings.warn(message) + warnings.warn(message, FutureWarning) return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED eol_date = version_info.python_eol + EOL_GRACE_PERIOD @@ -210,7 +210,7 @@ def min_python(date: datetime.date) -> str: {min_python(today)}, and then update {package_label}. """ ) - warnings.warn(message) + warnings.warn(message, FutureWarning) return PythonVersionStatus.PYTHON_VERSION_EOL if gapic_deprecation <= today <= gapic_end: @@ -224,7 +224,7 @@ def min_python(date: datetime.date) -> str: then update {package_label}. """ ) - warnings.warn(message) + warnings.warn(message, FutureWarning) return PythonVersionStatus.PYTHON_VERSION_DEPRECATED return PythonVersionStatus.PYTHON_VERSION_SUPPORTED diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index 6cb1934d..eeb4dfa2 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -32,12 +32,12 @@ def test_get_dependency_version_py38_plus(mock_version): """Test get_dependency_version on Python 3.8+.""" mock_version.return_value = "1.2.3" - assert get_dependency_version("some-package") == parse_version("1.2.3") + assert get_dependency_version("some-package") == (parse_version("1.2.3"), "1.2.3") mock_version.assert_called_once_with("some-package") # Test package not found mock_version.side_effect = ImportError - assert get_dependency_version("not-a-package") is None + assert get_dependency_version("not-a-package") == (None, "--") # TODO(https://github.com/googleapis/python-api-core/issues/835): Remove @@ -49,14 +49,14 @@ def test_get_dependency_version_py37(mock_get_distribution): mock_dist = MagicMock() mock_dist.version = "4.5.6" mock_get_distribution.return_value = mock_dist - assert get_dependency_version("another-package") == parse_version("4.5.6") + assert get_dependency_version("another-package") == (parse_version("4.5.6"), "4.5.6") mock_get_distribution.assert_called_once_with("another-package") # Test package not found mock_get_distribution.side_effect = ( Exception # pkg_resources has its own exception types ) - assert get_dependency_version("not-a-package") is None + assert get_dependency_version("not-a-package") == (None, "--") @patch("google.api_core._python_package_support._get_distribution_and_import_packages") @@ -71,9 +71,8 @@ def test_warn_deprecation_for_versions_less_than( ("my-package (my.package)", "my-package"), ] - # Case 1: Installed version is less than required, should warn. - mock_get_version.return_value = parse_version("1.0.0") - with pytest.warns(UserWarning) as record: + mock_get_version.return_value = (parse_version("1.0.0"), "1.0.0") + with pytest.warns(FutureWarning) as record: warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") assert len(record) == 1 assert ( @@ -87,17 +86,17 @@ def test_warn_deprecation_for_versions_less_than( # Case 2: Installed version is equal to required, should not warn. mock_get_packages.reset_mock() - mock_get_version.return_value = parse_version("2.0.0") + mock_get_version.return_value = (parse_version("2.0.0"), "2.0.0") warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") # Case 3: Installed version is greater than required, should not warn. mock_get_packages.reset_mock() - mock_get_version.return_value = parse_version("3.0.0") + mock_get_version.return_value = (parse_version("3.0.0"), "3.0.0") warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") # Case 4: Dependency not found, should not warn. mock_get_packages.reset_mock() - mock_get_version.return_value = None + mock_get_version.return_value = (None, "--") warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") # Assert that no warnings were recorded @@ -109,9 +108,9 @@ def test_warn_deprecation_for_versions_less_than( ("dep-package (dep.package)", "dep-package"), ("my-package (my.package)", "my-package"), ] - mock_get_version.return_value = parse_version("1.0.0") + mock_get_version.return_value = (parse_version("1.0.0"), "1.0.0") template = "Custom warning for {dependency_package} used by {dependent_package}." - with pytest.warns(UserWarning) as record: + with pytest.warns(FutureWarning) as record: warn_deprecation_for_versions_less_than( "my.package", "dep.package", "2.0.0", message_template=template ) diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py index 5bbd676e..e3735fce 100644 --- a/tests/unit/test_python_version_support.py +++ b/tests/unit/test_python_version_support.py @@ -129,7 +129,7 @@ def test_all_tracked_versions_and_date_scenarios( assert len(w) == 0 # All other statuses should issue a warning else: - with pytest.warns(UserWarning) as record: + with pytest.warns(FutureWarning) as record: result = check_python_version(today=mock_date) assert len(record) == 1 @@ -216,7 +216,7 @@ def test_untracked_older_version_is_unsupported(): with patch( "google.api_core._python_version_support.sys.version_info", mock_py_version ): - with pytest.warns(UserWarning) as record: + with pytest.warns(FutureWarning) as record: mock_date = datetime.date(2025, 1, 15) result = check_python_version(today=mock_date) From d421f98cad0649b568a877d830ebdbc2c695f2a3 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 22 Sep 2025 16:34:40 -0700 Subject: [PATCH 38/58] lint --- google/api_core/_python_package_support.py | 8 +++++--- tests/unit/test_python_package_support.py | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 3d239779..45e4eaab 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -104,7 +104,9 @@ def warn_deprecation_for_versions_less_than( or not next_supported_version ): # pragma: NO COVER return - (version_used, version_used_string) = get_dependency_version(dependency_import_package) + (version_used, version_used_string) = get_dependency_version( + dependency_import_package + ) if not version_used: return if version_used < parse_version(next_supported_version): @@ -141,9 +143,9 @@ def warn_deprecation_for_versions_less_than( dependent_package=dependent_package, next_supported_version=next_supported_version, version_used=version_used, - version_used_string=version_used_string + version_used_string=version_used_string, ), - FutureWarning + FutureWarning, ) diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index eeb4dfa2..784a0c6b 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -49,7 +49,10 @@ def test_get_dependency_version_py37(mock_get_distribution): mock_dist = MagicMock() mock_dist.version = "4.5.6" mock_get_distribution.return_value = mock_dist - assert get_dependency_version("another-package") == (parse_version("4.5.6"), "4.5.6") + assert get_dependency_version("another-package") == ( + parse_version("4.5.6"), + "4.5.6", + ) mock_get_distribution.assert_called_once_with("another-package") # Test package not found @@ -61,9 +64,7 @@ def test_get_dependency_version_py37(mock_get_distribution): @patch("google.api_core._python_package_support._get_distribution_and_import_packages") @patch("google.api_core._python_package_support.get_dependency_version") -def test_warn_deprecation_for_versions_less_than( - mock_get_version, mock_get_packages -): +def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_packages): """Test the deprecation warning logic.""" # Mock the helper function to return predictable package strings mock_get_packages.side_effect = [ From ac76f7a3b4a9bc873db2bc77fdd49ae0993bc3b7 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 1 Oct 2025 11:52:56 -0700 Subject: [PATCH 39/58] feat: add grace period for 3.9; tweak warning The warning messages now mor eclosely match what we'we're deploying in the generator for those who don't update their api-core dependency --- google/api_core/_python_version_support.py | 25 +++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 3a9ea915..f8c9278e 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -60,6 +60,7 @@ class VersionInfo(NamedTuple): python_beta=datetime.date(2020, 5, 18), python_start=datetime.date(2020, 10, 5), python_eol=datetime.date(2025, 10, 5), # TODO: specify day when announced + gapic_end=datetime.date(2025, 10, 5) + datetime.timedelta(days=90), # TODO: specify day when announced ), (3, 10): VersionInfo( python_beta=datetime.date(2021, 5, 3), @@ -155,7 +156,7 @@ def check_python_version( python_version = sys.version_info version_tuple = (python_version.major, python_version.minor) - py_version_str = f"{python_version.major}.{python_version.minor}" + py_version_str = sys.version.split()[0] version_info = PYTHON_VERSION_INFO.get(version_tuple) @@ -188,11 +189,11 @@ def min_python(date: datetime.date) -> str: if gapic_end < today: message = _flatten_message( f""" - You are using a non-supported Python version - ({py_version_str}). Google will not post any further - updates to {package_label}. Please upgrade to the - latest Python version, or at least Python - {min_python(today)}, and then update {package_label}. + You are using a non-supported Python version ({py_version_str}). + Google will not post any further updates to {package_label} + supporting this Python version. Please upgrade to the latest Python + version, or at least Python {min_python(today)}, and then update + {package_label}. """ ) warnings.warn(message, FutureWarning) @@ -216,12 +217,12 @@ def min_python(date: datetime.date) -> str: if gapic_deprecation <= today <= gapic_end: message = _flatten_message( f""" - You are using a Python version ({py_version_str}) - which Google will stop supporting in {package_label} when - it reaches its end of life ({version_info.python_eol}). Please - upgrade to the latest Python version, or at - least Python {min_python(version_info.python_eol)}, and - then update {package_label}. + You are using a Python version ({py_version_str}) which Google will + stop supporting in new releases of {package_label} once it reaches + its end of life ({version_info.python_eol}). Please upgrade to the + latest Python version, or at least Python + {min_python(version_info.python_eol)}, to continue receiving updates + for {package_label} past that date. """ ) warnings.warn(message, FutureWarning) From e093a96e419a99ffd7bbeddee10e2f60fb629185 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 1 Oct 2025 12:19:25 -0700 Subject: [PATCH 40/58] lint --- google/api_core/_python_version_support.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index f8c9278e..65b9ecde 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -60,7 +60,8 @@ class VersionInfo(NamedTuple): python_beta=datetime.date(2020, 5, 18), python_start=datetime.date(2020, 10, 5), python_eol=datetime.date(2025, 10, 5), # TODO: specify day when announced - gapic_end=datetime.date(2025, 10, 5) + datetime.timedelta(days=90), # TODO: specify day when announced + gapic_end=datetime.date(2025, 10, 5) + + datetime.timedelta(days=90), # TODO: specify day when announced ), (3, 10): VersionInfo( python_beta=datetime.date(2021, 5, 3), From 187f848b7c02deaa4afb7a1c5b54f7dbd8f877de Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Wed, 1 Oct 2025 12:19:40 -0700 Subject: [PATCH 41/58] allow providing a recommended version Sometimes the version we recommend is newer than the next non-deprecated version, so we want to inform users eagerly to reduce their toil --- google/api_core/_python_package_support.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 45e4eaab..c27edb81 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -66,6 +66,7 @@ def warn_deprecation_for_versions_less_than( dependent_import_package: str, dependency_import_package: str, next_supported_version: str, + recommended_version: Optional[str] = None, message_template: Optional[str] = None, ): """Issue any needed deprecation warnings for `dependency_import_package`. @@ -84,6 +85,8 @@ def warn_deprecation_for_versions_less_than( dependency_import_package: The import name of the dependency to check. next_supported_version: The dependency_import_package version number below which a deprecation warning will be logged. + recommended_version: If provided, the recommended next version, which + could be higher than `next_supported_version`. message_template: A custom default message template to replace the default. This `message_template` is treated as an f-string, where the following variables are defined: @@ -118,17 +121,22 @@ def warn_deprecation_for_versions_less_than( dependent_package, dependent_distribution_package, ) = _get_distribution_and_import_packages(dependent_import_package) + + recommendation = ( + " (we recommend {recommended_version})" if recommended_version else "" + ) message_template = message_template or _flatten_message( """ DEPRECATION: Package {dependent_package} depends on {dependency_package}, currently installed at version {version_used_string}. Future updates to {dependent_package} will require {dependency_package} at - version {next_supported_version} or higher. Please ensure - that either (a) your Python environment doesn't pin the - version of {dependency_package}, so that updates to - {dependent_package} can require the higher version, or - (b) you manually update your Python environment to use at + version {next_supported_version} or + higher{recommendation}. Please ensure that either (a) your + Python environment doesn't pin the version of + {dependency_package}, so that updates to + {dependent_package} can require the higher version, or (b) + you manually update your Python environment to use at least version {next_supported_version} of {dependency_package}. """ @@ -142,6 +150,7 @@ def warn_deprecation_for_versions_less_than( dependency_package=dependency_package, dependent_package=dependent_package, next_supported_version=next_supported_version, + recommendation=recommendation, version_used=version_used, version_used_string=version_used_string, ), @@ -162,5 +171,5 @@ def check_dependency_versions(dependent_import_package: str): """ warn_deprecation_for_versions_less_than( - dependent_import_package, "google.protobuf", "4.25.8" + dependent_import_package, "google.protobuf", "4.25.8", recommended_version="6.x" ) From b575a168c0882e1c30bec97a6e91e45a10bd6871 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 13:15:50 -0700 Subject: [PATCH 42/58] refactor: used namedtuple --- google/api_core/_python_package_support.py | 35 ++++++++++++---------- tests/unit/test_python_package_support.py | 28 +++++++++-------- 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index c27edb81..0bd16eb4 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -16,18 +16,23 @@ import warnings import sys -from typing import Optional, Tuple +from typing import Optional + +from collections import namedtuple + from ._python_version_support import ( _flatten_message, _get_distribution_and_import_packages, ) -from packaging.version import parse as parse_version, Version as PackagingVersion +from packaging.version import parse as parse_version + +DependencyVersion = namedtuple("DependencyVersion", ["version", "version_string"]) def get_dependency_version( dependency_name: str, -) -> Tuple[Optional[PackagingVersion], str]: +) -> DependencyVersion: """Get the parsed version of an installed package dependency. This function checks for an installed package and returns its version @@ -38,16 +43,16 @@ def get_dependency_version( dependency_name: The distribution name of the package (e.g., 'requests'). Returns: - A tuple containing the `packaging.version.Version` object and the - version string, or `(None, '--')` if the package is not found or - another error occurs during version discovery. + A DependencyVersion namedtuple with `version` and `version_string` + attributes, or `DependencyVersion(None, '--')` if the package is not + found or another error occurs during version discovery. """ try: if sys.version_info >= (3, 8): from importlib import metadata version_string = metadata.version(dependency_name) - return (parse_version(version_string), version_string) + return DependencyVersion(parse_version(version_string), version_string) # TODO(https://github.com/googleapis/python-api-core/issues/835): Remove # this code path once we drop support for Python 3.7 @@ -56,10 +61,10 @@ def get_dependency_version( import pkg_resources version_string = pkg_resources.get_distribution(dependency_name).version - return (parse_version(version_string), version_string) + return DependencyVersion(parse_version(version_string), version_string) except Exception: - return (None, "--") + return DependencyVersion(None, "--") def warn_deprecation_for_versions_less_than( @@ -107,12 +112,10 @@ def warn_deprecation_for_versions_less_than( or not next_supported_version ): # pragma: NO COVER return - (version_used, version_used_string) = get_dependency_version( - dependency_import_package - ) - if not version_used: + dependency_version = get_dependency_version(dependency_import_package) + if not dependency_version.version: return - if version_used < parse_version(next_supported_version): + if dependency_version.version < parse_version(next_supported_version): ( dependency_package, dependency_distribution_package, @@ -151,8 +154,8 @@ def warn_deprecation_for_versions_less_than( dependent_package=dependent_package, next_supported_version=next_supported_version, recommendation=recommendation, - version_used=version_used, - version_used_string=version_used_string, + version_used=dependency_version.version, + version_used_string=dependency_version.version_string, ), FutureWarning, ) diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index 784a0c6b..1397ebe3 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -22,6 +22,7 @@ from google.api_core._python_package_support import ( get_dependency_version, warn_deprecation_for_versions_less_than, + DependencyVersion, ) @@ -32,12 +33,13 @@ def test_get_dependency_version_py38_plus(mock_version): """Test get_dependency_version on Python 3.8+.""" mock_version.return_value = "1.2.3" - assert get_dependency_version("some-package") == (parse_version("1.2.3"), "1.2.3") + expected = DependencyVersion(parse_version("1.2.3"), "1.2.3") + assert get_dependency_version("some-package") == expected mock_version.assert_called_once_with("some-package") # Test package not found mock_version.side_effect = ImportError - assert get_dependency_version("not-a-package") == (None, "--") + assert get_dependency_version("not-a-package") == DependencyVersion(None, "--") # TODO(https://github.com/googleapis/python-api-core/issues/835): Remove @@ -49,17 +51,15 @@ def test_get_dependency_version_py37(mock_get_distribution): mock_dist = MagicMock() mock_dist.version = "4.5.6" mock_get_distribution.return_value = mock_dist - assert get_dependency_version("another-package") == ( - parse_version("4.5.6"), - "4.5.6", - ) + expected = DependencyVersion(parse_version("4.5.6"), "4.5.6") + assert get_dependency_version("another-package") == expected mock_get_distribution.assert_called_once_with("another-package") # Test package not found mock_get_distribution.side_effect = ( Exception # pkg_resources has its own exception types ) - assert get_dependency_version("not-a-package") == (None, "--") + assert get_dependency_version("not-a-package") == DependencyVersion(None, "--") @patch("google.api_core._python_package_support._get_distribution_and_import_packages") @@ -72,7 +72,7 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack ("my-package (my.package)", "my-package"), ] - mock_get_version.return_value = (parse_version("1.0.0"), "1.0.0") + mock_get_version.return_value = DependencyVersion(parse_version("1.0.0"), "1.0.0") with pytest.warns(FutureWarning) as record: warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") assert len(record) == 1 @@ -87,17 +87,21 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack # Case 2: Installed version is equal to required, should not warn. mock_get_packages.reset_mock() - mock_get_version.return_value = (parse_version("2.0.0"), "2.0.0") + mock_get_version.return_value = DependencyVersion( + parse_version("2.0.0"), "2.0.0" + ) warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") # Case 3: Installed version is greater than required, should not warn. mock_get_packages.reset_mock() - mock_get_version.return_value = (parse_version("3.0.0"), "3.0.0") + mock_get_version.return_value = DependencyVersion( + parse_version("3.0.0"), "3.0.0" + ) warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") # Case 4: Dependency not found, should not warn. mock_get_packages.reset_mock() - mock_get_version.return_value = (None, "--") + mock_get_version.return_value = DependencyVersion(None, "--") warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") # Assert that no warnings were recorded @@ -109,7 +113,7 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack ("dep-package (dep.package)", "dep-package"), ("my-package (my.package)", "my-package"), ] - mock_get_version.return_value = (parse_version("1.0.0"), "1.0.0") + mock_get_version.return_value = DependencyVersion(parse_version("1.0.0"), "1.0.0") template = "Custom warning for {dependency_package} used by {dependent_package}." with pytest.warns(FutureWarning) as record: warn_deprecation_for_versions_less_than( From ecb72118f1d0509600b93d39a954776139c13c4e Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 13:28:39 -0700 Subject: [PATCH 43/58] refactor: s/"--"/UNKNOWN_VERSION_STRING/ in code, not test --- google/api_core/_python_package_support.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 0bd16eb4..a8e35dff 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -29,6 +29,8 @@ DependencyVersion = namedtuple("DependencyVersion", ["version", "version_string"]) +UNKNOWN_VERSION_STRING = "--" + def get_dependency_version( dependency_name: str, @@ -43,9 +45,11 @@ def get_dependency_version( dependency_name: The distribution name of the package (e.g., 'requests'). Returns: - A DependencyVersion namedtuple with `version` and `version_string` - attributes, or `DependencyVersion(None, '--')` if the package is not - found or another error occurs during version discovery. + A DependencyVersion namedtuple with `version` and + `version_string` attributes, or `DependencyVersion(None, + UNKNOWN_VERSION_STRING)` if the package is not found or + another error occurs during version discovery. + """ try: if sys.version_info >= (3, 8): @@ -64,7 +68,7 @@ def get_dependency_version( return DependencyVersion(parse_version(version_string), version_string) except Exception: - return DependencyVersion(None, "--") + return DependencyVersion(None, UNKNOWN_VERSION_STRING) def warn_deprecation_for_versions_less_than( From 3c587cf6c4efb2b67f05a2784b7cd09e6e8ed7d0 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 13:44:35 -0700 Subject: [PATCH 44/58] refactor s/next_supported_version/minimum_fully_supported_version/ --- google/api_core/_python_package_support.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index a8e35dff..70679e73 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -74,28 +74,28 @@ def get_dependency_version( def warn_deprecation_for_versions_less_than( dependent_import_package: str, dependency_import_package: str, - next_supported_version: str, + minimum_fully_supported_version: str, recommended_version: Optional[str] = None, message_template: Optional[str] = None, ): """Issue any needed deprecation warnings for `dependency_import_package`. If `dependency_import_package` is installed at a version less than - `next_supported_version`, this issues a warning using either a + `minimum_fully_supported_version`, this issues a warning using either a default `message_template` or one provided by the user. The default `message_template` informs the user that they will not receive future updates for `dependent_import_package` if `dependency_import_package` is somehow pinned to a version lower - than `next_supported_version`. + than `minimum_fully_supported_version`. Args: dependent_import_package: The import name of the package that needs `dependency_import_package`. dependency_import_package: The import name of the dependency to check. - next_supported_version: The dependency_import_package version number + minimum_fully_supported_version: The dependency_import_package version number below which a deprecation warning will be logged. recommended_version: If provided, the recommended next version, which - could be higher than `next_supported_version`. + could be higher than `minimum_fully_supported_version`. message_template: A custom default message template to replace the default. This `message_template` is treated as an f-string, where the following variables are defined: @@ -105,7 +105,7 @@ def warn_deprecation_for_versions_less_than( `dependent_package` , which contain the import packages, the distribution packages, and pretty string with both the distribution and import packages for the dependency and the - dependent, respectively; and `next_supported_version`, + dependent, respectively; and `minimum_fully_supported_version`, `version_used`, and `version_used_string`, which refer to supported and currently-used versions of the dependency. @@ -113,13 +113,13 @@ def warn_deprecation_for_versions_less_than( if ( not dependent_import_package or not dependency_import_package - or not next_supported_version + or not minimum_fully_supported_version ): # pragma: NO COVER return dependency_version = get_dependency_version(dependency_import_package) if not dependency_version.version: return - if dependency_version.version < parse_version(next_supported_version): + if dependency_version.version < parse_version(minimum_fully_supported_version): ( dependency_package, dependency_distribution_package, @@ -138,13 +138,13 @@ def warn_deprecation_for_versions_less_than( {dependency_package}, currently installed at version {version_used_string}. Future updates to {dependent_package} will require {dependency_package} at - version {next_supported_version} or + version {minimum_fully_supported_version} or higher{recommendation}. Please ensure that either (a) your Python environment doesn't pin the version of {dependency_package}, so that updates to {dependent_package} can require the higher version, or (b) you manually update your Python environment to use at - least version {next_supported_version} of + least version {minimum_fully_supported_version} of {dependency_package}. """ ) @@ -156,7 +156,7 @@ def warn_deprecation_for_versions_less_than( dependency_distribution_package=dependency_distribution_package, dependency_package=dependency_package, dependent_package=dependent_package, - next_supported_version=next_supported_version, + minimum_fully_supported_version=minimum_fully_supported_version, recommendation=recommendation, version_used=dependency_version.version, version_used_string=dependency_version.version_string, From fbe0f0b6995ab2d6bf3e10b6363a7f38ba2821f4 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 14:00:30 -0700 Subject: [PATCH 45/58] refactor: s/dependent_/consumer_/ --- google/api_core/_python_package_support.py | 40 +++++++++++----------- tests/unit/test_python_package_support.py | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 70679e73..9e601217 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -72,7 +72,7 @@ def get_dependency_version( def warn_deprecation_for_versions_less_than( - dependent_import_package: str, + consumer_import_package: str, dependency_import_package: str, minimum_fully_supported_version: str, recommended_version: Optional[str] = None, @@ -84,12 +84,12 @@ def warn_deprecation_for_versions_less_than( `minimum_fully_supported_version`, this issues a warning using either a default `message_template` or one provided by the user. The default `message_template` informs the user that they will not receive - future updates for `dependent_import_package` if + future updates for `consumer_import_package` if `dependency_import_package` is somehow pinned to a version lower than `minimum_fully_supported_version`. Args: - dependent_import_package: The import name of the package that + consumer_import_package: The import name of the package that needs `dependency_import_package`. dependency_import_package: The import name of the dependency to check. minimum_fully_supported_version: The dependency_import_package version number @@ -99,19 +99,19 @@ def warn_deprecation_for_versions_less_than( message_template: A custom default message template to replace the default. This `message_template` is treated as an f-string, where the following variables are defined: - `dependency_import_package`, `dependent_import_package` and + `dependency_import_package`, `consumer_import_package` and `dependency_distribution_package` and - `dependent_distribution_package` and `dependency_package`, - `dependent_package` , which contain the import packages, the + `consumer_distribution_package` and `dependency_package`, + `consumer_package` , which contain the import packages, the distribution packages, and pretty string with both the distribution and import packages for the dependency and the - dependent, respectively; and `minimum_fully_supported_version`, + consumer, respectively; and `minimum_fully_supported_version`, `version_used`, and `version_used_string`, which refer to supported and currently-used versions of the dependency. """ if ( - not dependent_import_package + not consumer_import_package or not dependency_import_package or not minimum_fully_supported_version ): # pragma: NO COVER @@ -125,24 +125,24 @@ def warn_deprecation_for_versions_less_than( dependency_distribution_package, ) = _get_distribution_and_import_packages(dependency_import_package) ( - dependent_package, - dependent_distribution_package, - ) = _get_distribution_and_import_packages(dependent_import_package) + consumer_package, + consumer_distribution_package, + ) = _get_distribution_and_import_packages(consumer_import_package) recommendation = ( " (we recommend {recommended_version})" if recommended_version else "" ) message_template = message_template or _flatten_message( """ - DEPRECATION: Package {dependent_package} depends on + DEPRECATION: Package {consumer_package} depends on {dependency_package}, currently installed at version {version_used_string}. Future updates to - {dependent_package} will require {dependency_package} at + {consumer_package} will require {dependency_package} at version {minimum_fully_supported_version} or higher{recommendation}. Please ensure that either (a) your Python environment doesn't pin the version of {dependency_package}, so that updates to - {dependent_package} can require the higher version, or (b) + {consumer_package} can require the higher version, or (b) you manually update your Python environment to use at least version {minimum_fully_supported_version} of {dependency_package}. @@ -150,12 +150,12 @@ def warn_deprecation_for_versions_less_than( ) warnings.warn( message_template.format( - dependent_import_package=dependent_import_package, + consumer_import_package=consumer_import_package, dependency_import_package=dependency_import_package, - dependent_distribution_package=dependent_distribution_package, + consumer_distribution_package=consumer_distribution_package, dependency_distribution_package=dependency_distribution_package, dependency_package=dependency_package, - dependent_package=dependent_package, + consumer_package=consumer_package, minimum_fully_supported_version=minimum_fully_supported_version, recommendation=recommendation, version_used=dependency_version.version, @@ -165,7 +165,7 @@ def warn_deprecation_for_versions_less_than( ) -def check_dependency_versions(dependent_import_package: str): +def check_dependency_versions(consumer_import_package: str): """Bundle checks for all package dependencies. This function can be called by all dependents of google.api_core, @@ -173,10 +173,10 @@ def check_dependency_versions(dependent_import_package: str): dependencies. The dependencies to check should be updated here. Args: - dependent_import_package: The distribution name of the calling package, whose + consumer_import_package: The distribution name of the calling package, whose dependencies we're checking. """ warn_deprecation_for_versions_less_than( - dependent_import_package, "google.protobuf", "4.25.8", recommended_version="6.x" + consumer_import_package, "google.protobuf", "4.25.8", recommended_version="6.x" ) diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index 1397ebe3..f739ce5c 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -114,7 +114,7 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack ("my-package (my.package)", "my-package"), ] mock_get_version.return_value = DependencyVersion(parse_version("1.0.0"), "1.0.0") - template = "Custom warning for {dependency_package} used by {dependent_package}." + template = "Custom warning for {dependency_package} used by {consumer_package}." with pytest.warns(FutureWarning) as record: warn_deprecation_for_versions_less_than( "my.package", "dep.package", "2.0.0", message_template=template From 28b0b326e27598134ee2fb6ce8c88843c2b1970e Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 14:16:36 -0700 Subject: [PATCH 46/58] refactor: add _PACKAGE_DEPENDENCY_WARNINGS --- google/api_core/_python_package_support.py | 28 ++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 9e601217..495e3d5a 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -27,8 +27,24 @@ from packaging.version import parse as parse_version -DependencyVersion = namedtuple("DependencyVersion", ["version", "version_string"]) +# Here we list all the packages for which we want to issue warnings +# about deprecated and unsupported versions. +_DependencyConstraint = namedtuple( + "_DependencyConstraint", + ["package_name", "minimum_fully_supported_version", "recommended_version"], +) +_PACKAGE_DEPENDENCY_WARNINGS = [ + _DependencyConstraint( + "google.protobuf", + minimum_fully_supported_version="4.25.8", + recommended_version="6.x", + ) +] + +DependencyVersion = namedtuple("DependencyVersion", ["version", "version_string"]) +# Version string we provide in a DependencyVersion when we can't determine the version of a +# package. UNKNOWN_VERSION_STRING = "--" @@ -177,6 +193,10 @@ def check_dependency_versions(consumer_import_package: str): dependencies we're checking. """ - warn_deprecation_for_versions_less_than( - consumer_import_package, "google.protobuf", "4.25.8", recommended_version="6.x" - ) + for package_info in _PACKAGE_DEPENDENCY_WARNINGS: + warn_deprecation_for_versions_less_than( + consumer_import_package, + package_info.package_name, + package_info.minimum_fully_supported_version, + recommended_version=package_info.recommended_version, + ) From 21c9aec6dc8a98b957bf3daf8dc58f1c7e081f67 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 15:59:20 -0700 Subject: [PATCH 47/58] refactor: reorder and comment on _init__ statements for clarity --- google/api_core/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index 9286373c..0d375bb9 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -27,12 +27,13 @@ # google.api_core, the functionality below must be made available # manually in those artifacts. +# expose dependency checks for external callers check_python_version = _python_version_support.check_python_version -check_python_version(package="google.api_core") - check_dependency_versions = _python_package_support.check_dependency_versions -check_dependency_versions("google.api_core") - warn_deprecation_for_versions_less_than = ( _python_package_support.warn_deprecation_for_versions_less_than ) + +# perform version checks against api_core, and emit warnings if needed +check_python_version(package="google.api_core") +check_dependency_versions("google.api_core") From e24c13d24284bef02eaa7065aaea2afc15b94122 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 16:36:51 -0700 Subject: [PATCH 48/58] fix: exapnd VersionInfo with version field; clarify fake dates --- google/api_core/_python_version_support.py | 43 +++++++++++++++------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 65b9ecde..9aa96a41 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -35,6 +35,7 @@ class PythonVersionStatus(enum.Enum): class VersionInfo(NamedTuple): """Hold release and support date information for a Python version.""" + version: str python_beta: Optional[datetime.date] python_start: datetime.date python_eol: datetime.date @@ -44,55 +45,67 @@ class VersionInfo(NamedTuple): dep_unpatchable_cve: Optional[datetime.date] = None # unused -PYTHON_VERSION_INFO: Dict[Tuple[int, int], VersionInfo] = { +PYTHON_VERSIONS: list[VersionInfo] = [ # Refer to https://devguide.python.org/versions/ and the PEPs linked therefrom. - (3, 7): VersionInfo( + VersionInfo( + version="3.7", python_beta=None, python_start=datetime.date(2018, 6, 27), python_eol=datetime.date(2023, 6, 27), ), - (3, 8): VersionInfo( + VersionInfo( + version="3.8", python_beta=None, python_start=datetime.date(2019, 10, 14), python_eol=datetime.date(2024, 10, 7), ), - (3, 9): VersionInfo( + VersionInfo( + version="3.9", python_beta=datetime.date(2020, 5, 18), python_start=datetime.date(2020, 10, 5), - python_eol=datetime.date(2025, 10, 5), # TODO: specify day when announced - gapic_end=datetime.date(2025, 10, 5) - + datetime.timedelta(days=90), # TODO: specify day when announced + python_eol=datetime.date(2025, 10, 5), + gapic_end=datetime.date(2025, 10, 5) + datetime.timedelta(days=90), ), - (3, 10): VersionInfo( + VersionInfo( + version="3.10", python_beta=datetime.date(2021, 5, 3), python_start=datetime.date(2021, 10, 4), python_eol=datetime.date(2026, 10, 4), # TODO: specify day when announced ), - (3, 11): VersionInfo( + VersionInfo( + version="3.11", python_beta=datetime.date(2022, 5, 8), python_start=datetime.date(2022, 10, 24), python_eol=datetime.date(2027, 10, 24), # TODO: specify day when announced ), - (3, 12): VersionInfo( + VersionInfo( + version="3.12", python_beta=datetime.date(2023, 5, 22), python_start=datetime.date(2023, 10, 2), python_eol=datetime.date(2028, 10, 2), # TODO: specify day when announced ), - (3, 13): VersionInfo( + VersionInfo( + version="3.13", python_beta=datetime.date(2024, 5, 8), python_start=datetime.date(2024, 10, 7), python_eol=datetime.date(2029, 10, 7), # TODO: specify day when announced ), - (3, 14): VersionInfo( + VersionInfo( + version="3.14", python_beta=datetime.date(2025, 5, 7), python_start=datetime.date(2025, 10, 7), python_eol=datetime.date(2030, 10, 7), # TODO: specify day when announced ), +] + +PYTHON_VERSION_INFO: Dict[Tuple[int, int], VersionInfo] = { + tuple(map(int, info.version.split("."))): info for info in PYTHON_VERSIONS } + LOWEST_TRACKED_VERSION = min(PYTHON_VERSION_INFO.keys()) -FAKE_PAST_DATE = datetime.date(1970, 1, 1) -FAKE_FUTURE_DATE = datetime.date(9000, 1, 1) +FAKE_PAST_DATE = datetime.date.min + datetime.timedelta(days=900) +FAKE_FUTURE_DATE = datetime.date.max - datetime.timedelta(days=900) DEPRECATION_WARNING_PERIOD = datetime.timedelta(days=365) EOL_GRACE_PERIOD = datetime.timedelta(weeks=1) @@ -164,12 +177,14 @@ def check_python_version( if not version_info: if version_tuple < LOWEST_TRACKED_VERSION: version_info = VersionInfo( + version="0.0", python_beta=FAKE_PAST_DATE, python_start=FAKE_PAST_DATE, python_eol=FAKE_PAST_DATE, ) else: version_info = VersionInfo( + version="999.0", python_beta=FAKE_FUTURE_DATE, python_start=FAKE_FUTURE_DATE, python_eol=FAKE_FUTURE_DATE, From dbc3a26ffd2e9331eb7600799087cf3b312cc108 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 16:55:28 -0700 Subject: [PATCH 49/58] doc: document PythonVersionSupportStatus --- google/api_core/_python_version_support.py | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 9aa96a41..24bb0c4c 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -23,13 +23,32 @@ class PythonVersionStatus(enum.Enum): - """Represent the support status of a Python version.""" + """Support status of a Python version in this client library artifact release. + + "Support", in this context, means that this release of a client library + artifact is configured to run on the currently configured version of + Python. + """ PYTHON_VERSION_STATUS_UNSPECIFIED = "PYTHON_VERSION_STATUS_UNSPECIFIED" - PYTHON_VERSION_UNSUPPORTED = "PYTHON_VERSION_UNSUPPORTED" - PYTHON_VERSION_EOL = "PYTHON_VERSION_EOL" - PYTHON_VERSION_DEPRECATED = "PYTHON_VERSION_DEPRECATED" + PYTHON_VERSION_SUPPORTED = "PYTHON_VERSION_SUPPORTED" + """This Python version is fully supported, so the artifact running on this + version will have all features and bug fixes.""" + + PYTHON_VERSION_DEPRECATED = "PYTHON_VERSION_DEPRECATED" + """This Python version is still supported, but support will end within a + year. At that time, there will be no more releases for this artifact + running under this Python version.""" + + PYTHON_VERSION_EOL = "PYTHON_VERSION_EOL" + """This Python version has reached its end of life in the Python community + (see https://devguide.python.org/versions/), and this artifact will cease + supporting this Python version within the next few releases.""" + + PYTHON_VERSION_UNSUPPORTED = "PYTHON_VERSION_UNSUPPORTED" + """This release of the client library artifact may not be the latest, since + current releases no longer support this Python version.""" class VersionInfo(NamedTuple): From d472baeb3eaf0259c03b2c859a0e09e738143703 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 17:05:39 -0700 Subject: [PATCH 50/58] refactor: make fake past and future versions into constants --- google/api_core/_python_version_support.py | 30 ++++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 24bb0c4c..0ac59f73 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -123,8 +123,20 @@ class VersionInfo(NamedTuple): LOWEST_TRACKED_VERSION = min(PYTHON_VERSION_INFO.keys()) -FAKE_PAST_DATE = datetime.date.min + datetime.timedelta(days=900) -FAKE_FUTURE_DATE = datetime.date.max - datetime.timedelta(days=900) +_FAKE_PAST_DATE = datetime.date.min + datetime.timedelta(days=900) +_FAKE_PAST_VERSION = VersionInfo( + version="0.0", + python_beta=_FAKE_PAST_DATE, + python_start=_FAKE_PAST_DATE, + python_eol=_FAKE_PAST_DATE, +) +_FAKE_FUTURE_DATE = datetime.date.max - datetime.timedelta(days=900) +_FAKE_FUTURE_VERSION = VersionInfo( + version="999.0", + python_beta=_FAKE_FUTURE_DATE, + python_start=_FAKE_FUTURE_DATE, + python_eol=_FAKE_FUTURE_DATE, +) DEPRECATION_WARNING_PERIOD = datetime.timedelta(days=365) EOL_GRACE_PERIOD = datetime.timedelta(weeks=1) @@ -195,19 +207,9 @@ def check_python_version( if not version_info: if version_tuple < LOWEST_TRACKED_VERSION: - version_info = VersionInfo( - version="0.0", - python_beta=FAKE_PAST_DATE, - python_start=FAKE_PAST_DATE, - python_eol=FAKE_PAST_DATE, - ) + version_info = _FAKE_PAST_VERSION else: - version_info = VersionInfo( - version="999.0", - python_beta=FAKE_FUTURE_DATE, - python_start=FAKE_FUTURE_DATE, - python_eol=FAKE_FUTURE_DATE, - ) + version_info = _FAKE_FUTURE_VERSION gapic_deprecation = version_info.gapic_deprecation or ( version_info.python_eol - DEPRECATION_WARNING_PERIOD From 0550f8387b772fde14dda717c47845accd7edc33 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 17:08:21 -0700 Subject: [PATCH 51/58] refactor: reword default string for min_python --- google/api_core/_python_version_support.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 0ac59f73..2f76bd54 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -221,7 +221,7 @@ def min_python(date: datetime.date) -> str: for version, info in sorted(PYTHON_VERSION_INFO.items()): if info.python_start <= date < info.python_eol: return f"{version[0]}.{version[1]}" - return "at a supported version" + return "at a currently supported version [https://devguide.python.org/versions]" if gapic_end < today: message = _flatten_message( From 491b695f63fb0aac662177d1f9fc3ff995dba1c9 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 17:30:43 -0700 Subject: [PATCH 52/58] fix: fix populating PYTHON_VERSION_INFO --- google/api_core/_python_version_support.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 2f76bd54..5a2d9e80 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -117,9 +117,10 @@ class VersionInfo(NamedTuple): ), ] -PYTHON_VERSION_INFO: Dict[Tuple[int, int], VersionInfo] = { - tuple(map(int, info.version.split("."))): info for info in PYTHON_VERSIONS -} +PYTHON_VERSION_INFO: Dict[Tuple[int, int], VersionInfo] = {} +for info in PYTHON_VERSIONS: + major, minor = map(int, info.version.split(".")) + PYTHON_VERSION_INFO[(major, minor)] = info LOWEST_TRACKED_VERSION = min(PYTHON_VERSION_INFO.keys()) From 6338389a2578fa0473b6265dbad76c096a54d501 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 13 Oct 2025 19:16:57 -0700 Subject: [PATCH 53/58] fix: type hints --- google/api_core/_python_version_support.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 5a2d9e80..72c9b39a 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -19,7 +19,7 @@ import warnings import sys import textwrap -from typing import Any, NamedTuple, Optional, Dict, Tuple +from typing import Any, List, NamedTuple, Optional, Dict, Tuple class PythonVersionStatus(enum.Enum): @@ -64,7 +64,7 @@ class VersionInfo(NamedTuple): dep_unpatchable_cve: Optional[datetime.date] = None # unused -PYTHON_VERSIONS: list[VersionInfo] = [ +PYTHON_VERSIONS: List[VersionInfo] = [ # Refer to https://devguide.python.org/versions/ and the PEPs linked therefrom. VersionInfo( version="3.7", From 05bcf6f9459866a23c1178db520abad2819e27a9 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Fri, 17 Oct 2025 13:10:33 -0700 Subject: [PATCH 54/58] feat: allow check_dependency_versions() to take any number of DependencyConstraints --- google/api_core/__init__.py | 1 + google/api_core/_python_package_support.py | 23 ++++++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index 0d375bb9..85811a15 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -33,6 +33,7 @@ warn_deprecation_for_versions_less_than = ( _python_package_support.warn_deprecation_for_versions_less_than ) +DependencyConstraint = _python_package_support.DependencyConstraint # perform version checks against api_core, and emit warnings if needed check_python_version(package="google.api_core") diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index 495e3d5a..cc805b8d 100644 --- a/google/api_core/_python_package_support.py +++ b/google/api_core/_python_package_support.py @@ -29,12 +29,12 @@ # Here we list all the packages for which we want to issue warnings # about deprecated and unsupported versions. -_DependencyConstraint = namedtuple( - "_DependencyConstraint", +DependencyConstraint = namedtuple( + "DependencyConstraint", ["package_name", "minimum_fully_supported_version", "recommended_version"], ) _PACKAGE_DEPENDENCY_WARNINGS = [ - _DependencyConstraint( + DependencyConstraint( "google.protobuf", minimum_fully_supported_version="4.25.8", recommended_version="6.x", @@ -181,19 +181,26 @@ def warn_deprecation_for_versions_less_than( ) -def check_dependency_versions(consumer_import_package: str): +def check_dependency_versions( + consumer_import_package: str, *package_dependency_warnings: DependencyConstraint +): """Bundle checks for all package dependencies. - This function can be called by all dependents of google.api_core, + This function can be called by all consumers of google.api_core, to emit needed deprecation warnings for any of their - dependencies. The dependencies to check should be updated here. + dependencies. The dependencies to check can be passed as arguments, or if + none are provided, it will default to the list in + `_PACKAGE_DEPENDENCY_WARNINGS`. Args: consumer_import_package: The distribution name of the calling package, whose dependencies we're checking. - + *package_dependency_warnings: A variable number of DependencyConstraint + objects, each specifying a dependency to check. """ - for package_info in _PACKAGE_DEPENDENCY_WARNINGS: + if not package_dependency_warnings: + package_dependency_warnings = tuple(_PACKAGE_DEPENDENCY_WARNINGS) + for package_info in package_dependency_warnings: warn_deprecation_for_versions_less_than( consumer_import_package, package_info.package_name, From fc224b784d81a741147632c11358f8abcdd9355c Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Fri, 17 Oct 2025 13:33:23 -0700 Subject: [PATCH 55/58] add unit test to satisfy coverage requirement --- tests/unit/test_python_package_support.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index f739ce5c..47d3257a 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -124,3 +124,22 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack "Custom warning for dep-package (dep.package) used by my-package (my.package)." in str(record[0].message) ) + + +from google.api_core._python_package_support import ( + check_dependency_versions, + DependencyConstraint, +) + + +@patch("google.api_core._python_package_support.warn_deprecation_for_versions_less_than") +def test_check_dependency_versions_with_custom_warnings(mock_warn): + """Test check_dependency_versions with custom warning parameters.""" + custom_warning1 = DependencyConstraint("pkg1", "1.0.0", "2.0.0") + custom_warning2 = DependencyConstraint("pkg2", "2.0.0", "3.0.0") + + check_dependency_versions("my-consumer", custom_warning1, custom_warning2) + + assert mock_warn.call_count == 2 + mock_warn.assert_any_call("my-consumer", "pkg1", "1.0.0", recommended_version="2.0.0") + mock_warn.assert_any_call("my-consumer", "pkg2", "2.0.0", recommended_version="3.0.0") From a6c40ce28e40cb969300c035ab6d1d3dd5f66dce Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Fri, 17 Oct 2025 13:41:55 -0700 Subject: [PATCH 56/58] fix lint --- tests/unit/test_python_package_support.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index 47d3257a..0d74b4b6 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -132,7 +132,9 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack ) -@patch("google.api_core._python_package_support.warn_deprecation_for_versions_less_than") +@patch( + "google.api_core._python_package_support.warn_deprecation_for_versions_less_than" +) def test_check_dependency_versions_with_custom_warnings(mock_warn): """Test check_dependency_versions with custom warning parameters.""" custom_warning1 = DependencyConstraint("pkg1", "1.0.0", "2.0.0") @@ -141,5 +143,9 @@ def test_check_dependency_versions_with_custom_warnings(mock_warn): check_dependency_versions("my-consumer", custom_warning1, custom_warning2) assert mock_warn.call_count == 2 - mock_warn.assert_any_call("my-consumer", "pkg1", "1.0.0", recommended_version="2.0.0") - mock_warn.assert_any_call("my-consumer", "pkg2", "2.0.0", recommended_version="3.0.0") + mock_warn.assert_any_call( + "my-consumer", "pkg1", "1.0.0", recommended_version="2.0.0" + ) + mock_warn.assert_any_call( + "my-consumer", "pkg2", "2.0.0", recommended_version="3.0.0" + ) From d0414b2b755d5c1dca74a084110ed0447f2f03e7 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Fri, 17 Oct 2025 13:49:41 -0700 Subject: [PATCH 57/58] fix lint --- tests/unit/test_python_package_support.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index 0d74b4b6..56990365 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -22,6 +22,8 @@ from google.api_core._python_package_support import ( get_dependency_version, warn_deprecation_for_versions_less_than, + check_dependency_versions, + DependencyConstraint, DependencyVersion, ) @@ -126,12 +128,6 @@ def test_warn_deprecation_for_versions_less_than(mock_get_version, mock_get_pack ) -from google.api_core._python_package_support import ( - check_dependency_versions, - DependencyConstraint, -) - - @patch( "google.api_core._python_package_support.warn_deprecation_for_versions_less_than" ) From 835df53132da8b0b187d45c9b113eca0f285f28b Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Thu, 23 Oct 2025 16:01:18 -0700 Subject: [PATCH 58/58] test: add test for _flatten_message --- google/api_core/_python_version_support.py | 2 +- tests/unit/test_python_version_support.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 72c9b39a..9fb92af6 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -144,7 +144,7 @@ class VersionInfo(NamedTuple): def _flatten_message(text: str) -> str: """Dedent a multi-line string and flatten it into a single line.""" - return textwrap.dedent(text).strip().replace("\n", " ") + return " ".join(textwrap.dedent(text).strip().split()) # TODO(https://github.com/googleapis/python-api-core/issues/835): Remove once we diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py index e3735fce..c38160b7 100644 --- a/tests/unit/test_python_version_support.py +++ b/tests/unit/test_python_version_support.py @@ -22,6 +22,7 @@ # Code to be tested from google.api_core._python_version_support import ( + _flatten_message, check_python_version, PythonVersionStatus, PYTHON_VERSION_INFO, @@ -31,6 +32,17 @@ VersionInfoMock = namedtuple("VersionInfoMock", ["major", "minor"]) +def test_flatten_message(): + """Test that _flatten_message correctly dedents and flattens a string.""" + input_text = """ + This is a multi-line + string with some + indentation. + """ + expected_output = "This is a multi-line string with some indentation." + assert _flatten_message(input_text) == expected_output + + def _create_failure_message( expected, result, py_version, date, gapic_dep, py_eol, eol_warn, gapic_end ):