diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index b80ea3726..85811a157 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -17,6 +17,24 @@ 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__ + +# NOTE: Until dependent artifacts require this version of +# 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_dependency_versions = _python_package_support.check_dependency_versions +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") +check_dependency_versions("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 000000000..cc805b8d7 --- /dev/null +++ b/google/api_core/_python_package_support.py @@ -0,0 +1,209 @@ +# 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 warnings +import sys +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 + +# 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 = "--" + + +def get_dependency_version( + dependency_name: str, +) -> DependencyVersion: + """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 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): + from importlib import metadata + + version_string = metadata.version(dependency_name) + 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 + else: # pragma: NO COVER + # Use pkg_resources, which is part of setuptools. + import pkg_resources + + version_string = pkg_resources.get_distribution(dependency_name).version + return DependencyVersion(parse_version(version_string), version_string) + + except Exception: + return DependencyVersion(None, UNKNOWN_VERSION_STRING) + + +def warn_deprecation_for_versions_less_than( + consumer_import_package: str, + dependency_import_package: 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 + `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 `consumer_import_package` if + `dependency_import_package` is somehow pinned to a version lower + than `minimum_fully_supported_version`. + + Args: + 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 + below which a deprecation warning will be logged. + recommended_version: If provided, the recommended next version, which + 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: + `dependency_import_package`, `consumer_import_package` and + `dependency_distribution_package` and + `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 + 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 consumer_import_package + or not dependency_import_package + 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(minimum_fully_supported_version): + ( + dependency_package, + dependency_distribution_package, + ) = _get_distribution_and_import_packages(dependency_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 {consumer_package} depends on + {dependency_package}, currently installed at version + {version_used_string}. Future updates to + {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 + {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}. + """ + ) + warnings.warn( + message_template.format( + consumer_import_package=consumer_import_package, + dependency_import_package=dependency_import_package, + consumer_distribution_package=consumer_distribution_package, + dependency_distribution_package=dependency_distribution_package, + dependency_package=dependency_package, + consumer_package=consumer_package, + minimum_fully_supported_version=minimum_fully_supported_version, + recommendation=recommendation, + version_used=dependency_version.version, + version_used_string=dependency_version.version_string, + ), + FutureWarning, + ) + + +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 consumers of google.api_core, + to emit needed deprecation warnings for any of their + 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. + """ + 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, + package_info.minimum_fully_supported_version, + recommended_version=package_info.recommended_version, + ) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py new file mode 100644 index 000000000..9fb92af6f --- /dev/null +++ b/google/api_core/_python_version_support.py @@ -0,0 +1,269 @@ +# 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 warnings +import sys +import textwrap +from typing import Any, List, NamedTuple, Optional, Dict, Tuple + + +class PythonVersionStatus(enum.Enum): + """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_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): + """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 + 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 # unused + + +PYTHON_VERSIONS: List[VersionInfo] = [ + # Refer to https://devguide.python.org/versions/ and the PEPs linked therefrom. + VersionInfo( + version="3.7", + python_beta=None, + python_start=datetime.date(2018, 6, 27), + python_eol=datetime.date(2023, 6, 27), + ), + VersionInfo( + version="3.8", + python_beta=None, + python_start=datetime.date(2019, 10, 14), + python_eol=datetime.date(2024, 10, 7), + ), + 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), + gapic_end=datetime.date(2025, 10, 5) + datetime.timedelta(days=90), + ), + 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 + ), + 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 + ), + 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 + ), + 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 + ), + 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] = {} +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()) +_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) + + +def _flatten_message(text: str) -> str: + """Dedent a multi-line string and flatten it into a single line.""" + return " ".join(textwrap.dedent(text).strip().split()) + + +# 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 + """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: # pragma: NO COVER + # 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) -> 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 = ( + 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: str = "this package", 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() + package_label, _ = _get_distribution_and_import_packages(package) + + python_version = sys.version_info + version_tuple = (python_version.major, python_version.minor) + py_version_str = sys.version.split()[0] + + version_info = PYTHON_VERSION_INFO.get(version_tuple) + + if not version_info: + if version_tuple < LOWEST_TRACKED_VERSION: + version_info = _FAKE_PAST_VERSION + else: + version_info = _FAKE_FUTURE_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) + + 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 currently supported version [https://devguide.python.org/versions]" + + 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} + 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) + return PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + + 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_label} + with critical bug fixes on a best-effort basis, but not + 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}. + """ + ) + warnings.warn(message, FutureWarning) + 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 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) + return PythonVersionStatus.PYTHON_VERSION_DEPRECATED + + return PythonVersionStatus.PYTHON_VERSION_SUPPORTED diff --git a/tests/unit/gapic/test_method.py b/tests/unit/gapic/test_method.py index c27de64ea..927902bf0 100644 --- a/tests/unit/gapic/test_method.py +++ b/tests/unit/gapic/test_method.py @@ -186,7 +186,9 @@ def test_wrap_method_with_overriding_retry_timeout_compression(unused_sleep): assert result == 42 assert method.call_count == 2 method.assert_called_with( - timeout=22, compression=grpc.Compression.Deflate, metadata=mock.ANY + timeout=22, + compression=grpc.Compression.Deflate, + metadata=mock.ANY, ) diff --git a/tests/unit/test_bidi.py b/tests/unit/test_bidi.py index 7640367ce..b51db249a 100644 --- a/tests/unit/test_bidi.py +++ b/tests/unit/test_bidi.py @@ -828,7 +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 # pragma: NO COVER + assert callback.call_args.args[0] == grpc.StatusCode.INVALID_ARGUMENT def test_consumer_expected_error(self, caplog): diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py new file mode 100644 index 000000000..569903658 --- /dev/null +++ b/tests/unit/test_python_package_support.py @@ -0,0 +1,147 @@ +# 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 +import warnings +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, + check_dependency_versions, + DependencyConstraint, + DependencyVersion, +) + + +# 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): + """Test get_dependency_version on Python 3.8+.""" + mock_version.return_value = "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") == DependencyVersion(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): + """Test get_dependency_version on Python 3.7.""" + mock_dist = MagicMock() + mock_dist.version = "4.5.6" + mock_get_distribution.return_value = mock_dist + 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") == DependencyVersion(None, "--") + + +@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): + """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"), + ] + + 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 + assert ( + "DEPRECATION: Package my-package (my.package) depends on dep-package (dep.package)" + in str(record[0].message) + ) + + # Cases where no warning should be issued + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") # Capture all warnings + + # Case 2: Installed version is equal to required, should not warn. + mock_get_packages.reset_mock() + 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 = 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 = DependencyVersion(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_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 = DependencyVersion(parse_version("1.0.0"), "1.0.0") + 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 + ) + assert len(record) == 1 + assert ( + "Custom warning for dep-package (dep.package) used by my-package (my.package)." + in str(record[0].message) + ) + + +@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" + ) diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py new file mode 100644 index 000000000..c38160b78 --- /dev/null +++ b/tests/unit/test_python_version_support.py @@ -0,0 +1,253 @@ +# 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 +import warnings +from collections import namedtuple + +from unittest.mock import patch + +# Code to be tested +from google.api_core._python_version_support import ( + _flatten_message, + check_python_version, + PythonVersionStatus, + PYTHON_VERSION_INFO, +) + +# Helper object for mocking sys.version_info +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 +): + """Create a detailed failure message for a test.""" + return textwrap.dedent( # pragma: NO COVER + 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): + # 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(FutureWarning) 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(): + """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_before_boundary = check_python_version( + today=custom_gapic_end + datetime.timedelta(days=-1) + ) + 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 + + 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 pytest.warns(FutureWarning) as record: + mock_date = datetime.date(2025, 1, 15) + result = check_python_version(today=mock_date) + + assert result == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED + assert len(record) == 1 + assert "non-supported" in str(record[0].message) + + +def test_untracked_newer_version_is_supported(): + """Test that a new, untracked version is supported and does not log.""" + mock_py_version = VersionInfoMock(major=40, minor=0) + + with patch( + "google.api_core._python_version_support.sys.version_info", mock_py_version + ): + 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 + assert len(w) == 0 diff --git a/tests/unit/test_timeout.py b/tests/unit/test_timeout.py index 2c20202bd..8ce143f35 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):