From 14a59789e144905bd6c82180ad07a52bf1f84f02 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Thu, 4 Sep 2025 16:56:26 -0700 Subject: [PATCH 01/27] fix: fix async tests and round-off error in test expectations (#837) * fix: properly designate async test methods * fix: fix async tests * fix: made additional test be an async method --- tests/asyncio/gapic/test_method_async.py | 6 +++++- tests/asyncio/test_operation_async.py | 4 ++-- tests/unit/gapic/test_method.py | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/asyncio/gapic/test_method_async.py b/tests/asyncio/gapic/test_method_async.py index cc4e7de83..3edf8b6d4 100644 --- a/tests/asyncio/gapic/test_method_async.py +++ b/tests/asyncio/gapic/test_method_async.py @@ -256,7 +256,11 @@ async def test_wrap_method_with_overriding_timeout_as_a_number(): result = await wrapped_method(timeout=22) assert result == 42 - method.assert_called_once_with(timeout=22, metadata=mock.ANY) + + actual_timeout = method.call_args[1]["timeout"] + metadata = method.call_args[1]["metadata"] + assert metadata == mock.ANY + assert actual_timeout == pytest.approx(22, abs=0.01) @pytest.mark.asyncio diff --git a/tests/asyncio/test_operation_async.py b/tests/asyncio/test_operation_async.py index 9d9fb5d25..5b2f012bf 100644 --- a/tests/asyncio/test_operation_async.py +++ b/tests/asyncio/test_operation_async.py @@ -85,7 +85,7 @@ async def test_constructor(): @pytest.mark.asyncio -def test_metadata(): +async def test_metadata(): expected_metadata = struct_pb2.Struct() future, _, _ = make_operation_future( [make_operation_proto(metadata=expected_metadata)] @@ -178,7 +178,7 @@ async def test_unexpected_result(unused_sleep): @pytest.mark.asyncio -def test_from_gapic(): +async def test_from_gapic(): operation_proto = make_operation_proto(done=True) operations_client = mock.create_autospec( operations_v1.OperationsClient, instance=True diff --git a/tests/unit/gapic/test_method.py b/tests/unit/gapic/test_method.py index 8896429cd..c27de64ea 100644 --- a/tests/unit/gapic/test_method.py +++ b/tests/unit/gapic/test_method.py @@ -201,7 +201,11 @@ def test_wrap_method_with_overriding_timeout_as_a_number(): result = wrapped_method(timeout=22) assert result == 42 - method.assert_called_once_with(timeout=22, metadata=mock.ANY) + + actual_timeout = method.call_args[1]["timeout"] + metadata = method.call_args[1]["metadata"] + assert metadata == mock.ANY + assert actual_timeout == pytest.approx(22, abs=0.01) def test_wrap_method_with_call(): From 324eb7464d6ade9a8c2e413d4695bc7d7adfcb3d Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 1 Oct 2025 05:44:18 -0400 Subject: [PATCH 02/27] fix: deprecate credentials_file argument (#841) * fix: deprecate credentials_file argument * lint * cover * fix build --- google/api_core/client_options.py | 11 ++++-- google/api_core/general_helpers.py | 36 +++++++++++++++++++ google/api_core/grpc_helpers.py | 17 +++++---- google/api_core/grpc_helpers_async.py | 11 ++++-- .../api_core/operations_v1/transports/base.py | 20 +++++++---- .../api_core/operations_v1/transports/rest.py | 31 +++++++++------- .../operations_v1/transports/rest_asyncio.py | 21 +++++++++++ .../test_operations_rest_client.py | 35 ++++++++++++++++-- 8 files changed, 149 insertions(+), 33 deletions(-) diff --git a/google/api_core/client_options.py b/google/api_core/client_options.py index d11665d22..30bff4824 100644 --- a/google/api_core/client_options.py +++ b/google/api_core/client_options.py @@ -49,6 +49,9 @@ def get_client_cert(): """ from typing import Callable, Mapping, Optional, Sequence, Tuple +import warnings + +from google.api_core import general_helpers class ClientOptions(object): @@ -67,8 +70,9 @@ class ClientOptions(object): and ``client_encrypted_cert_source`` are mutually exclusive. quota_project_id (Optional[str]): A project name that a client's quota belongs to. - credentials_file (Optional[str]): A path to a file storing credentials. - ``credentials_file` and ``api_key`` are mutually exclusive. + credentials_file (Optional[str]): Deprecated. A path to a file storing credentials. + ``credentials_file` and ``api_key`` are mutually exclusive. This argument will be + removed in the next major version of `google-api-core`. .. warning:: Important: If you accept a credential configuration (credential JSON/File/Stream) @@ -114,6 +118,9 @@ def __init__( api_audience: Optional[str] = None, universe_domain: Optional[str] = None, ): + if credentials_file is not None: + warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) + if client_cert_source and client_encrypted_cert_source: raise ValueError( "client_cert_source and client_encrypted_cert_source are mutually exclusive" diff --git a/google/api_core/general_helpers.py b/google/api_core/general_helpers.py index a6af45b7a..06282299d 100644 --- a/google/api_core/general_helpers.py +++ b/google/api_core/general_helpers.py @@ -14,3 +14,39 @@ # This import for backward compatibility only. from functools import wraps # noqa: F401 pragma: NO COVER + +_CREDENTIALS_FILE_WARNING = """\ +The `credentials_file` argument is deprecated because of a potential security risk. + +The `google.auth.load_credentials_from_file` method does not validate the credential +configuration. The security risk occurs when a credential configuration is accepted +from a source that is not under your control and used without validation on your side. + +If you know that you will be loading credential configurations of a +specific type, it is recommended to use a credential-type-specific +load method. + +This will ensure that an unexpected credential type with potential for +malicious intent is not loaded unintentionally. You might still have to do +validation for certain credential types. Please follow the recommendations +for that method. For example, if you want to load only service accounts, +you can create the service account credentials explicitly: + +``` +from google.cloud.vision_v1 import ImageAnnotatorClient +from google.oauth2 import service_account + +credentials = service_account.Credentials.from_service_account_file(filename) +client = ImageAnnotatorClient(credentials=credentials) +``` + +If you are loading your credential configuration from an untrusted source and have +not mitigated the risks (e.g. by validating the configuration yourself), make +these changes as soon as possible to prevent security risks to your environment. + +Regardless of the method used, it is always your responsibility to validate +configurations received from external sources. + +Refer to https://cloud.google.com/docs/authentication/external/externally-sourced-credentials +for more details. +""" diff --git a/google/api_core/grpc_helpers.py b/google/api_core/grpc_helpers.py index 079630248..430b8ce48 100644 --- a/google/api_core/grpc_helpers.py +++ b/google/api_core/grpc_helpers.py @@ -13,20 +13,19 @@ # limitations under the License. """Helpers for :mod:`grpc`.""" -from typing import Generic, Iterator, Optional, TypeVar - import collections import functools +from typing import Generic, Iterator, Optional, TypeVar import warnings -import grpc - -from google.api_core import exceptions import google.auth import google.auth.credentials import google.auth.transport.grpc import google.auth.transport.requests import google.protobuf +import grpc + +from google.api_core import exceptions, general_helpers PROTOBUF_VERSION = google.protobuf.__version__ @@ -213,9 +212,10 @@ def _create_composite_credentials( credentials (google.auth.credentials.Credentials): The credentials. If not specified, then this function will attempt to ascertain the credentials from the environment using :func:`google.auth.default`. - credentials_file (str): A file with credentials that can be loaded with + credentials_file (str): Deprecated. A file with credentials that can be loaded with :func:`google.auth.load_credentials_from_file`. This argument is - mutually exclusive with credentials. + mutually exclusive with credentials. This argument will be + removed in the next major version of `google-api-core`. .. warning:: Important: If you accept a credential configuration (credential JSON/File/Stream) @@ -245,6 +245,9 @@ def _create_composite_credentials( Raises: google.api_core.DuplicateCredentialArgs: If both a credentials object and credentials_file are passed. """ + if credentials_file is not None: + warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) + if credentials and credentials_file: raise exceptions.DuplicateCredentialArgs( "'credentials' and 'credentials_file' are mutually exclusive." diff --git a/google/api_core/grpc_helpers_async.py b/google/api_core/grpc_helpers_async.py index af6614302..312d4df80 100644 --- a/google/api_core/grpc_helpers_async.py +++ b/google/api_core/grpc_helpers_async.py @@ -20,13 +20,14 @@ import asyncio import functools +import warnings from typing import AsyncGenerator, Generic, Iterator, Optional, TypeVar import grpc from grpc import aio -from google.api_core import exceptions, grpc_helpers +from google.api_core import exceptions, general_helpers, grpc_helpers # denotes the proto response type for grpc calls P = TypeVar("P") @@ -233,9 +234,10 @@ def create_channel( are passed to :func:`google.auth.default`. ssl_credentials (grpc.ChannelCredentials): Optional SSL channel credentials. This can be used to specify different certificates. - credentials_file (str): A file with credentials that can be loaded with + credentials_file (str): Deprecated. A file with credentials that can be loaded with :func:`google.auth.load_credentials_from_file`. This argument is - mutually exclusive with credentials. + mutually exclusive with credentials. This argument will be + removed in the next major version of `google-api-core`. .. warning:: Important: If you accept a credential configuration (credential JSON/File/Stream) @@ -280,6 +282,9 @@ def create_channel( ValueError: If `ssl_credentials` is set and `attempt_direct_path` is set to `True`. """ + if credentials_file is not None: + warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) + # If `ssl_credentials` is set and `attempt_direct_path` is set to `True`, # raise ValueError as this is not yet supported. # See https://github.com/googleapis/python-api-core/issues/590 diff --git a/google/api_core/operations_v1/transports/base.py b/google/api_core/operations_v1/transports/base.py index 71764c1e9..46c2f5d16 100644 --- a/google/api_core/operations_v1/transports/base.py +++ b/google/api_core/operations_v1/transports/base.py @@ -16,12 +16,8 @@ import abc import re from typing import Awaitable, Callable, Optional, Sequence, Union +import warnings -import google.api_core # type: ignore -from google.api_core import exceptions as core_exceptions # type: ignore -from google.api_core import gapic_v1 # type: ignore -from google.api_core import retry as retries # type: ignore -from google.api_core import version import google.auth # type: ignore from google.auth import credentials as ga_credentials # type: ignore from google.longrunning import operations_pb2 @@ -30,6 +26,12 @@ from google.protobuf import empty_pb2, json_format # type: ignore from grpc import Compression +import google.api_core # type: ignore +from google.api_core import exceptions as core_exceptions # type: ignore +from google.api_core import gapic_v1 # type: ignore +from google.api_core import general_helpers +from google.api_core import retry as retries # type: ignore +from google.api_core import version PROTOBUF_VERSION = google.protobuf.__version__ @@ -69,9 +71,10 @@ def __init__( credentials identify the application to the service; if none are specified, the client will attempt to ascertain the credentials from the environment. - credentials_file (Optional[str]): A file with credentials that can + credentials_file (Optional[str]): Deprecated. A file with credentials that can be loaded with :func:`google.auth.load_credentials_from_file`. - This argument is mutually exclusive with credentials. + This argument is mutually exclusive with credentials. This argument will be + removed in the next major version of `google-api-core`. .. warning:: Important: If you accept a credential configuration (credential JSON/File/Stream) @@ -98,6 +101,9 @@ def __init__( "https", but for testing or local servers, "http" can be specified. """ + if credentials_file is not None: + warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) + maybe_url_match = re.match("^(?Phttp(?:s)?://)?(?P.*)$", host) if maybe_url_match is None: raise ValueError( diff --git a/google/api_core/operations_v1/transports/rest.py b/google/api_core/operations_v1/transports/rest.py index 0705c518a..62f34d69f 100644 --- a/google/api_core/operations_v1/transports/rest.py +++ b/google/api_core/operations_v1/transports/rest.py @@ -15,23 +15,26 @@ # from typing import Callable, Dict, Optional, Sequence, Tuple, Union +import warnings +from google.auth import credentials as ga_credentials # type: ignore +from google.auth.transport.requests import AuthorizedSession # type: ignore +from google.longrunning import operations_pb2 # type: ignore +import google.protobuf +from google.protobuf import empty_pb2 # type: ignore +from google.protobuf import json_format # type: ignore +import grpc from requests import __version__ as requests_version from google.api_core import exceptions as core_exceptions # type: ignore from google.api_core import gapic_v1 # type: ignore +from google.api_core import general_helpers from google.api_core import path_template # type: ignore from google.api_core import rest_helpers # type: ignore from google.api_core import retry as retries # type: ignore -from google.auth import credentials as ga_credentials # type: ignore -from google.auth.transport.requests import AuthorizedSession # type: ignore -from google.longrunning import operations_pb2 # type: ignore -from google.protobuf import empty_pb2 # type: ignore -from google.protobuf import json_format # type: ignore -import google.protobuf -import grpc -from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO, OperationsTransport +from .base import DEFAULT_CLIENT_INFO as BASE_DEFAULT_CLIENT_INFO +from .base import OperationsTransport PROTOBUF_VERSION = google.protobuf.__version__ @@ -91,9 +94,10 @@ def __init__( are specified, the client will attempt to ascertain the credentials from the environment. - credentials_file (Optional[str]): A file with credentials that can + credentials_file (Optional[str]): Deprecated. A file with credentials that can be loaded with :func:`google.auth.load_credentials_from_file`. - This argument is ignored if ``channel`` is provided. + This argument is ignored if ``channel`` is provided. This argument will be + removed in the next major version of `google-api-core`. .. warning:: Important: If you accept a credential configuration (credential JSON/File/Stream) @@ -101,9 +105,9 @@ def __init__( validate it before providing it to any Google API or client library. Providing an unvalidated credential configuration to Google APIs or libraries can compromise the security of your systems and data. For more information, refer to - `Validate credential configurations from external sources`_. + `Validate credential configuration from external sources`_. - .. _Validate credential configurations from external sources: + .. _Validate credential configuration from external sources: https://cloud.google.com/docs/authentication/external/externally-sourced-credentials scopes (Optional(Sequence[str])): A list of scopes. This argument is @@ -130,6 +134,9 @@ def __init__( "v1" by default. """ + if credentials_file is not None: + warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) + # Run the base constructor # TODO(yon-mg): resolve other ctor params i.e. scopes, quota, etc. # TODO: When custom host (api_endpoint) is set, `scopes` must *also* be set on the diff --git a/google/api_core/operations_v1/transports/rest_asyncio.py b/google/api_core/operations_v1/transports/rest_asyncio.py index 71c20eb8a..6fa9f56aa 100644 --- a/google/api_core/operations_v1/transports/rest_asyncio.py +++ b/google/api_core/operations_v1/transports/rest_asyncio.py @@ -16,6 +16,7 @@ import json from typing import Any, Callable, Coroutine, Dict, Optional, Sequence, Tuple +import warnings from google.auth import __version__ as auth_version @@ -29,6 +30,7 @@ from google.api_core import exceptions as core_exceptions # type: ignore from google.api_core import gapic_v1 # type: ignore +from google.api_core import general_helpers from google.api_core import path_template # type: ignore from google.api_core import rest_helpers # type: ignore from google.api_core import retry_async as retries_async # type: ignore @@ -96,6 +98,22 @@ def __init__( credentials identify the application to the service; if none are specified, the client will attempt to ascertain the credentials from the environment. + credentials_file (Optional[str]): Deprecated. A file with credentials that can + be loaded with :func:`google.auth.load_credentials_from_file`. + This argument is ignored if ``channel`` is provided. This argument will be + removed in the next major version of `google-api-core`. + + .. warning:: + Important: If you accept a credential configuration (credential JSON/File/Stream) + from an external source for authentication to Google Cloud Platform, you must + validate it before providing it to any Google API or client library. Providing an + unvalidated credential configuration to Google APIs or libraries can compromise + the security of your systems and data. For more information, refer to + `Validate credential configurations from external sources`_. + + .. _Validate credential configurations from external sources: + + https://cloud.google.com/docs/authentication/external/externally-sourced-credentials client_info (google.api_core.gapic_v1.client_info.ClientInfo): The client info used to send a user-agent string along with API requests. If ``None``, then default info will be used. @@ -113,6 +131,9 @@ def __init__( "v1" by default. """ + if credentials_file is not None: + warnings.warn(general_helpers._CREDENTIALS_FILE_WARNING, DeprecationWarning) + unsupported_params = { # TODO(https://github.com/googleapis/python-api-core/issues/715): Add support for `credentials_file` to async REST transport. "google.api_core.client_options.ClientOptions.credentials_file": credentials_file, diff --git a/tests/unit/operations_v1/test_operations_rest_client.py b/tests/unit/operations_v1/test_operations_rest_client.py index d1f6e0eba..4e8ef4073 100644 --- a/tests/unit/operations_v1/test_operations_rest_client.py +++ b/tests/unit/operations_v1/test_operations_rest_client.py @@ -368,6 +368,22 @@ def test_operations_client_client_options( always_use_jwt_access=True, ) + # Check the case credentials_file is provided + options = client_options.ClientOptions(credentials_file="credentials.json") + with mock.patch.object(transport_class, "__init__") as patched: + patched.return_value = None + client = client_class(client_options=options, transport=transport_name) + patched.assert_called_once_with( + credentials=None, + credentials_file="credentials.json", + host=client.DEFAULT_ENDPOINT, + scopes=None, + client_cert_source_for_mtls=None, + quota_project_id=None, + client_info=transports.base.DEFAULT_CLIENT_INFO, + always_use_jwt_access=True, + ) + # TODO: Add support for mtls in async REST @pytest.mark.parametrize( @@ -544,8 +560,23 @@ def test_operations_client_client_options_credentials_file( ) -def test_list_operations_rest(): - client = _get_operations_client(is_async=False) +@pytest.mark.parametrize( + "credentials_file", + [None, "credentials.json"], +) +@mock.patch( + "google.auth.default", + autospec=True, + return_value=(mock.sentinel.credentials, mock.sentinel.project), +) +def test_list_operations_rest(google_auth_default, credentials_file): + sync_transport = transports.rest.OperationsRestTransport( + credentials_file=credentials_file, + http_options=HTTP_OPTIONS, + ) + + client = AbstractOperationsClient(transport=sync_transport) + # Mock the http request call within the method and fake a response. with mock.patch.object(_get_session_type(is_async=False), "request") as req: # Designate an appropriate value for the returned response. From 4587c0bac93bc693bfb8b5b51b294f8177ee8217 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:56:41 -0700 Subject: [PATCH 03/27] chore(main): release 2.25.2 (#838) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ google/api_core/version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aab5d53fe..669ff00e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ [1]: https://pypi.org/project/google-api-core/#history +## [2.25.2](https://github.com/googleapis/python-api-core/compare/v2.25.1...v2.25.2) (2025-10-01) + + +### Bug Fixes + +* Deprecate credentials_file argument ([#841](https://github.com/googleapis/python-api-core/issues/841)) ([324eb74](https://github.com/googleapis/python-api-core/commit/324eb7464d6ade9a8c2e413d4695bc7d7adfcb3d)) +* Fix async tests and round-off error in test expectations ([#837](https://github.com/googleapis/python-api-core/issues/837)) ([14a5978](https://github.com/googleapis/python-api-core/commit/14a59789e144905bd6c82180ad07a52bf1f84f02)) + ## [2.25.1](https://github.com/googleapis/python-api-core/compare/v2.25.0...v2.25.1) (2025-06-02) diff --git a/google/api_core/version.py b/google/api_core/version.py index 21cbec9fe..e8672849f 100644 --- a/google/api_core/version.py +++ b/google/api_core/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.25.1" +__version__ = "2.25.2" From 43690de33a23321d52ab856e2bf253590e1a9357 Mon Sep 17 00:00:00 2001 From: Lingqing Gan Date: Wed, 8 Oct 2025 13:33:54 -0700 Subject: [PATCH 04/27] feat: add trove classifier for Python 3.14 (#842) * chore: add trove classifier for Python 3.14 * pin grpcio and grpcio-status versions * revert pinning grpcio for python 3.12 and 3.13 --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index da404ab3b..71ce72245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Topic :: Internet", ] @@ -62,8 +63,10 @@ async_rest = ["google-auth[aiohttp] >= 2.35.0, < 3.0.0"] grpc = [ "grpcio >= 1.33.2, < 2.0.0", "grpcio >= 1.49.1, < 2.0.0; python_version >= '3.11'", + "grpcio >= 1.75.1, < 2.0.0; python_version >= '3.14'", "grpcio-status >= 1.33.2, < 2.0.0", "grpcio-status >= 1.49.1, < 2.0.0; python_version >= '3.11'", + "grpcio-status >= 1.75.1, < 2.0.0; python_version >= '3.14'", ] grpcgcp = ["grpcio-gcp >= 0.2.2, < 1.0.0"] grpcio-gcp = ["grpcio-gcp >= 0.2.2, < 1.0.0"] From 8168988306b5fdd55be4de26d4bcd0c8f953c74f Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:28:33 -0700 Subject: [PATCH 05/27] chore(main): release 2.26.0 (#843) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ google/api_core/version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 669ff00e4..c86f6ab6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://pypi.org/project/google-api-core/#history +## [2.26.0](https://github.com/googleapis/python-api-core/compare/v2.25.2...v2.26.0) (2025-10-08) + + +### Features + +* Add trove classifier for Python 3.14 ([#842](https://github.com/googleapis/python-api-core/issues/842)) ([43690de](https://github.com/googleapis/python-api-core/commit/43690de33a23321d52ab856e2bf253590e1a9357)) + ## [2.25.2](https://github.com/googleapis/python-api-core/compare/v2.25.1...v2.25.2) (2025-10-01) diff --git a/google/api_core/version.py b/google/api_core/version.py index e8672849f..1f7d79ab9 100644 --- a/google/api_core/version.py +++ b/google/api_core/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.25.2" +__version__ = "2.26.0" From 95305480d234b6dd0903960db020e55125a997e0 Mon Sep 17 00:00:00 2001 From: Chandra Shekhar Sirimala Date: Tue, 14 Oct 2025 22:05:54 +0530 Subject: [PATCH 06/27] feat: support for async bidi streaming apis (#836) --- google/api_core/bidi.py | 102 ++++------- google/api_core/bidi_async.py | 244 +++++++++++++++++++++++++ google/api_core/bidi_base.py | 88 +++++++++ tests/asyncio/test_bidi_async.py | 305 +++++++++++++++++++++++++++++++ 4 files changed, 676 insertions(+), 63 deletions(-) create mode 100644 google/api_core/bidi_async.py create mode 100644 google/api_core/bidi_base.py create mode 100644 tests/asyncio/test_bidi_async.py diff --git a/google/api_core/bidi.py b/google/api_core/bidi.py index bed4c70e9..270ad0915 100644 --- a/google/api_core/bidi.py +++ b/google/api_core/bidi.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Bi-directional streaming RPC helpers.""" +"""Helpers for synchronous bidirectional streaming RPCs.""" import collections import datetime @@ -22,6 +22,7 @@ import time from google.api_core import exceptions +from google.api_core.bidi_base import BidiRpcBase _LOGGER = logging.getLogger(__name__) _BIDIRECTIONAL_CONSUMER_NAME = "Thread-ConsumeBidirectionalStream" @@ -36,21 +37,6 @@ class _RequestQueueGenerator(object): otherwise open-ended set of requests to send through a request-streaming (or bidirectional) RPC. - The reason this is necessary is because gRPC takes an iterator as the - request for request-streaming RPCs. gRPC consumes this iterator in another - thread to allow it to block while generating requests for the stream. - However, if the generator blocks indefinitely gRPC will not be able to - clean up the thread as it'll be blocked on `next(iterator)` and not be able - to check the channel status to stop iterating. This helper mitigates that - by waiting on the queue with a timeout and checking the RPC state before - yielding. - - Finally, it allows for retrying without swapping queues because if it does - pull an item off the queue when the RPC is inactive, it'll immediately put - it back and then exit. This is necessary because yielding the item in this - case will cause gRPC to discard it. In practice, this means that the order - of messages is not guaranteed. If such a thing is necessary it would be - easy to use a priority queue. Example:: @@ -62,12 +48,6 @@ class _RequestQueueGenerator(object): print(response) q.put(...) - Note that it is possible to accomplish this behavior without "spinning" - (using a queue timeout). One possible way would be to use more threads to - multiplex the grpc end event with the queue, another possible way is to - use selectors and a custom event/queue object. Both of these approaches - are significant from an engineering perspective for small benefit - the - CPU consumed by spinning is pretty minuscule. Args: queue (queue_module.Queue): The request queue. @@ -96,6 +76,31 @@ def _is_active(self): return self.call is None or self.call.is_active() def __iter__(self): + # The reason this is necessary is because gRPC takes an iterator as the + # request for request-streaming RPCs. gRPC consumes this iterator in + # another thread to allow it to block while generating requests for + # the stream. However, if the generator blocks indefinitely gRPC will + # not be able to clean up the thread as it'll be blocked on + # `next(iterator)` and not be able to check the channel status to stop + # iterating. This helper mitigates that by waiting on the queue with + # a timeout and checking the RPC state before yielding. + # + # Finally, it allows for retrying without swapping queues because if + # it does pull an item off the queue when the RPC is inactive, it'll + # immediately put it back and then exit. This is necessary because + # yielding the item in this case will cause gRPC to discard it. In + # practice, this means that the order of messages is not guaranteed. + # If such a thing is necessary it would be easy to use a priority + # queue. + # + # Note that it is possible to accomplish this behavior without + # "spinning" (using a queue timeout). One possible way would be to use + # more threads to multiplex the grpc end event with the queue, another + # possible way is to use selectors and a custom event/queue object. + # Both of these approaches are significant from an engineering + # perspective for small benefit - the CPU consumed by spinning is + # pretty minuscule. + if self._initial_request is not None: if callable(self._initial_request): yield self._initial_request() @@ -201,7 +206,7 @@ def __repr__(self): ) -class BidiRpc(object): +class BidiRpc(BidiRpcBase): """A helper for consuming a bi-directional streaming RPC. This maps gRPC's built-in interface which uses a request iterator and a @@ -227,6 +232,8 @@ class BidiRpc(object): rpc.send(example_pb2.StreamingRpcRequest( data='example')) + rpc.close() + This does *not* retry the stream on errors. See :class:`ResumableBidiRpc`. Args: @@ -240,40 +247,14 @@ class BidiRpc(object): the request. """ - def __init__(self, start_rpc, initial_request=None, metadata=None): - self._start_rpc = start_rpc - self._initial_request = initial_request - self._rpc_metadata = metadata - self._request_queue = queue_module.Queue() - self._request_generator = None - self._is_active = False - self._callbacks = [] - self.call = None - - def add_done_callback(self, callback): - """Adds a callback that will be called when the RPC terminates. - - This occurs when the RPC errors or is successfully terminated. - - Args: - callback (Callable[[grpc.Future], None]): The callback to execute. - It will be provided with the same gRPC future as the underlying - stream which will also be a :class:`grpc.Call`. - """ - self._callbacks.append(callback) - - def _on_call_done(self, future): - # This occurs when the RPC errors or is successfully terminated. - # Note that grpc's "future" here can also be a grpc.RpcError. - # See note in https://github.com/grpc/grpc/issues/10885#issuecomment-302651331 - # that `grpc.RpcError` is also `grpc.call`. - for callback in self._callbacks: - callback(future) + def _create_queue(self): + """Create a queue for requests.""" + return queue_module.Queue() def open(self): """Opens the stream.""" if self.is_active: - raise ValueError("Can not open an already open stream.") + raise ValueError("Cannot open an already open stream.") request_generator = _RequestQueueGenerator( self._request_queue, initial_request=self._initial_request @@ -322,7 +303,7 @@ def send(self, request): request (protobuf.Message): The request to send. """ if self.call is None: - raise ValueError("Can not send() on an RPC that has never been open()ed.") + raise ValueError("Cannot send on an RPC stream that has never been opened.") # Don't use self.is_active(), as ResumableBidiRpc will overload it # to mean something semantically different. @@ -343,20 +324,15 @@ def recv(self): protobuf.Message: The received message. """ if self.call is None: - raise ValueError("Can not recv() on an RPC that has never been open()ed.") + raise ValueError("Cannot recv on an RPC stream that has never been opened.") return next(self.call) @property def is_active(self): - """bool: True if this stream is currently open and active.""" + """True if this stream is currently open and active.""" return self.call is not None and self.call.is_active() - @property - def pending_requests(self): - """int: Returns an estimate of the number of queued requests.""" - return self._request_queue.qsize() - def _never_terminate(future_or_error): """By default, no errors cause BiDi termination.""" @@ -544,7 +520,7 @@ def _send(self, request): call = self.call if call is None: - raise ValueError("Can not send() on an RPC that has never been open()ed.") + raise ValueError("Cannot send on an RPC that has never been opened.") # Don't use self.is_active(), as ResumableBidiRpc will overload it # to mean something semantically different. @@ -563,7 +539,7 @@ def _recv(self): call = self.call if call is None: - raise ValueError("Can not recv() on an RPC that has never been open()ed.") + raise ValueError("Cannot recv on an RPC that has never been opened.") return next(call) diff --git a/google/api_core/bidi_async.py b/google/api_core/bidi_async.py new file mode 100644 index 000000000..d73b4c98d --- /dev/null +++ b/google/api_core/bidi_async.py @@ -0,0 +1,244 @@ +# 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 +# +# https://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. + +"""Asynchronous bi-directional streaming RPC helpers.""" + +import asyncio +import logging +from typing import Callable, Optional, Union + +from grpc import aio + +from google.api_core import exceptions +from google.api_core.bidi_base import BidiRpcBase + +from google.protobuf.message import Message as ProtobufMessage + + +_LOGGER = logging.getLogger(__name__) + + +class _AsyncRequestQueueGenerator: + """_AsyncRequestQueueGenerator is a helper class for sending asynchronous + requests to a gRPC stream from a Queue. + + This generator takes asynchronous requests off a given `asyncio.Queue` and + yields them to gRPC. + + It's useful when you have an indeterminate, indefinite, or otherwise + open-ended set of requests to send through a request-streaming (or + bidirectional) RPC. + + Example:: + + requests = _AsyncRequestQueueGenerator(q) + call = await stub.StreamingRequest(requests) + requests.call = call + + async for response in call: + print(response) + await q.put(...) + + Args: + queue (asyncio.Queue): The request queue. + initial_request (Union[ProtobufMessage, + Callable[[], ProtobufMessage]]): The initial request to + yield. This is done independently of the request queue to allow for + easily restarting streams that require some initial configuration + request. + """ + + def __init__( + self, + queue: asyncio.Queue, + initial_request: Optional[ + Union[ProtobufMessage, Callable[[], ProtobufMessage]] + ] = None, + ) -> None: + self._queue = queue + self._initial_request = initial_request + self.call: Optional[aio.Call] = None + + def _is_active(self) -> bool: + """Returns true if the call is not set or not completed.""" + # Note: there is a possibility that this starts *before* the call + # property is set. So we have to check if self.call is set before + # seeing if it's active. We need to return True if self.call is None. + # See https://github.com/googleapis/python-api-core/issues/560. + return self.call is None or not self.call.done() + + async def __aiter__(self): + # The reason this is necessary is because it lets the user have + # control on when they would want to send requests proto messages + # instead of sending all of them initially. + # + # This is achieved via asynchronous queue (asyncio.Queue), + # gRPC awaits until there's a message in the queue. + # + # Finally, it allows for retrying without swapping queues because if + # it does pull an item off the queue when the RPC is inactive, it'll + # immediately put it back and then exit. This is necessary because + # yielding the item in this case will cause gRPC to discard it. In + # practice, this means that the order of messages is not guaranteed. + # If preserving order is necessary it would be easy to use a priority + # queue. + if self._initial_request is not None: + if callable(self._initial_request): + yield self._initial_request() + else: + yield self._initial_request + + while True: + item = await self._queue.get() + + # The consumer explicitly sent "None", indicating that the request + # should end. + if item is None: + _LOGGER.debug("Cleanly exiting request generator.") + return + + if not self._is_active(): + # We have an item, but the call is closed. We should put the + # item back on the queue so that the next call can consume it. + await self._queue.put(item) + _LOGGER.debug( + "Inactive call, replacing item on queue and exiting " + "request generator." + ) + return + + yield item + + +class AsyncBidiRpc(BidiRpcBase): + """A helper for consuming a async bi-directional streaming RPC. + + This maps gRPC's built-in interface which uses a request iterator and a + response iterator into a socket-like :func:`send` and :func:`recv`. This + is a more useful pattern for long-running or asymmetric streams (streams + where there is not a direct correlation between the requests and + responses). + + Example:: + + initial_request = example_pb2.StreamingRpcRequest( + setting='example') + rpc = AsyncBidiRpc( + stub.StreamingRpc, + initial_request=initial_request, + metadata=[('name', 'value')] + ) + + await rpc.open() + + while rpc.is_active: + print(await rpc.recv()) + await rpc.send(example_pb2.StreamingRpcRequest( + data='example')) + + await rpc.close() + + This does *not* retry the stream on errors. + + Args: + start_rpc (grpc.aio.StreamStreamMultiCallable): The gRPC method used to + start the RPC. + initial_request (Union[ProtobufMessage, + Callable[[], ProtobufMessage]]): The initial request to + yield. This is useful if an initial request is needed to start the + stream. + metadata (Sequence[Tuple(str, str)]): RPC metadata to include in + the request. + """ + + def _create_queue(self) -> asyncio.Queue: + """Create a queue for requests.""" + return asyncio.Queue() + + async def open(self) -> None: + """Opens the stream.""" + if self.is_active: + raise ValueError("Cannot open an already open stream.") + + request_generator = _AsyncRequestQueueGenerator( + self._request_queue, initial_request=self._initial_request + ) + try: + call = await self._start_rpc(request_generator, metadata=self._rpc_metadata) + except exceptions.GoogleAPICallError as exc: + # The original `grpc.aio.AioRpcError` (which is usually also a + # `grpc.aio.Call`) is available from the ``response`` property on + # the mapped exception. + self._on_call_done(exc.response) + raise + + request_generator.call = call + + # TODO: api_core should expose the future interface for wrapped + # callables as well. + if hasattr(call, "_wrapped"): # pragma: NO COVER + call._wrapped.add_done_callback(self._on_call_done) + else: + call.add_done_callback(self._on_call_done) + + self._request_generator = request_generator + self.call = call + + async def close(self) -> None: + """Closes the stream.""" + if self.call is None: + return + + await self._request_queue.put(None) + self.call.cancel() + self._request_generator = None + self._initial_request = None + self._callbacks = [] + # Don't set self.call to None. Keep it around so that send/recv can + # raise the error. + + async def send(self, request: ProtobufMessage) -> None: + """Queue a message to be sent on the stream. + + If the underlying RPC has been closed, this will raise. + + Args: + request (ProtobufMessage): The request to send. + """ + if self.call is None: + raise ValueError("Cannot send on an RPC stream that has never been opened.") + + if not self.call.done(): + await self._request_queue.put(request) + else: + # calling read should cause the call to raise. + await self.call.read() + + async def recv(self) -> ProtobufMessage: + """Wait for a message to be returned from the stream. + + If the underlying RPC has been closed, this will raise. + + Returns: + ProtobufMessage: The received message. + """ + if self.call is None: + raise ValueError("Cannot recv on an RPC stream that has never been opened.") + + return await self.call.read() + + @property + def is_active(self) -> bool: + """Whether the stream is currently open and active.""" + return self.call is not None and not self.call.done() diff --git a/google/api_core/bidi_base.py b/google/api_core/bidi_base.py new file mode 100644 index 000000000..9288fda41 --- /dev/null +++ b/google/api_core/bidi_base.py @@ -0,0 +1,88 @@ +# Copyright 2025, Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# You may obtain a copy of the License at +# https://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. + +"""Base class for bi-directional streaming RPC helpers.""" + + +class BidiRpcBase: + """A base class for consuming a bi-directional streaming RPC. + + This maps gRPC's built-in interface which uses a request iterator and a + response iterator into a socket-like :func:`send` and :func:`recv`. This + is a more useful pattern for long-running or asymmetric streams (streams + where there is not a direct correlation between the requests and + responses). + + This does *not* retry the stream on errors. + + Args: + start_rpc (Union[grpc.StreamStreamMultiCallable, + grpc.aio.StreamStreamMultiCallable]): The gRPC method used + to start the RPC. + initial_request (Union[protobuf.Message, + Callable[[], protobuf.Message]]): The initial request to + yield. This is useful if an initial request is needed to start the + stream. + metadata (Sequence[Tuple(str, str)]): RPC metadata to include in + the request. + """ + + def __init__(self, start_rpc, initial_request=None, metadata=None): + self._start_rpc = start_rpc + self._initial_request = initial_request + self._rpc_metadata = metadata + self._request_queue = self._create_queue() + self._request_generator = None + self._callbacks = [] + self.call = None + + def _create_queue(self): + """Create a queue for requests.""" + raise NotImplementedError("`_create_queue` is not implemented.") + + def add_done_callback(self, callback): + """Adds a callback that will be called when the RPC terminates. + + This occurs when the RPC errors or is successfully terminated. + + Args: + callback (Union[Callable[[grpc.Future], None], Callable[[Any], None]]): + The callback to execute after gRPC call completed (success or + failure). + + For sync streaming gRPC: Callable[[grpc.Future], None] + + For async streaming gRPC: Callable[[Any], None] + """ + self._callbacks.append(callback) + + def _on_call_done(self, future): + # This occurs when the RPC errors or is successfully terminated. + # Note that grpc's "future" here can also be a grpc.RpcError. + # See note in https://github.com/grpc/grpc/issues/10885#issuecomment-302651331 + # that `grpc.RpcError` is also `grpc.Call`. + # for asynchronous gRPC call it would be `grpc.aio.AioRpcError` + + # Note: sync callbacks can be limiting for async code, because you can't + # await anything in a sync callback. + for callback in self._callbacks: + callback(future) + + @property + def is_active(self): + """True if the gRPC call is not done yet.""" + raise NotImplementedError("`is_active` is not implemented.") + + @property + def pending_requests(self): + """Estimate of the number of queued requests.""" + return self._request_queue.qsize() diff --git a/tests/asyncio/test_bidi_async.py b/tests/asyncio/test_bidi_async.py new file mode 100644 index 000000000..696113dbf --- /dev/null +++ b/tests/asyncio/test_bidi_async.py @@ -0,0 +1,305 @@ +# Copyright 2025, Google LLC All rights reserved. +# +# 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 asyncio + +from unittest import mock + +try: + from unittest.mock import AsyncMock +except ImportError: # pragma: NO COVER + from mock import AsyncMock # type: ignore + + +import pytest + +try: + from grpc import aio +except ImportError: # pragma: NO COVER + pytest.skip("No GRPC", allow_module_level=True) + +from google.api_core import bidi_async +from google.api_core import exceptions + +# TODO: remove this when droppping support for "Python 3.10" and below. +if sys.version_info < (3, 10): # type: ignore[operator] + + def aiter(obj): + return obj.__aiter__() + + async def anext(obj): + return await obj.__anext__() + + +@pytest.mark.asyncio +class Test_AsyncRequestQueueGenerator: + async def test_bounded_consume(self): + call = mock.create_autospec(aio.Call, instance=True) + call.done.return_value = False + + q = asyncio.Queue() + await q.put(mock.sentinel.A) + await q.put(mock.sentinel.B) + + generator = bidi_async._AsyncRequestQueueGenerator(q) + generator.call = call + + items = [] + gen_aiter = aiter(generator) + + items.append(await anext(gen_aiter)) + items.append(await anext(gen_aiter)) + + # At this point, the queue is empty. The next call to anext will sleep. + # We make the call inactive. + call.done.return_value = True + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(anext(gen_aiter), timeout=0.01) + + assert items == [mock.sentinel.A, mock.sentinel.B] + + async def test_yield_initial_and_exit(self): + q = asyncio.Queue() + call = mock.create_autospec(aio.Call, instance=True) + call.done.return_value = True + + generator = bidi_async._AsyncRequestQueueGenerator( + q, initial_request=mock.sentinel.A + ) + generator.call = call + + assert await anext(aiter(generator)) == mock.sentinel.A + + async def test_yield_initial_callable_and_exit(self): + q = asyncio.Queue() + call = mock.create_autospec(aio.Call, instance=True) + call.done.return_value = True + + generator = bidi_async._AsyncRequestQueueGenerator( + q, initial_request=lambda: mock.sentinel.A + ) + generator.call = call + + assert await anext(aiter(generator)) == mock.sentinel.A + + async def test_exit_when_inactive_with_item(self): + q = asyncio.Queue() + await q.put(mock.sentinel.A) + + call = mock.create_autospec(aio.Call, instance=True) + call.done.return_value = True + + generator = bidi_async._AsyncRequestQueueGenerator(q) + generator.call = call + + with pytest.raises( + StopAsyncIteration, + ): + assert await anext(aiter(generator)) + + # Make sure it put the item back. + assert not q.empty() + assert await q.get() == mock.sentinel.A + + async def test_exit_when_inactive_empty(self): + q = asyncio.Queue() + call = mock.create_autospec(aio.Call, instance=True) + call.done.return_value = True + + generator = bidi_async._AsyncRequestQueueGenerator(q) + generator.call = call + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(anext(aiter(generator)), timeout=0.01) + + async def test_exit_with_stop(self): + q = asyncio.Queue() + await q.put(None) + call = mock.create_autospec(aio.Call, instance=True) + call.done.return_value = False + + generator = bidi_async._AsyncRequestQueueGenerator(q) + generator.call = call + + with pytest.raises(StopAsyncIteration): + assert await anext(aiter(generator)) + + +def make_async_rpc(): + """Makes a mock async RPC used to test Bidi classes.""" + call = mock.create_autospec(aio.StreamStreamCall, instance=True) + rpc = AsyncMock() + + def rpc_side_effect(request, metadata=None): + call.done.return_value = False + return call + + rpc.side_effect = rpc_side_effect + + def cancel_side_effect(): + call.done.return_value = True + return True + + call.cancel.side_effect = cancel_side_effect + call.read = AsyncMock() + + return rpc, call + + +class AsyncClosedCall: + def __init__(self, exception): + self.exception = exception + + def done(self): + return True + + async def read(self): + raise self.exception + + +class TestAsyncBidiRpc: + def test_initial_state(self): + bidi_rpc = bidi_async.AsyncBidiRpc(None) + assert bidi_rpc.is_active is False + + def test_done_callbacks(self): + bidi_rpc = bidi_async.AsyncBidiRpc(None) + callback = mock.Mock(spec=["__call__"]) + + bidi_rpc.add_done_callback(callback) + bidi_rpc._on_call_done(mock.sentinel.future) + + callback.assert_called_once_with(mock.sentinel.future) + + @pytest.mark.asyncio + @pytest.mark.skipif( + sys.version_info < (3, 8), # type: ignore[operator] + reason="Versions of Python below 3.8 don't provide support for assert_awaited_once", + ) + async def test_metadata(self): + rpc, call = make_async_rpc() + bidi_rpc = bidi_async.AsyncBidiRpc(rpc, metadata=mock.sentinel.A) + assert bidi_rpc._rpc_metadata == mock.sentinel.A + + await bidi_rpc.open() + assert bidi_rpc.call == call + rpc.assert_awaited_once() + assert rpc.call_args.kwargs["metadata"] == mock.sentinel.A + + @pytest.mark.asyncio + async def test_open(self): + rpc, call = make_async_rpc() + bidi_rpc = bidi_async.AsyncBidiRpc(rpc) + + await bidi_rpc.open() + + assert bidi_rpc.call == call + assert bidi_rpc.is_active + call.add_done_callback.assert_called_once_with(bidi_rpc._on_call_done) + + @pytest.mark.asyncio + async def test_open_error_already_open(self): + rpc, _ = make_async_rpc() + bidi_rpc = bidi_async.AsyncBidiRpc(rpc) + + await bidi_rpc.open() + + with pytest.raises(ValueError): + await bidi_rpc.open() + + @pytest.mark.asyncio + async def test_open_error_call_error(self): + rpc, _ = make_async_rpc() + expected_exception = exceptions.GoogleAPICallError( + "test", response=mock.sentinel.response + ) + rpc.side_effect = expected_exception + bidi_rpc = bidi_async.AsyncBidiRpc(rpc) + callback = mock.Mock(spec=["__call__"]) + bidi_rpc.add_done_callback(callback) + + with pytest.raises(exceptions.GoogleAPICallError) as exc_info: + await bidi_rpc.open() + + assert exc_info.value == expected_exception + callback.assert_called_once_with(mock.sentinel.response) + + @pytest.mark.asyncio + async def test_close(self): + rpc, call = make_async_rpc() + bidi_rpc = bidi_async.AsyncBidiRpc(rpc) + await bidi_rpc.open() + + await bidi_rpc.close() + + call.cancel.assert_called_once() + assert bidi_rpc.call is call + assert bidi_rpc.is_active is False + # ensure the request queue was signaled to stop. + assert bidi_rpc.pending_requests == 1 + assert await bidi_rpc._request_queue.get() is None + # ensure request and callbacks are cleaned up + assert bidi_rpc._initial_request is None + assert not bidi_rpc._callbacks + + @pytest.mark.asyncio + async def test_close_no_rpc(self): + bidi_rpc = bidi_async.AsyncBidiRpc(None) + await bidi_rpc.close() + + @pytest.mark.asyncio + async def test_send(self): + rpc, call = make_async_rpc() + bidi_rpc = bidi_async.AsyncBidiRpc(rpc) + await bidi_rpc.open() + + await bidi_rpc.send(mock.sentinel.request) + + assert bidi_rpc.pending_requests == 1 + assert await bidi_rpc._request_queue.get() is mock.sentinel.request + + @pytest.mark.asyncio + async def test_send_not_open(self): + bidi_rpc = bidi_async.AsyncBidiRpc(None) + + with pytest.raises(ValueError): + await bidi_rpc.send(mock.sentinel.request) + + @pytest.mark.asyncio + async def test_send_dead_rpc(self): + error = ValueError() + bidi_rpc = bidi_async.AsyncBidiRpc(None) + bidi_rpc.call = AsyncClosedCall(error) + + with pytest.raises(ValueError): + await bidi_rpc.send(mock.sentinel.request) + + @pytest.mark.asyncio + async def test_recv(self): + bidi_rpc = bidi_async.AsyncBidiRpc(None) + bidi_rpc.call = mock.create_autospec(aio.Call, instance=True) + bidi_rpc.call.read = AsyncMock(return_value=mock.sentinel.response) + + response = await bidi_rpc.recv() + + assert response == mock.sentinel.response + + @pytest.mark.asyncio + async def test_recv_not_open(self): + bidi_rpc = bidi_async.AsyncBidiRpc(None) + + with pytest.raises(ValueError): + await bidi_rpc.recv() From 1df063f8891518b32ebeefb157ee7bf112d49230 Mon Sep 17 00:00:00 2001 From: "gcf-owl-bot[bot]" <78513119+gcf-owl-bot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:41:59 -0700 Subject: [PATCH 07/27] chore(python): Add Python 3.14 to python post processor image (#844) Source-Link: https://github.com/googleapis/synthtool/commit/16790a32126759493ba20781e04edd165825ff82 Post-Processor: gcr.io/cloud-devrel-public-resources/owlbot-python:latest@sha256:543e209e7c1c1ffe720eb4db1a3f045a75099304fb19aa11a47dc717b8aae2a9 Co-authored-by: Owl Bot Co-authored-by: Victor Chudnovsky --- .github/.OwlBot.lock.yaml | 4 +- .kokoro/samples/python3.14/common.cfg | 40 ++++++++++++++++++++ .kokoro/samples/python3.14/continuous.cfg | 6 +++ .kokoro/samples/python3.14/periodic-head.cfg | 11 ++++++ .kokoro/samples/python3.14/periodic.cfg | 6 +++ .kokoro/samples/python3.14/presubmit.cfg | 6 +++ 6 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 .kokoro/samples/python3.14/common.cfg create mode 100644 .kokoro/samples/python3.14/continuous.cfg create mode 100644 .kokoro/samples/python3.14/periodic-head.cfg create mode 100644 .kokoro/samples/python3.14/periodic.cfg create mode 100644 .kokoro/samples/python3.14/presubmit.cfg diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index 51b21a62b..4a311db02 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:a7aef70df5f13313ddc027409fc8f3151422ec2a57ac8730fce8fa75c060d5bb -# created: 2025-04-10T17:00:10.042601326Z + digest: sha256:543e209e7c1c1ffe720eb4db1a3f045a75099304fb19aa11a47dc717b8aae2a9 +# created: 2025-10-09T14:48:42.914384887Z diff --git a/.kokoro/samples/python3.14/common.cfg b/.kokoro/samples/python3.14/common.cfg new file mode 100644 index 000000000..a083385d7 --- /dev/null +++ b/.kokoro/samples/python3.14/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.14" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-314" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-api-core/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# Download trampoline resources. +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" + +# Use the trampoline script to run in docker. +build_file: "python-api-core/.kokoro/trampoline_v2.sh" diff --git a/.kokoro/samples/python3.14/continuous.cfg b/.kokoro/samples/python3.14/continuous.cfg new file mode 100644 index 000000000..a1c8d9759 --- /dev/null +++ b/.kokoro/samples/python3.14/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.14/periodic-head.cfg b/.kokoro/samples/python3.14/periodic-head.cfg new file mode 100644 index 000000000..a18c0cfc6 --- /dev/null +++ b/.kokoro/samples/python3.14/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-api-core/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.14/periodic.cfg b/.kokoro/samples/python3.14/periodic.cfg new file mode 100644 index 000000000..71cd1e597 --- /dev/null +++ b/.kokoro/samples/python3.14/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} diff --git a/.kokoro/samples/python3.14/presubmit.cfg b/.kokoro/samples/python3.14/presubmit.cfg new file mode 100644 index 000000000..a1c8d9759 --- /dev/null +++ b/.kokoro/samples/python3.14/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file From 6c16e96e3e1ec983571f5e35d1e11b2cf125a11b Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:38:21 -0700 Subject: [PATCH 08/27] chore(main): release 2.27.0 (#845) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ google/api_core/version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c86f6ab6a..69e124dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://pypi.org/project/google-api-core/#history +## [2.27.0](https://github.com/googleapis/python-api-core/compare/v2.26.0...v2.27.0) (2025-10-22) + + +### Features + +* Support for async bidi streaming apis ([#836](https://github.com/googleapis/python-api-core/issues/836)) ([9530548](https://github.com/googleapis/python-api-core/commit/95305480d234b6dd0903960db020e55125a997e0)) + ## [2.26.0](https://github.com/googleapis/python-api-core/compare/v2.25.2...v2.26.0) (2025-10-08) diff --git a/google/api_core/version.py b/google/api_core/version.py index 1f7d79ab9..4f038c462 100644 --- a/google/api_core/version.py +++ b/google/api_core/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.26.0" +__version__ = "2.27.0" From d36e896f98a2371c4d58ce1a7a3bc1a77a081836 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Fri, 24 Oct 2025 09:51:31 -0700 Subject: [PATCH 09/27] feat: provide and use Python version support check (#832) --- google/api_core/__init__.py | 18 ++ google/api_core/_python_package_support.py | 209 ++++++++++++++++ google/api_core/_python_version_support.py | 269 +++++++++++++++++++++ tests/unit/gapic/test_method.py | 4 +- tests/unit/test_bidi.py | 6 + tests/unit/test_python_package_support.py | 147 +++++++++++ tests/unit/test_python_version_support.py | 253 +++++++++++++++++++ tests/unit/test_timeout.py | 11 +- 8 files changed, 915 insertions(+), 2 deletions(-) create mode 100644 google/api_core/_python_package_support.py create mode 100644 google/api_core/_python_version_support.py create mode 100644 tests/unit/test_python_package_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 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): From 6206ce08e019fff0b8599d51c45b9abd2c7a754c Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:35:37 -0700 Subject: [PATCH 10/27] chore(main): release 2.28.0 (#847) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ google/api_core/version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e124dc6..08fdc19f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://pypi.org/project/google-api-core/#history +## [2.28.0](https://github.com/googleapis/python-api-core/compare/v2.27.0...v2.28.0) (2025-10-24) + + +### Features + +* Provide and use Python version support check ([#832](https://github.com/googleapis/python-api-core/issues/832)) ([d36e896](https://github.com/googleapis/python-api-core/commit/d36e896f98a2371c4d58ce1a7a3bc1a77a081836)) + ## [2.27.0](https://github.com/googleapis/python-api-core/compare/v2.26.0...v2.27.0) (2025-10-22) diff --git a/google/api_core/version.py b/google/api_core/version.py index 4f038c462..10e7fb4f5 100644 --- a/google/api_core/version.py +++ b/google/api_core/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.27.0" +__version__ = "2.28.0" From ca59a863b08a79c2bf0607f9085de1417422820b Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Tue, 28 Oct 2025 16:57:38 -0400 Subject: [PATCH 11/27] fix: remove dependency on packaging and pkg_resources (#852) * fix: remove dependency on packaging and pkg_resources * add test case * lint * update docstring * add constraint for importlib_metadata * address feedback * address feedback * remove comment * address feedback * address feedback --- google/api_core/_python_package_support.py | 65 +++++++++++++++------- noxfile.py | 1 + pyproject.toml | 3 + testing/constraints-3.7.txt | 1 + tests/unit/test_python_package_support.py | 63 ++++++++++----------- 5 files changed, 78 insertions(+), 55 deletions(-) diff --git a/google/api_core/_python_package_support.py b/google/api_core/_python_package_support.py index cc805b8d7..06da2bb00 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 collections import namedtuple @@ -25,7 +25,14 @@ _get_distribution_and_import_packages, ) -from packaging.version import parse as parse_version +if sys.version_info >= (3, 8): + from importlib import metadata +else: + # TODO(https://github.com/googleapis/python-api-core/issues/835): Remove + # this code path once we drop support for Python 3.7 + import importlib_metadata as metadata + +ParsedVersion = Tuple[int, ...] # Here we list all the packages for which we want to issue warnings # about deprecated and unsupported versions. @@ -48,42 +55,56 @@ UNKNOWN_VERSION_STRING = "--" +def parse_version_to_tuple(version_string: str) -> ParsedVersion: + """Safely converts a semantic version string to a comparable tuple of integers. + + Example: "4.25.8" -> (4, 25, 8) + Ignores non-numeric parts and handles common version formats. + + Args: + version_string: Version string in the format "x.y.z" or "x.y.z" + + Returns: + Tuple of integers for the parsed version string. + """ + parts = [] + for part in version_string.split("."): + try: + parts.append(int(part)) + except ValueError: + # If it's a non-numeric part (e.g., '1.0.0b1' -> 'b1'), stop here. + # This is a simplification compared to 'packaging.parse_version', but sufficient + # for comparing strictly numeric semantic versions. + break + return tuple(parts) + + 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 + as a comparable tuple of integers 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 + A DependencyVersion namedtuple with `version` (a tuple of integers) 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) - + version_string: str = metadata.version(dependency_name) + parsed_version = parse_version_to_tuple(version_string) + return DependencyVersion(parsed_version, version_string) except Exception: + # Catch exceptions from metadata.version() (e.g., PackageNotFoundError) + # or errors during parse_version_to_tuple return DependencyVersion(None, UNKNOWN_VERSION_STRING) @@ -132,10 +153,14 @@ def warn_deprecation_for_versions_less_than( 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): + + if dependency_version.version < parse_version_to_tuple( + minimum_fully_supported_version + ): ( dependency_package, dependency_distribution_package, diff --git a/noxfile.py b/noxfile.py index ac21330ef..04347a4f3 100644 --- a/noxfile.py +++ b/noxfile.py @@ -127,6 +127,7 @@ def default(session, install_grpc=True, prerelease=False, install_async_rest=Fal "mock; python_version=='3.7'", "pytest", "pytest-cov", + "pytest-mock", "pytest-xdist", ) diff --git a/pyproject.toml b/pyproject.toml index 71ce72245..0132afe05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,9 @@ dependencies = [ "proto-plus >= 1.25.0, < 2.0.0; python_version >= '3.13'", "google-auth >= 2.14.1, < 3.0.0", "requests >= 2.18.0, < 3.0.0", + # TODO(https://github.com/googleapis/python-api-core/issues/835): Remove + # `importlib_metadata` once we drop support for Python 3.7 + "importlib_metadata>=1.4; python_version<'3.8'", ] dynamic = ["version"] diff --git a/testing/constraints-3.7.txt b/testing/constraints-3.7.txt index 4ce1c8999..1a9b85d12 100644 --- a/testing/constraints-3.7.txt +++ b/testing/constraints-3.7.txt @@ -13,3 +13,4 @@ grpcio==1.33.2 grpcio-status==1.33.2 grpcio-gcp==0.2.2 proto-plus==1.22.3 +importlib_metadata==1.4 diff --git a/tests/unit/test_python_package_support.py b/tests/unit/test_python_package_support.py index 569903658..6a93e7154 100644 --- a/tests/unit/test_python_package_support.py +++ b/tests/unit/test_python_package_support.py @@ -14,12 +14,12 @@ import sys import warnings -from unittest.mock import patch, MagicMock +from unittest.mock import patch import pytest -from packaging.version import parse as parse_version from google.api_core._python_package_support import ( + parse_version_to_tuple, get_dependency_version, warn_deprecation_for_versions_less_than, check_dependency_versions, @@ -28,39 +28,28 @@ ) -# 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") +@pytest.mark.parametrize("version_string_to_test", ["1.2.3", "1.2.3b1"]) +def test_get_dependency_version(mocker, version_string_to_test): + """Test get_dependency_version.""" + if sys.version_info >= (3, 8): + mock_importlib = mocker.patch( + "importlib.metadata.version", return_value=version_string_to_test + ) + else: + # TODO(https://github.com/googleapis/python-api-core/issues/835): Remove + # `importlib_metadata` once we drop support for Python 3.7 + mock_importlib = mocker.patch( + "importlib_metadata.version", return_value=version_string_to_test + ) + expected = DependencyVersion( + parse_version_to_tuple(version_string_to_test), version_string_to_test + ) 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") + mock_importlib.assert_called_once_with("some-package") # Test package not found - mock_get_distribution.side_effect = ( - Exception # pkg_resources has its own exception types - ) + mock_importlib.side_effect = ImportError assert get_dependency_version("not-a-package") == DependencyVersion(None, "--") @@ -74,7 +63,9 @@ 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") + mock_get_version.return_value = DependencyVersion( + parse_version_to_tuple("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 @@ -90,14 +81,14 @@ 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 = DependencyVersion( - parse_version("2.0.0"), "2.0.0" + parse_version_to_tuple("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" + parse_version_to_tuple("3.0.0"), "3.0.0" ) warn_deprecation_for_versions_less_than("my.package", "dep.package", "2.0.0") @@ -115,7 +106,9 @@ 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 = DependencyVersion(parse_version("1.0.0"), "1.0.0") + mock_get_version.return_value = DependencyVersion( + parse_version_to_tuple("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( From b8b9a29e062fa0344f5f84c363b0e1b7cf7f1ffd Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:28:32 -0700 Subject: [PATCH 12/27] chore(main): release 2.28.1 (#854) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ google/api_core/version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08fdc19f1..7b2a256bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://pypi.org/project/google-api-core/#history +## [2.28.1](https://github.com/googleapis/python-api-core/compare/v2.28.0...v2.28.1) (2025-10-28) + + +### Bug Fixes + +* Remove dependency on packaging and pkg_resources ([#852](https://github.com/googleapis/python-api-core/issues/852)) ([ca59a86](https://github.com/googleapis/python-api-core/commit/ca59a863b08a79c2bf0607f9085de1417422820b)) + ## [2.28.0](https://github.com/googleapis/python-api-core/compare/v2.27.0...v2.28.0) (2025-10-24) diff --git a/google/api_core/version.py b/google/api_core/version.py index 10e7fb4f5..967959b05 100644 --- a/google/api_core/version.py +++ b/google/api_core/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.28.0" +__version__ = "2.28.1" From a4b291f962c4679b7f8fc62edede688806ab14e4 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Wed, 29 Oct 2025 14:23:53 -0700 Subject: [PATCH 13/27] chore(librarian): onboard to librarian (#856) * chore: onboard to librarian * chore: cleanup --- .github/.OwlBot.lock.yaml | 17 ----------------- .github/.OwlBot.yaml | 19 ------------------- .github/release-please.yml | 11 ----------- .github/release-trigger.yml | 2 -- .librarian/config.yaml | 6 ++++++ .librarian/state.yaml | 10 ++++++++++ 6 files changed, 16 insertions(+), 49 deletions(-) delete mode 100644 .github/.OwlBot.lock.yaml delete mode 100644 .github/.OwlBot.yaml delete mode 100644 .github/release-please.yml delete mode 100644 .github/release-trigger.yml create mode 100644 .librarian/config.yaml create mode 100644 .librarian/state.yaml diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml deleted file mode 100644 index 4a311db02..000000000 --- a/.github/.OwlBot.lock.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# 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. -docker: - image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:543e209e7c1c1ffe720eb4db1a3f045a75099304fb19aa11a47dc717b8aae2a9 -# created: 2025-10-09T14:48:42.914384887Z diff --git a/.github/.OwlBot.yaml b/.github/.OwlBot.yaml deleted file mode 100644 index c8b40cc7f..000000000 --- a/.github/.OwlBot.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright 2021 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. - -docker: - image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - -begin-after-commit-hash: 7af2cb8b2b725641ac0d07e2f256d453682802e6 - diff --git a/.github/release-please.yml b/.github/release-please.yml deleted file mode 100644 index 29601ad46..000000000 --- a/.github/release-please.yml +++ /dev/null @@ -1,11 +0,0 @@ -releaseType: python -handleGHRelease: true -# NOTE: this section is generated by synthtool.languages.python -# See https://github.com/googleapis/synthtool/blob/master/synthtool/languages/python.py -branches: -- branch: v1 - handleGHRelease: true - releaseType: python -- branch: v0 - handleGHRelease: true - releaseType: python diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml deleted file mode 100644 index 50e8bd300..000000000 --- a/.github/release-trigger.yml +++ /dev/null @@ -1,2 +0,0 @@ -enabled: true -multiScmName: python-api-core diff --git a/.librarian/config.yaml b/.librarian/config.yaml new file mode 100644 index 000000000..111f94dd5 --- /dev/null +++ b/.librarian/config.yaml @@ -0,0 +1,6 @@ +global_files_allowlist: + # Allow the container to read and write the root `CHANGELOG.md` + # file during the `release` step to update the latest client library + # versions which are hardcoded in the file. + - path: "CHANGELOG.md" + permissions: "read-write" diff --git a/.librarian/state.yaml b/.librarian/state.yaml new file mode 100644 index 000000000..6ac8b0a64 --- /dev/null +++ b/.librarian/state.yaml @@ -0,0 +1,10 @@ +image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator:latest +libraries: + - id: google-api-core + version: 2.28.1 + apis: [] + source_roots: + - . + preserve_regex: [] + remove_regex: [] + tag_format: v{version} From c97b3a004044ebf8b35c2a7ba97409d7795e11b0 Mon Sep 17 00:00:00 2001 From: pujawadare Date: Thu, 30 Oct 2025 23:48:29 +0530 Subject: [PATCH 14/27] fix: closes tailing streams in bidi classes. (#851) Always put `None` into the request queue when closing a bidi stream. This ensures that the request queue is always signaled as closed, even if the underlying gRPC call object is not yet available. --- google/api_core/bidi.py | 6 +++--- google/api_core/bidi_async.py | 6 +++--- tests/asyncio/test_bidi_async.py | 15 +++++++++++++++ tests/unit/test_bidi.py | 11 ++++++++++- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/google/api_core/bidi.py b/google/api_core/bidi.py index 270ad0915..7f45c2af1 100644 --- a/google/api_core/bidi.py +++ b/google/api_core/bidi.py @@ -281,11 +281,11 @@ def open(self): def close(self): """Closes the stream.""" - if self.call is None: - return + if self.call is not None: + self.call.cancel() + # Put None in request queue to signal termination. self._request_queue.put(None) - self.call.cancel() self._request_generator = None self._initial_request = None self._callbacks = [] diff --git a/google/api_core/bidi_async.py b/google/api_core/bidi_async.py index d73b4c98d..3770f69dd 100644 --- a/google/api_core/bidi_async.py +++ b/google/api_core/bidi_async.py @@ -197,11 +197,11 @@ async def open(self) -> None: async def close(self) -> None: """Closes the stream.""" - if self.call is None: - return + if self.call is not None: + self.call.cancel() + # Put None in request queue to signal termination. await self._request_queue.put(None) - self.call.cancel() self._request_generator = None self._initial_request = None self._callbacks = [] diff --git a/tests/asyncio/test_bidi_async.py b/tests/asyncio/test_bidi_async.py index 696113dbf..add685a96 100644 --- a/tests/asyncio/test_bidi_async.py +++ b/tests/asyncio/test_bidi_async.py @@ -255,6 +255,21 @@ async def test_close(self): assert bidi_rpc._initial_request is None assert not bidi_rpc._callbacks + @pytest.mark.asyncio + async def test_close_with_no_rpc(self): + bidi_rpc = bidi_async.AsyncBidiRpc(None) + + await bidi_rpc.close() + + assert bidi_rpc.call is None + assert bidi_rpc.is_active is False + # ensure the request queue was signaled to stop. + assert bidi_rpc.pending_requests == 1 + assert await bidi_rpc._request_queue.get() is None + # ensure request and callbacks are cleaned up + assert bidi_rpc._initial_request is None + assert not bidi_rpc._callbacks + @pytest.mark.asyncio async def test_close_no_rpc(self): bidi_rpc = bidi_async.AsyncBidiRpc(None) diff --git a/tests/unit/test_bidi.py b/tests/unit/test_bidi.py index b51db249a..4a8eb74fa 100644 --- a/tests/unit/test_bidi.py +++ b/tests/unit/test_bidi.py @@ -301,10 +301,19 @@ def test_close(self): assert bidi_rpc._initial_request is None assert not bidi_rpc._callbacks - def test_close_no_rpc(self): + def test_close_with_no_rpc(self): bidi_rpc = bidi.BidiRpc(None) bidi_rpc.close() + assert bidi_rpc.call is None + assert bidi_rpc.is_active is False + # ensure the request queue was signaled to stop. + assert bidi_rpc.pending_requests == 1 + assert bidi_rpc._request_queue.get() is None + # ensure request and callbacks are cleaned up + assert bidi_rpc._initial_request is None + assert not bidi_rpc._callbacks + def test_send(self): rpc, call = make_rpc() bidi_rpc = bidi.BidiRpc(rpc) From 6493118cae2720696c3d0097274edfd7fe2bce67 Mon Sep 17 00:00:00 2001 From: Reuben <60552974+ReubenFrankel@users.noreply.github.com> Date: Tue, 4 Nov 2025 21:05:06 +0000 Subject: [PATCH 15/27] fix: Log version check errors (#858) * Use error log over `print` to avoid stdout write * Move common return to end of function * Update google/api_core/_python_version_support.py Co-authored-by: Chalmer Lowe * Fix lint error --------- Co-authored-by: Chalmer Lowe --- google/api_core/_python_version_support.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/google/api_core/_python_version_support.py b/google/api_core/_python_version_support.py index 9fb92af6f..2c56364bb 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -16,12 +16,16 @@ import datetime import enum +import logging import warnings import sys import textwrap from typing import Any, List, NamedTuple, Optional, Dict, Tuple +_LOGGER = logging.getLogger(__name__) + + class PythonVersionStatus(enum.Enum): """Support status of a Python version in this client library artifact release. @@ -168,11 +172,14 @@ def _get_pypi_package_name(module_name): 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 + _LOGGER.info( + "An error occurred while determining PyPI package name for %s: %s", + module_name, + e, + ) + + return None def _get_distribution_and_import_packages(import_package: str) -> Tuple[str, Any]: From 628003e217d9a881d24f3316aecfd48c244a73f0 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 6 Nov 2025 13:30:26 -0500 Subject: [PATCH 16/27] fix: remove call to importlib.metadata.packages_distributions() for py38/py39 (#859) * fix: remove call to importlib.metadata.packages_distributions() for py38/py39 * cover * update comment --- 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 2c56364bb..d0c0dfe1b 100644 --- a/google/api_core/_python_version_support.py +++ b/google/api_core/_python_version_support.py @@ -151,9 +151,11 @@ def _flatten_message(text: str) -> str: 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): +# TODO(https://github.com/googleapis/python-api-core/issues/835): +# Remove once we no longer support Python 3.9. +# `importlib.metadata.packages_distributions()` is only supported in Python 3.10 and newer +# https://docs.python.org/3/library/importlib.metadata.html#importlib.metadata.packages_distributions +if sys.version_info < (3, 10): def _get_pypi_package_name(module_name): # pragma: NO COVER """Determine the PyPI package name for a given module name.""" @@ -172,7 +174,7 @@ def _get_pypi_package_name(module_name): 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] - except Exception as e: + except Exception as e: # pragma: NO COVER _LOGGER.info( "An error occurred while determining PyPI package name for %s: %s", module_name, From 4f68f9303f43ca33638dd98e17e0786e729edeea Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 13 Nov 2025 12:57:29 -0500 Subject: [PATCH 17/27] chore(librarian): clean up owlbot files and pin image sha (#861) Additional clean up following https://github.com/googleapis/python-api-core/pull/856 --------- Co-authored-by: ohmayr Co-authored-by: Victor Chudnovsky --- .github/auto-approve.yml | 3 -- .librarian/config.yaml | 6 --- .librarian/state.yaml | 2 +- owlbot.py | 40 ----------------- pyproject.toml | 2 + tests/asyncio/gapic/test_method_async.py | 2 +- tests/asyncio/test_grpc_helpers_async.py | 33 ++++++++------ tests/helpers.py | 11 +++++ .../test_operations_rest_client.py | 45 ++++++++++++------- tests/unit/test_client_options.py | 36 ++++++++------- tests/unit/test_grpc_helpers.py | 29 +++++++----- tests/unit/test_python_version_support.py | 20 +++++---- 12 files changed, 112 insertions(+), 117 deletions(-) delete mode 100644 .github/auto-approve.yml delete mode 100644 .librarian/config.yaml delete mode 100644 owlbot.py diff --git a/.github/auto-approve.yml b/.github/auto-approve.yml deleted file mode 100644 index 311ebbb85..000000000 --- a/.github/auto-approve.yml +++ /dev/null @@ -1,3 +0,0 @@ -# https://github.com/googleapis/repo-automation-bots/tree/main/packages/auto-approve -processes: - - "OwlBotTemplateChanges" diff --git a/.librarian/config.yaml b/.librarian/config.yaml deleted file mode 100644 index 111f94dd5..000000000 --- a/.librarian/config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -global_files_allowlist: - # Allow the container to read and write the root `CHANGELOG.md` - # file during the `release` step to update the latest client library - # versions which are hardcoded in the file. - - path: "CHANGELOG.md" - permissions: "read-write" diff --git a/.librarian/state.yaml b/.librarian/state.yaml index 6ac8b0a64..1a97264b7 100644 --- a/.librarian/state.yaml +++ b/.librarian/state.yaml @@ -1,4 +1,4 @@ -image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator:latest +image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620 libraries: - id: google-api-core version: 2.28.1 diff --git a/owlbot.py b/owlbot.py deleted file mode 100644 index 58bc75170..000000000 --- a/owlbot.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright 2020 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. - -"""This script is used to synthesize generated parts of this library.""" - -import synthtool as s -from synthtool import gcp -from synthtool.languages import python - -common = gcp.CommonTemplates() - -# ---------------------------------------------------------------------------- -# Add templated files -# ---------------------------------------------------------------------------- -excludes = [ - "noxfile.py", # pytype - "setup.cfg", # pytype - ".coveragerc", # layout - "CONTRIBUTING.rst", # no systests - ".github/workflows/unittest.yml", # exclude unittest gh action - ".github/workflows/lint.yml", # exclude lint gh action - "README.rst", -] -templated_files = common.py_library(microgenerator=True, cov_level=100) -s.move(templated_files, excludes=excludes) - -python.configure_previous_major_version_branches() - -s.shell.run(["nox", "-s", "blacken"], hide_output=False) diff --git a/pyproject.toml b/pyproject.toml index 0132afe05..31f82052a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,8 @@ ignore_missing_imports = true filterwarnings = [ # treat all warnings as errors "error", + # Prevent Python version warnings from interfering with tests + "ignore:.* Python version .*:FutureWarning", # Remove once https://github.com/pytest-dev/pytest-cov/issues/621 is fixed "ignore:.*The --rsyncdir command line argument and rsyncdirs config variable are deprecated:DeprecationWarning", # Remove once https://github.com/protocolbuffers/protobuf/issues/12186 is fixed diff --git a/tests/asyncio/gapic/test_method_async.py b/tests/asyncio/gapic/test_method_async.py index 3edf8b6d4..40dd168a0 100644 --- a/tests/asyncio/gapic/test_method_async.py +++ b/tests/asyncio/gapic/test_method_async.py @@ -260,7 +260,7 @@ async def test_wrap_method_with_overriding_timeout_as_a_number(): actual_timeout = method.call_args[1]["timeout"] metadata = method.call_args[1]["metadata"] assert metadata == mock.ANY - assert actual_timeout == pytest.approx(22, abs=0.01) + assert actual_timeout == pytest.approx(22, abs=0.05) @pytest.mark.asyncio diff --git a/tests/asyncio/test_grpc_helpers_async.py b/tests/asyncio/test_grpc_helpers_async.py index aa8d5d10b..43700f22b 100644 --- a/tests/asyncio/test_grpc_helpers_async.py +++ b/tests/asyncio/test_grpc_helpers_async.py @@ -17,6 +17,7 @@ from unittest.mock import AsyncMock # pragma: NO COVER # noqa: F401 except ImportError: # pragma: NO COVER import mock # type: ignore +from ..helpers import warn_deprecated_credentials_file import pytest # noqa: I202 try: @@ -522,11 +523,12 @@ def test_create_channel_explicit_with_duplicate_credentials(): target = "example:443" with pytest.raises(exceptions.DuplicateCredentialArgs) as excinfo: - grpc_helpers_async.create_channel( - target, - credentials_file="credentials.json", - credentials=mock.sentinel.credentials, - ) + with warn_deprecated_credentials_file(): + grpc_helpers_async.create_channel( + target, + credentials_file="credentials.json", + credentials=mock.sentinel.credentials, + ) assert "mutually exclusive" in str(excinfo.value) @@ -641,9 +643,10 @@ def test_create_channel_with_credentials_file( credentials_file = "/path/to/credentials/file.json" composite_creds = composite_creds_call.return_value - channel = grpc_helpers_async.create_channel( - target, credentials_file=credentials_file - ) + with warn_deprecated_credentials_file(): + channel = grpc_helpers_async.create_channel( + target, credentials_file=credentials_file + ) google.auth.load_credentials_from_file.assert_called_once_with( credentials_file, scopes=None, default_scopes=None @@ -670,9 +673,10 @@ def test_create_channel_with_credentials_file_and_scopes( credentials_file = "/path/to/credentials/file.json" composite_creds = composite_creds_call.return_value - channel = grpc_helpers_async.create_channel( - target, credentials_file=credentials_file, scopes=scopes - ) + with warn_deprecated_credentials_file(): + channel = grpc_helpers_async.create_channel( + target, credentials_file=credentials_file, scopes=scopes + ) google.auth.load_credentials_from_file.assert_called_once_with( credentials_file, scopes=scopes, default_scopes=None @@ -699,9 +703,10 @@ def test_create_channel_with_credentials_file_and_default_scopes( credentials_file = "/path/to/credentials/file.json" composite_creds = composite_creds_call.return_value - channel = grpc_helpers_async.create_channel( - target, credentials_file=credentials_file, default_scopes=default_scopes - ) + with warn_deprecated_credentials_file(): + channel = grpc_helpers_async.create_channel( + target, credentials_file=credentials_file, default_scopes=default_scopes + ) google.auth.load_credentials_from_file.assert_called_once_with( credentials_file, scopes=None, default_scopes=default_scopes diff --git a/tests/helpers.py b/tests/helpers.py index 3429d511e..4c7d5db3e 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -14,7 +14,9 @@ """Helpers for tests""" +import functools import logging +import pytest # noqa: I202 from typing import List import proto @@ -69,3 +71,12 @@ def parse_responses(response_message_cls, all_responses: List[proto.Message]) -> logging.info(f"Sending JSON stream: {json_responses}") ret_val = "[{}]".format(",".join(json_responses)) return bytes(ret_val, "utf-8") + + +warn_deprecated_credentials_file = functools.partial( + # This is used to test that the auth credentials file deprecation + # warning is emitted as expected. + pytest.warns, + DeprecationWarning, + match="argument is deprecated because of a potential security risk", +) diff --git a/tests/unit/operations_v1/test_operations_rest_client.py b/tests/unit/operations_v1/test_operations_rest_client.py index 4e8ef4073..87523c5dd 100644 --- a/tests/unit/operations_v1/test_operations_rest_client.py +++ b/tests/unit/operations_v1/test_operations_rest_client.py @@ -23,6 +23,7 @@ import pytest from typing import Any, List +from ...helpers import warn_deprecated_credentials_file try: import grpc # noqa: F401 @@ -369,7 +370,8 @@ def test_operations_client_client_options( ) # Check the case credentials_file is provided - options = client_options.ClientOptions(credentials_file="credentials.json") + with warn_deprecated_credentials_file(): + options = client_options.ClientOptions(credentials_file="credentials.json") with mock.patch.object(transport_class, "__init__") as patched: patched.return_value = None client = client_class(client_options=options, transport=transport_name) @@ -539,11 +541,14 @@ def test_operations_client_client_options_credentials_file( client_class, transport_class, transport_name ): # Check the case credentials file is provided. - options = client_options.ClientOptions(credentials_file="credentials.json") + with warn_deprecated_credentials_file(): + options = client_options.ClientOptions(credentials_file="credentials.json") if "async" in str(client_class): # TODO(): Add support for credentials file to async REST transport. with pytest.raises(core_exceptions.AsyncRestUnsupportedParameterError): - client_class(client_options=options, transport=transport_name) + with warn_deprecated_credentials_file(): + + client_class(client_options=options, transport=transport_name) else: with mock.patch.object(transport_class, "__init__") as patched: patched.return_value = None @@ -570,10 +575,18 @@ def test_operations_client_client_options_credentials_file( return_value=(mock.sentinel.credentials, mock.sentinel.project), ) def test_list_operations_rest(google_auth_default, credentials_file): - sync_transport = transports.rest.OperationsRestTransport( - credentials_file=credentials_file, - http_options=HTTP_OPTIONS, - ) + if credentials_file: + with warn_deprecated_credentials_file(): + sync_transport = transports.rest.OperationsRestTransport( + credentials_file=credentials_file, + http_options=HTTP_OPTIONS, + ) + else: + # no warning expected + sync_transport = transports.rest.OperationsRestTransport( + credentials_file=credentials_file, + http_options=HTTP_OPTIONS, + ) client = AbstractOperationsClient(transport=sync_transport) @@ -1130,10 +1143,11 @@ def test_transport_adc(client_class, transport_class, credentials): def test_operations_base_transport_error(): # Passing both a credentials object and credentials_file should raise an error with pytest.raises(core_exceptions.DuplicateCredentialArgs): - transports.OperationsTransport( - credentials=ga_credentials.AnonymousCredentials(), - credentials_file="credentials.json", - ) + with warn_deprecated_credentials_file(): + transports.OperationsTransport( + credentials=ga_credentials.AnonymousCredentials(), + credentials_file="credentials.json", + ) def test_operations_base_transport(): @@ -1171,10 +1185,11 @@ def test_operations_base_transport_with_credentials_file(): ) as Transport: Transport.return_value = None load_creds.return_value = (ga_credentials.AnonymousCredentials(), None) - transports.OperationsTransport( - credentials_file="credentials.json", - quota_project_id="octopus", - ) + with warn_deprecated_credentials_file(): + transports.OperationsTransport( + credentials_file="credentials.json", + quota_project_id="octopus", + ) load_creds.assert_called_once_with( "credentials.json", scopes=None, diff --git a/tests/unit/test_client_options.py b/tests/unit/test_client_options.py index 396d66271..58b0286cb 100644 --- a/tests/unit/test_client_options.py +++ b/tests/unit/test_client_options.py @@ -14,6 +14,7 @@ from re import match import pytest +from ..helpers import warn_deprecated_credentials_file from google.api_core import client_options @@ -27,19 +28,19 @@ def get_client_encrypted_cert(): def test_constructor(): - - options = client_options.ClientOptions( - api_endpoint="foo.googleapis.com", - client_cert_source=get_client_cert, - quota_project_id="quote-proj", - credentials_file="path/to/credentials.json", - scopes=[ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/cloud-platform.read-only", - ], - api_audience="foo2.googleapis.com", - universe_domain="googleapis.com", - ) + with warn_deprecated_credentials_file(): + options = client_options.ClientOptions( + api_endpoint="foo.googleapis.com", + client_cert_source=get_client_cert, + quota_project_id="quote-proj", + credentials_file="path/to/credentials.json", + scopes=[ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/cloud-platform.read-only", + ], + api_audience="foo2.googleapis.com", + universe_domain="googleapis.com", + ) assert options.api_endpoint == "foo.googleapis.com" assert options.client_cert_source() == (b"cert", b"key") @@ -102,10 +103,11 @@ def test_constructor_with_api_key(): def test_constructor_with_both_api_key_and_credentials_file(): with pytest.raises(ValueError): - client_options.ClientOptions( - api_key="api-key", - credentials_file="path/to/credentials.json", - ) + with warn_deprecated_credentials_file(): + client_options.ClientOptions( + api_key="api-key", + credentials_file="path/to/credentials.json", + ) def test_from_dict(): diff --git a/tests/unit/test_grpc_helpers.py b/tests/unit/test_grpc_helpers.py index 8de9d8c0b..ed4a92249 100644 --- a/tests/unit/test_grpc_helpers.py +++ b/tests/unit/test_grpc_helpers.py @@ -15,6 +15,7 @@ from unittest import mock import pytest +from ..helpers import warn_deprecated_credentials_file try: import grpc @@ -581,11 +582,12 @@ def test_create_channel_explicit_with_duplicate_credentials(): target = "example.com:443" with pytest.raises(exceptions.DuplicateCredentialArgs): - grpc_helpers.create_channel( - target, - credentials_file="credentials.json", - credentials=mock.sentinel.credentials, - ) + with warn_deprecated_credentials_file(): + grpc_helpers.create_channel( + target, + credentials_file="credentials.json", + credentials=mock.sentinel.credentials, + ) @mock.patch("grpc.compute_engine_channel_credentials") @@ -710,7 +712,8 @@ def test_create_channel_with_credentials_file( credentials_file = "/path/to/credentials/file.json" composite_creds = composite_creds_call.return_value - channel = grpc_helpers.create_channel(target, credentials_file=credentials_file) + with warn_deprecated_credentials_file(): + channel = grpc_helpers.create_channel(target, credentials_file=credentials_file) google.auth.load_credentials_from_file.assert_called_once_with( credentials_file, scopes=None, default_scopes=None @@ -742,9 +745,10 @@ def test_create_channel_with_credentials_file_and_scopes( credentials_file = "/path/to/credentials/file.json" composite_creds = composite_creds_call.return_value - channel = grpc_helpers.create_channel( - target, credentials_file=credentials_file, scopes=scopes - ) + with warn_deprecated_credentials_file(): + channel = grpc_helpers.create_channel( + target, credentials_file=credentials_file, scopes=scopes + ) google.auth.load_credentials_from_file.assert_called_once_with( credentials_file, scopes=scopes, default_scopes=None @@ -776,9 +780,10 @@ def test_create_channel_with_credentials_file_and_default_scopes( credentials_file = "/path/to/credentials/file.json" composite_creds = composite_creds_call.return_value - channel = grpc_helpers.create_channel( - target, credentials_file=credentials_file, default_scopes=default_scopes - ) + with warn_deprecated_credentials_file(): + channel = grpc_helpers.create_channel( + target, credentials_file=credentials_file, default_scopes=default_scopes + ) load_credentials_from_file.assert_called_once_with( credentials_file, scopes=None, default_scopes=default_scopes diff --git a/tests/unit/test_python_version_support.py b/tests/unit/test_python_version_support.py index c38160b78..76eb821e0 100644 --- a/tests/unit/test_python_version_support.py +++ b/tests/unit/test_python_version_support.py @@ -178,17 +178,20 @@ 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) - ) + with pytest.warns(FutureWarning, match="past its end of life"): + 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) + with pytest.warns(FutureWarning, match="past its end of life"): + 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) - ) + with pytest.warns(FutureWarning, match="non-supported Python version"): + result_after_boundary = check_python_version( + today=custom_gapic_end + datetime.timedelta(days=1) + ) assert ( result_after_boundary == PythonVersionStatus.PYTHON_VERSION_UNSUPPORTED ) @@ -217,7 +220,8 @@ def test_override_gapic_deprecation_only(): result_before_boundary == PythonVersionStatus.PYTHON_VERSION_SUPPORTED ) - result_at_boundary = check_python_version(today=custom_gapic_dep) + with pytest.warns(FutureWarning, match="Google will stop supporting"): + result_at_boundary = check_python_version(today=custom_gapic_dep) assert result_at_boundary == PythonVersionStatus.PYTHON_VERSION_DEPRECATED From c969186f2b66bde1df5e25bbedc5868e27d136f9 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Thu, 13 Nov 2025 11:07:18 -0800 Subject: [PATCH 18/27] feat: make parse_version_to_tuple public (#864) --- google/api_core/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/google/api_core/__init__.py b/google/api_core/__init__.py index 85811a157..a52ffe874 100644 --- a/google/api_core/__init__.py +++ b/google/api_core/__init__.py @@ -30,6 +30,7 @@ # expose dependency checks for external callers check_python_version = _python_version_support.check_python_version check_dependency_versions = _python_package_support.check_dependency_versions +parse_version_to_tuple = _python_package_support.parse_version_to_tuple warn_deprecation_for_versions_less_than = ( _python_package_support.warn_deprecation_for_versions_less_than ) From 93404080f853699b9217e4b76391a13525db4e3e Mon Sep 17 00:00:00 2001 From: Faizal Kassamali Date: Mon, 17 Nov 2025 11:27:27 -0800 Subject: [PATCH 19/27] fix: flaky tests due to imprecision in floating point calculation and performance test setup (#865) Fix flaky tests due to imprecision in floating point calculation and performance test setup --- tests/unit/gapic/test_method.py | 21 +++++++++++++++++++++ tests/unit/gapic/test_routing_header.py | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/unit/gapic/test_method.py b/tests/unit/gapic/test_method.py index 927902bf0..29e8fc217 100644 --- a/tests/unit/gapic/test_method.py +++ b/tests/unit/gapic/test_method.py @@ -192,6 +192,7 @@ def test_wrap_method_with_overriding_retry_timeout_compression(unused_sleep): ) +@pytest.mark.skip(reason="Known flaky due to floating point comparison. #866") def test_wrap_method_with_overriding_timeout_as_a_number(): method = mock.Mock(spec=["__call__"], return_value=42) default_retry = retry.Retry() @@ -200,6 +201,8 @@ def test_wrap_method_with_overriding_timeout_as_a_number(): method, default_retry, default_timeout ) + # Using "result = wrapped_method(timeout=22)" fails since wrapped_method + # does floating point calculations that results in 21.987.. instead of 22 result = wrapped_method(timeout=22) assert result == 42 @@ -210,6 +213,24 @@ def test_wrap_method_with_overriding_timeout_as_a_number(): assert actual_timeout == pytest.approx(22, abs=0.01) +def test_wrap_method_with_overriding_constant_timeout(): + method = mock.Mock(spec=["__call__"], return_value=42) + default_retry = retry.Retry() + default_timeout = timeout.ConstantTimeout(60) + wrapped_method = google.api_core.gapic_v1.method.wrap_method( + method, default_retry, default_timeout + ) + + result = wrapped_method(timeout=timeout.ConstantTimeout(22)) + + assert result == 42 + + actual_timeout = method.call_args[1]["timeout"] + metadata = method.call_args[1]["metadata"] + assert metadata == mock.ANY + assert actual_timeout == 22 + + def test_wrap_method_with_call(): method = mock.Mock() mock_call = mock.Mock() diff --git a/tests/unit/gapic/test_routing_header.py b/tests/unit/gapic/test_routing_header.py index 2c8c75469..f0ec82ec6 100644 --- a/tests/unit/gapic/test_routing_header.py +++ b/tests/unit/gapic/test_routing_header.py @@ -90,8 +90,8 @@ def test__urlencode_param(key, value, expected): def test__urlencode_param_caching_performance(): import time - key = "key" * 100 - value = "value" * 100 + key = "key" * 10000 + value = "value" * 10000 # time with empty cache start_time = time.perf_counter() routing_header._urlencode_param(key, value) From 54d1d364c1ba7426ae61d623f967314174758468 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 26 Nov 2025 11:47:40 -0500 Subject: [PATCH 20/27] tests: update default python runtime used in tests to 3.14 (#870) This change is needed as part of b/463296248 --- .github/workflows/lint.yml | 2 +- .github/workflows/mypy.yml | 2 +- .github/workflows/unittest.yml | 2 +- CONTRIBUTING.rst | 4 +++- google/api_core/grpc_helpers_async.py | 2 +- google/api_core/operation.py | 2 +- google/api_core/operations_v1/pagers.py | 2 +- google/api_core/operations_v1/pagers_async.py | 2 +- google/api_core/operations_v1/pagers_base.py | 2 +- noxfile.py | 6 +++--- tests/asyncio/test_rest_streaming_async.py | 2 -- tests/unit/operations_v1/test_operations_rest_client.py | 3 --- tests/unit/test_client_logging.py | 1 - tests/unit/test_client_options.py | 2 -- tests/unit/test_page_iterator.py | 1 - 15 files changed, 14 insertions(+), 21 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1051da0bd..3ed755f00 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.14" - name: Install nox run: | python -m pip install --upgrade setuptools pip wheel diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index e6a79291d..8363e7218 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -12,7 +12,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.14" - name: Install nox run: | python -m pip install --upgrade setuptools pip wheel diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index f260a6a55..f654277bb 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -66,7 +66,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.14" - name: Install coverage run: | python -m pip install --upgrade setuptools pip wheel diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 1a1f608b6..0ac24bc08 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -21,7 +21,7 @@ In order to add a feature: documentation. - The feature must work fully on the following CPython versions: - 3.7, 3.8, 3.9, 3.10, 3.11, 3.12 and 3.13 on both UNIX and Windows. + 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13 and 3.14 on both UNIX and Windows. - The feature must not add unnecessary dependencies (where "unnecessary" is of course subjective, but new dependencies should @@ -204,6 +204,7 @@ We support: - `Python 3.11`_ - `Python 3.12`_ - `Python 3.13`_ +- `Python 3.14`_ .. _Python 3.7: https://docs.python.org/3.7/ .. _Python 3.8: https://docs.python.org/3.8/ @@ -212,6 +213,7 @@ We support: .. _Python 3.11: https://docs.python.org/3.11/ .. _Python 3.12: https://docs.python.org/3.12/ .. _Python 3.13: https://docs.python.org/3.13/ +.. _Python 3.14: https://docs.python.org/3.14/ Supported versions can be found in our ``noxfile.py`` `config`_. diff --git a/google/api_core/grpc_helpers_async.py b/google/api_core/grpc_helpers_async.py index 312d4df80..9e1ad1105 100644 --- a/google/api_core/grpc_helpers_async.py +++ b/google/api_core/grpc_helpers_async.py @@ -220,7 +220,7 @@ def create_channel( default_host=None, compression=None, attempt_direct_path: Optional[bool] = False, - **kwargs + **kwargs, ): """Create an AsyncIO secure channel with credentials. diff --git a/google/api_core/operation.py b/google/api_core/operation.py index 4b9c9a58b..5206243a7 100644 --- a/google/api_core/operation.py +++ b/google/api_core/operation.py @@ -78,7 +78,7 @@ def __init__( result_type, metadata_type=None, polling=polling.DEFAULT_POLLING, - **kwargs + **kwargs, ): super(Operation, self).__init__(polling=polling, **kwargs) self._operation = operation diff --git a/google/api_core/operations_v1/pagers.py b/google/api_core/operations_v1/pagers.py index 132f1c664..76efd5946 100644 --- a/google/api_core/operations_v1/pagers.py +++ b/google/api_core/operations_v1/pagers.py @@ -48,7 +48,7 @@ def __init__( request: operations_pb2.ListOperationsRequest, response: operations_pb2.ListOperationsResponse, *, - metadata: Sequence[Tuple[str, str]] = () + metadata: Sequence[Tuple[str, str]] = (), ): super().__init__( method=method, request=request, response=response, metadata=metadata diff --git a/google/api_core/operations_v1/pagers_async.py b/google/api_core/operations_v1/pagers_async.py index e2909dd50..4bb7f8c7d 100644 --- a/google/api_core/operations_v1/pagers_async.py +++ b/google/api_core/operations_v1/pagers_async.py @@ -48,7 +48,7 @@ def __init__( request: operations_pb2.ListOperationsRequest, response: operations_pb2.ListOperationsResponse, *, - metadata: Sequence[Tuple[str, str]] = () + metadata: Sequence[Tuple[str, str]] = (), ): super().__init__( method=method, request=request, response=response, metadata=metadata diff --git a/google/api_core/operations_v1/pagers_base.py b/google/api_core/operations_v1/pagers_base.py index 24caf74f2..5ef8384ef 100644 --- a/google/api_core/operations_v1/pagers_base.py +++ b/google/api_core/operations_v1/pagers_base.py @@ -47,7 +47,7 @@ def __init__( request: operations_pb2.ListOperationsRequest, response: operations_pb2.ListOperationsResponse, *, - metadata: Sequence[Tuple[str, str]] = () + metadata: Sequence[Tuple[str, str]] = (), ): """Instantiate the pager. diff --git a/noxfile.py b/noxfile.py index 04347a4f3..37ef2caf9 100644 --- a/noxfile.py +++ b/noxfile.py @@ -23,14 +23,14 @@ import nox # pytype: disable=import-error -BLACK_VERSION = "black==22.3.0" +BLACK_VERSION = "black==23.7.0" BLACK_PATHS = ["docs", "google", "tests", "noxfile.py", "setup.py"] # Black and flake8 clash on the syntax for ignoring flake8's F401 in this file. BLACK_EXCLUDES = ["--exclude", "^/google/api_core/operations_v1/__init__.py"] PYTHON_VERSIONS = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] -DEFAULT_PYTHON_VERSION = "3.10" +DEFAULT_PYTHON_VERSION = "3.14" CURRENT_DIRECTORY = pathlib.Path(__file__).parent.absolute() # 'docfx' is excluded since it only needs to run in 'docs-presubmit' @@ -261,7 +261,7 @@ def unit_w_async_rest_extra(session): def lint_setup_py(session): """Verify that setup.py is valid (including RST check).""" - session.install("docutils", "Pygments") + session.install("docutils", "Pygments", "setuptools") session.run("python", "setup.py", "check", "--restructuredtext", "--strict") diff --git a/tests/asyncio/test_rest_streaming_async.py b/tests/asyncio/test_rest_streaming_async.py index c9caa2b15..13549c7f9 100644 --- a/tests/asyncio/test_rest_streaming_async.py +++ b/tests/asyncio/test_rest_streaming_async.py @@ -292,7 +292,6 @@ async def test_next_escaped_characters_in_string( @pytest.mark.asyncio @pytest.mark.parametrize("response_type", [EchoResponse, httpbody_pb2.HttpBody]) async def test_next_not_array(response_type): - data = '{"hello": 0}' with mock.patch.object( ResponseMock, "content", return_value=mock_async_gen(data) @@ -352,7 +351,6 @@ async def test_check_buffer(response_type, return_value): @pytest.mark.asyncio @pytest.mark.parametrize("response_type", [EchoResponse, httpbody_pb2.HttpBody]) async def test_next_html(response_type): - data = "" with mock.patch.object( ResponseMock, "content", return_value=mock_async_gen(data) diff --git a/tests/unit/operations_v1/test_operations_rest_client.py b/tests/unit/operations_v1/test_operations_rest_client.py index 87523c5dd..2b96d4edc 100644 --- a/tests/unit/operations_v1/test_operations_rest_client.py +++ b/tests/unit/operations_v1/test_operations_rest_client.py @@ -226,7 +226,6 @@ def test_operations_client_service_account_always_use_jwt(transport_class): PYPARAM_CLIENT, ) def test_operations_client_from_service_account_file(client_class): - if "async" in str(client_class): # TODO(): Add support for service account creds to async REST transport. with pytest.raises(NotImplementedError): @@ -547,7 +546,6 @@ def test_operations_client_client_options_credentials_file( # TODO(): Add support for credentials file to async REST transport. with pytest.raises(core_exceptions.AsyncRestUnsupportedParameterError): with warn_deprecated_credentials_file(): - client_class(client_options=options, transport=transport_name) else: with mock.patch.object(transport_class, "__init__") as patched: @@ -1089,7 +1087,6 @@ async def test_cancel_operation_rest_failure_async(): PYPARAM_CLIENT_TRANSPORT_CREDENTIALS, ) def test_credentials_transport_error(client_class, transport_class, credentials): - # It is an error to provide credentials and a transport instance. transport = transport_class(credentials=credentials) with pytest.raises(ValueError): diff --git a/tests/unit/test_client_logging.py b/tests/unit/test_client_logging.py index b3b0b5c8e..c73b269fe 100644 --- a/tests/unit/test_client_logging.py +++ b/tests/unit/test_client_logging.py @@ -88,7 +88,6 @@ def test_setup_logging_w_incorrect_scope(): def test_initialize_logging(): - with mock.patch("os.getenv", return_value="foogle.bar"): with mock.patch("google.api_core.client_logging._BASE_LOGGER_NAME", "foogle"): initialize_logging() diff --git a/tests/unit/test_client_options.py b/tests/unit/test_client_options.py index 58b0286cb..54558eea0 100644 --- a/tests/unit/test_client_options.py +++ b/tests/unit/test_client_options.py @@ -55,7 +55,6 @@ def test_constructor(): def test_constructor_with_encrypted_cert_source(): - options = client_options.ClientOptions( api_endpoint="foo.googleapis.com", client_encrypted_cert_source=get_client_encrypted_cert, @@ -79,7 +78,6 @@ def test_constructor_with_both_cert_sources(): def test_constructor_with_api_key(): - options = client_options.ClientOptions( api_endpoint="foo.googleapis.com", client_cert_source=get_client_cert, diff --git a/tests/unit/test_page_iterator.py b/tests/unit/test_page_iterator.py index 560722c5d..ba0fbba4f 100644 --- a/tests/unit/test_page_iterator.py +++ b/tests/unit/test_page_iterator.py @@ -360,7 +360,6 @@ def test__has_next_page_w_max_results_not_done(self): assert iterator._has_next_page() def test__has_next_page_w_max_results_done(self): - iterator = page_iterator.HTTPIterator( mock.sentinel.client, mock.sentinel.api_request, From 2196e2affabe670dd2bde448d7641084fad3d83c Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 26 Nov 2025 13:41:04 -0500 Subject: [PATCH 21/27] chore: remove sync-repo-settings.yaml which is not used (#872) As per[ this README](https://github.com/googleapis/repo-automation-bots/blob/main/packages/sync-repo-settings/README.md), the `sync-repo-settings` bot is deprecated. The bot has already been disabled for this repo and this configuration is now obsolete. --- .github/sync-repo-settings.yaml | 60 --------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 .github/sync-repo-settings.yaml diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml deleted file mode 100644 index b724bada2..000000000 --- a/.github/sync-repo-settings.yaml +++ /dev/null @@ -1,60 +0,0 @@ -# https://github.com/googleapis/repo-automation-bots/tree/main/packages/sync-repo-settings -# Rules for main branch protection -branchProtectionRules: -# Identifies the protection rule pattern. Name of the branch to be protected. -# Defaults to `main` -- pattern: main - requiresCodeOwnerReviews: true - requiresStrictStatusChecks: true - requiredStatusCheckContexts: - - 'cla/google' - # No Kokoro: the following are Github actions - - 'lint' - - 'mypy' - - 'unit_grpc_gcp-3.7' - - 'unit_grpc_gcp-3.8' - - 'unit_grpc_gcp-3.9' - - 'unit_grpc_gcp-3.10' - - 'unit_grpc_gcp-3.11' - - 'unit_grpc_gcp-3.12' - - 'unit_grpc_gcp-3.13' - - 'unit_grpc_gcp-3.14' - - 'unit-3.7' - - 'unit-3.8' - - 'unit-3.9' - - 'unit-3.10' - - 'unit-3.11' - - 'unit-3.12' - - 'unit-3.13' - - 'unit-3.14' - - 'unit_wo_grpc-3.10' - - 'unit_wo_grpc-3.11' - - 'unit_wo_grpc-3.12' - - 'unit_wo_grpc-3.13' - - 'unit_wo_grpc-3.14' - - 'unit_w_prerelease_deps-3.7' - - 'unit_w_prerelease_deps-3.8' - - 'unit_w_prerelease_deps-3.9' - - 'unit_w_prerelease_deps-3.10' - - 'unit_w_prerelease_deps-3.11' - - 'unit_w_prerelease_deps-3.12' - - 'unit_w_prerelease_deps-3.13' - - 'unit_w_prerelease_deps-3.14' - - 'unit_w_async_rest_extra-3.7' - - 'unit_w_async_rest_extra-3.8' - - 'unit_w_async_rest_extra-3.9' - - 'unit_w_async_rest_extra-3.10' - - 'unit_w_async_rest_extra-3.11' - - 'unit_w_async_rest_extra-3.12' - - 'unit_w_async_rest_extra-3.13' - - 'unit_w_async_rest_extra-3.14' - - 'cover' - - 'docs' - - 'docfx' -permissionRules: - - team: actools-python - permission: admin - - team: actools - permission: admin - - team: yoshi-python - permission: push From d211307fd5a418489837270ee4a0cdbf4ef3fe57 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 26 Nov 2025 14:00:41 -0500 Subject: [PATCH 22/27] tests: refactor unit test nox sessions (#873) This change is needed as part of b/463296248. Instead of having 4 different presubmits that run 4 different nox unit tests, we now have all 4 patterns tested under a single nox session. This follows the pattern that we have in google-cloud-python where there is a single unit test nox session. --- .github/workflows/unittest.yml | 44 +++++++++++++++-------- noxfile.py | 65 ++++++++++++++++------------------ 2 files changed, 60 insertions(+), 49 deletions(-) diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index f654277bb..83d808558 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -6,7 +6,31 @@ on: - main jobs: - run-unittests: + unit-prerelease: + name: prerelease_deps + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.14"] + option: ["prerelease"] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + allow-prereleases: true + - name: Install nox + run: | + python -m pip install --upgrade setuptools pip wheel + python -m pip install nox + - name: Run ${{ matrix.option }} tests + env: + COVERAGE_FILE: .coverage${{ matrix.option }}-${{matrix.python }} + run: | + nox -s prerelease_deps + unit: name: unit${{ matrix.option }}-${{ matrix.python }} # TODO(https://github.com/googleapis/gapic-generator-python/issues/2303): use `ubuntu-latest` once this bug is fixed. # Use ubuntu-22.04 until Python 3.7 is removed from the test matrix @@ -14,7 +38,6 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - option: ["", "_grpc_gcp", "_wo_grpc", "_w_prerelease_deps", "_w_async_rest_extra"] python: - "3.7" - "3.8" @@ -24,13 +47,6 @@ jobs: - "3.12" - "3.13" - "3.14" - exclude: - - option: "_wo_grpc" - python: 3.7 - - option: "_wo_grpc" - python: 3.8 - - option: "_wo_grpc" - python: 3.9 steps: - name: Checkout uses: actions/checkout@v4 @@ -45,21 +61,21 @@ jobs: python -m pip install nox - name: Run unit tests env: - COVERAGE_FILE: .coverage${{ matrix.option }}-${{matrix.python }} + COVERAGE_FILE: .coverage-${{matrix.python }} run: | - nox -s unit${{ matrix.option }}-${{ matrix.python }} + nox -s unit-${{ matrix.python }} - name: Upload coverage results uses: actions/upload-artifact@v4 with: - name: coverage-artifact-${{ matrix.option }}-${{ matrix.python }} - path: .coverage${{ matrix.option }}-${{ matrix.python }} + name: coverage-artifact-${{ matrix.python }} + path: .coverage-${{ matrix.python }} include-hidden-files: true report-coverage: name: cover runs-on: ubuntu-latest needs: - - run-unittests + - unit steps: - name: Checkout uses: actions/checkout@v4 diff --git a/noxfile.py b/noxfile.py index 37ef2caf9..9f53dbde0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -215,46 +215,41 @@ def default(session, install_grpc=True, prerelease=False, install_async_rest=Fal @nox.session(python=PYTHON_VERSIONS) -def unit(session): +@nox.parametrize( + ["install_grpc_gcp", "install_grpc", "install_async_rest"], + [ + (False, True, False), # Run unit tests with grpcio installed + (True, True, False), # Run unit tests with grpcio/grpcio-gcp installed + (False, False, False), # Run unit tests without grpcio installed + (False, True, True), # Run unit tests with grpcio and async rest installed + ], +) +def unit(session, install_grpc_gcp, install_grpc, install_async_rest): """Run the unit test suite.""" - default(session) - -@nox.session(python=PYTHON_VERSIONS) -def unit_w_prerelease_deps(session): - """Run the unit test suite.""" - default(session, prerelease=True) - - -@nox.session(python=PYTHON_VERSIONS) -def unit_grpc_gcp(session): - """ - Run the unit test suite with grpcio-gcp installed. - `grpcio-gcp` doesn't support protobuf 4+. - Remove extra `grpcgcp` when protobuf 3.x is dropped. - https://github.com/googleapis/python-api-core/issues/594 - """ - constraints_path = str( - CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" + # `grpcio-gcp` doesn't support protobuf 4+. + # Remove extra `grpcgcp` when protobuf 3.x is dropped. + # https://github.com/googleapis/python-api-core/issues/594 + if install_grpc_gcp: + constraints_path = str( + CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" + ) + # Install grpcio-gcp + session.install("-e", ".[grpcgcp]", "-c", constraints_path) + # Install protobuf < 4.0.0 + session.install("protobuf<4.0.0") + + default( + session=session, + install_grpc=install_grpc, + install_async_rest=install_async_rest, ) - # Install grpcio-gcp - session.install("-e", ".[grpcgcp]", "-c", constraints_path) - # Install protobuf < 4.0.0 - session.install("protobuf<4.0.0") - - default(session) - - -@nox.session(python=PYTHON_VERSIONS) -def unit_wo_grpc(session): - """Run the unit test suite w/o grpcio installed""" - default(session, install_grpc=False) -@nox.session(python=PYTHON_VERSIONS) -def unit_w_async_rest_extra(session): - """Run the unit test suite with the `async_rest` extra""" - default(session, install_async_rest=True) +@nox.session(python=DEFAULT_PYTHON_VERSION) +def prerelease_deps(session): + """Run the unit test suite.""" + default(session, prerelease=True) @nox.session(python=DEFAULT_PYTHON_VERSION) From f0188c6f9c4dc2036f50fa5d8a8b9433749872d2 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 26 Nov 2025 14:00:51 -0500 Subject: [PATCH 23/27] chore: update github action workflow permissions (#875) This PR addresses the feedback from Github in https://github.com/googleapis/python-api-core/pull/873 --- .github/workflows/docs.yml | 4 ++++ .github/workflows/lint.yml | 4 ++++ .github/workflows/mypy.yml | 4 ++++ .github/workflows/unittest.yml | 3 +++ 4 files changed, 15 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2833fe98f..7ef5a2874 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,6 +2,10 @@ on: pull_request: branches: - main + +permissions: + contents: read + name: docs jobs: docs: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3ed755f00..d4929b60f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,10 @@ on: pull_request: branches: - main + +permissions: + contents: read + name: lint jobs: lint: diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 8363e7218..21621fe22 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -2,6 +2,10 @@ on: pull_request: branches: - main + +permissions: + contents: read + name: mypy jobs: mypy: diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 83d808558..f0f80d482 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -5,6 +5,9 @@ on: branches: - main +permissions: + contents: read + jobs: unit-prerelease: name: prerelease_deps From f8bf6f9610f3e0e7580f223794c3906513e1fa73 Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Sat, 6 Dec 2025 03:09:33 +0530 Subject: [PATCH 24/27] feat: Auto enable mTLS when supported certificates are detected (#869) The Python SDK will use a hybrid approach for mTLS enablement: If the GOOGLE_API_USE_CLIENT_CERTIFICATE environment variable is set (either true or false or any value), the SDK will respect that setting. This is necessary for test scenarios and users who need to explicitly control mTLS behavior. If the GOOGLE_API_USE_CLIENT_CERTIFICATE environment variable is not set, the SDK will automatically enable mTLS only if it detects Managed Workload Identity (MWID) or X.509 Workforce Identity Federation (WIF) certificate sources. In other cases where the variable is not set, mTLS will remain disabled. --------- Signed-off-by: Radhika Agrawal --- .../abstract_operations_base_client.py | 22 ++++++++++------ .../test_operations_rest_client.py | 26 ++++++++++++++++--- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/google/api_core/operations_v1/abstract_operations_base_client.py b/google/api_core/operations_v1/abstract_operations_base_client.py index 160c2a88f..f62f60b04 100644 --- a/google/api_core/operations_v1/abstract_operations_base_client.py +++ b/google/api_core/operations_v1/abstract_operations_base_client.py @@ -300,16 +300,22 @@ def __init__( client_options = client_options_lib.ClientOptions() # Create SSL credentials for mutual TLS if needed. - use_client_cert = os.getenv( - "GOOGLE_API_USE_CLIENT_CERTIFICATE", "false" - ).lower() - if use_client_cert not in ("true", "false"): - raise ValueError( - "Environment variable `GOOGLE_API_USE_CLIENT_CERTIFICATE` must be either `true` or `false`" - ) + if hasattr(mtls, "should_use_client_cert"): + use_client_cert = mtls.should_use_client_cert() + else: + # if unsupported, fallback to reading from env var + use_client_cert_str = os.getenv( + "GOOGLE_API_USE_CLIENT_CERTIFICATE", "false" + ).lower() + if use_client_cert_str not in ("true", "false"): + raise ValueError( + "Environment variable `GOOGLE_API_USE_CLIENT_CERTIFICATE` must be" + " either `true` or `false`" + ) + use_client_cert = use_client_cert_str == "true" client_cert_source_func = None is_mtls = False - if use_client_cert == "true": + if use_client_cert: if client_options.client_cert_source: is_mtls = True client_cert_source_func = client_options.client_cert_source diff --git a/tests/unit/operations_v1/test_operations_rest_client.py b/tests/unit/operations_v1/test_operations_rest_client.py index 2b96d4edc..a3189cf5f 100644 --- a/tests/unit/operations_v1/test_operations_rest_client.py +++ b/tests/unit/operations_v1/test_operations_rest_client.py @@ -35,6 +35,7 @@ from google.api_core import client_options from google.api_core import exceptions as core_exceptions from google.api_core import gapic_v1 +from google.api_core import parse_version_to_tuple from google.api_core.operations_v1 import AbstractOperationsClient import google.auth @@ -42,6 +43,7 @@ from google.api_core.operations_v1 import pagers_async from google.api_core.operations_v1 import transports from google.auth import credentials as ga_credentials +from google.auth import __version__ as auth_version from google.auth.exceptions import MutualTLSChannelError from google.longrunning import operations_pb2 from google.oauth2 import service_account @@ -345,12 +347,30 @@ def test_operations_client_client_options( with pytest.raises(MutualTLSChannelError): client = client_class() - # Check the case GOOGLE_API_USE_CLIENT_CERTIFICATE has unsupported value. + # Check the case GOOGLE_API_USE_CLIENT_CERTIFICATE has unsupported value with mock.patch.dict( os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "Unsupported"} ): - with pytest.raises(ValueError): - client = client_class() + # Test behavior for google.auth versions < 2.43.0. + # These versions do not have the updated mtls.should_use_client_cert logic. + # Verify that a ValueError is raised when GOOGLE_API_USE_CLIENT_CERTIFICATE + # is set to an unsupported value, as expected in these older versions. + if parse_version_to_tuple(auth_version) < (2, 43, 0): + with pytest.raises(ValueError): + client = client_class() + # Test behavior for google.auth versions >= 2.43.0. + # In these versions, if GOOGLE_API_USE_CLIENT_CERTIFICATE is set to an + # unsupported value (e.g., not 'true' or 'false'), the expected behavior + # of the internal google.auth.mtls.should_use_client_cert() function + # is to return False. Expect should_use_client_cert to return False, so + # client creation should proceed without requiring a client certificate. + else: + with mock.patch.object(transport_class, "__init__") as patched: + patched.return_value = None + client = client_class( + credentials=ga_credentials.AnonymousCredentials(), + transport=transport_name, + ) # Check the case quota_project_id is provided options = client_options.ClientOptions(quota_project_id="octopus") From 0fe0632cbf432e4ae9ef477e0719e64256a3b834 Mon Sep 17 00:00:00 2001 From: Daniel Sanche Date: Thu, 8 Jan 2026 13:41:39 -0800 Subject: [PATCH 25/27] chore: fix mypy check (#882) Mypy tests are are currently failing, due to a combination of a typing change in the auth library, and the deprecation of Python 3.7 This PR fixes mypy, and makes the types a bit more explicit --- google/api_core/operations_v1/transports/base.py | 11 +++++++---- noxfile.py | 1 - pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/google/api_core/operations_v1/transports/base.py b/google/api_core/operations_v1/transports/base.py index 46c2f5d16..2d78809b1 100644 --- a/google/api_core/operations_v1/transports/base.py +++ b/google/api_core/operations_v1/transports/base.py @@ -119,8 +119,6 @@ def __init__( host += ":443" # pragma: NO COVER self._host = host - scopes_kwargs = {"scopes": scopes, "default_scopes": self.AUTH_SCOPES} - # Save the scopes. self._scopes = scopes @@ -133,12 +131,17 @@ def __init__( if credentials_file is not None: credentials, _ = google.auth.load_credentials_from_file( - credentials_file, **scopes_kwargs, quota_project_id=quota_project_id + credentials_file, + scopes=scopes, + quota_project_id=quota_project_id, + default_scopes=self.AUTH_SCOPES, ) elif credentials is None: credentials, _ = google.auth.default( - **scopes_kwargs, quota_project_id=quota_project_id + scopes=scopes, + quota_project_id=quota_project_id, + default_scopes=self.AUTH_SCOPES, ) # If the credentials are service account credentials, then always try to use self signed JWT. diff --git a/noxfile.py b/noxfile.py index 9f53dbde0..2e8e64a6d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -276,7 +276,6 @@ def mypy(session): "types-requests", "types-protobuf", "types-dataclasses", - "types-mock; python_version=='3.7'", ) session.run("mypy", "google", "tests") diff --git a/pyproject.toml b/pyproject.toml index 31f82052a..46f8889e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ version = { attr = "google.api_core.version.__version__" } include = ["google*"] [tool.mypy] -python_version = "3.7" +python_version = "3.14" namespace_packages = true ignore_missing_imports = true From 2d93bd1e189a5c67d993bd78f38ee68d4e6429df Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 8 Jan 2026 16:43:03 -0500 Subject: [PATCH 26/27] tests: remove pytype nox session (#876) The `pytype` nox session was not running as a presubmit so I've removed it. `pytype` is deprecated as per [this note](https://github.com/google/pytype?tab=readme-ov-file#an-update-on-pytype). --- noxfile.py | 10 +--------- setup.cfg | 9 --------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/noxfile.py b/noxfile.py index 2e8e64a6d..1c4d55dd7 100644 --- a/noxfile.py +++ b/noxfile.py @@ -20,7 +20,7 @@ import unittest # https://github.com/google/importlab/issues/25 -import nox # pytype: disable=import-error +import nox BLACK_VERSION = "black==23.7.0" @@ -41,7 +41,6 @@ "unit_w_prerelease_deps", "unit_w_async_rest_extra", "cover", - "pytype", "mypy", "lint", "lint_setup_py", @@ -260,13 +259,6 @@ def lint_setup_py(session): session.run("python", "setup.py", "check", "--restructuredtext", "--strict") -@nox.session(python=DEFAULT_PYTHON_VERSION) -def pytype(session): - """Run type-checking.""" - session.install(".[grpc]", "pytype") - session.run("pytype") - - @nox.session(python=DEFAULT_PYTHON_VERSION) def mypy(session): """Run type-checking.""" diff --git a/setup.cfg b/setup.cfg index f7b5a3bc6..e69de29bb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +0,0 @@ -[pytype] -python_version = 3.7 -inputs = - google/ -exclude = - tests/ -output = .pytype/ -# Workaround for https://github.com/google/pytype/issues/150 -disable = pyi-error From 014d3def6ac4fdd5b39d5296a5aad10a546fde21 Mon Sep 17 00:00:00 2001 From: Daniel Sanche Date: Thu, 8 Jan 2026 13:57:48 -0800 Subject: [PATCH 27/27] chore: librarian release pull request: 20260108T134327Z (#883) PR created by the Librarian CLI to initialize a release. Merging this PR will auto trigger a release. Librarian Version: v1.0.1 Language Image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620
google-api-core: 2.29.0 ## [2.29.0](https://github.com/googleapis/python-api-core/compare/v2.28.1...v2.29.0) (2026-01-08) ### Features * make parse_version_to_tuple public (#864) ([c969186f](https://github.com/googleapis/python-api-core/commit/c969186f)) * Auto enable mTLS when supported certificates are detected (#869) ([f8bf6f96](https://github.com/googleapis/python-api-core/commit/f8bf6f96)) ### Bug Fixes * remove call to importlib.metadata.packages_distributions() for py38/py39 (#859) ([628003e2](https://github.com/googleapis/python-api-core/commit/628003e2)) * Log version check errors (#858) ([6493118c](https://github.com/googleapis/python-api-core/commit/6493118c)) * flaky tests due to imprecision in floating point calculation and performance test setup (#865) ([93404080](https://github.com/googleapis/python-api-core/commit/93404080)) * closes tailing streams in bidi classes. (#851) ([c97b3a00](https://github.com/googleapis/python-api-core/commit/c97b3a00))
--- .librarian/state.yaml | 3 ++- CHANGELOG.md | 16 ++++++++++++++++ google/api_core/version.py | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.librarian/state.yaml b/.librarian/state.yaml index 1a97264b7..e6e2940d4 100644 --- a/.librarian/state.yaml +++ b/.librarian/state.yaml @@ -1,7 +1,8 @@ image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620 libraries: - id: google-api-core - version: 2.28.1 + version: 2.29.0 + last_generated_commit: "" apis: [] source_roots: - . diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b2a256bb..716dfd01b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ [1]: https://pypi.org/project/google-api-core/#history +## [2.29.0](https://github.com/googleapis/google-cloud-python/compare/google-api-core-v2.28.1...google-api-core-v2.29.0) (2026-01-08) + + +### Features + +* Auto enable mTLS when supported certificates are detected (#869) ([f8bf6f9610f3e0e7580f223794c3906513e1fa73](https://github.com/googleapis/google-cloud-python/commit/f8bf6f9610f3e0e7580f223794c3906513e1fa73)) +* make parse_version_to_tuple public (#864) ([c969186f2b66bde1df5e25bbedc5868e27d136f9](https://github.com/googleapis/google-cloud-python/commit/c969186f2b66bde1df5e25bbedc5868e27d136f9)) + + +### Bug Fixes + +* flaky tests due to imprecision in floating point calculation and performance test setup (#865) ([93404080f853699b9217e4b76391a13525db4e3e](https://github.com/googleapis/google-cloud-python/commit/93404080f853699b9217e4b76391a13525db4e3e)) +* remove call to importlib.metadata.packages_distributions() for py38/py39 (#859) ([628003e217d9a881d24f3316aecfd48c244a73f0](https://github.com/googleapis/google-cloud-python/commit/628003e217d9a881d24f3316aecfd48c244a73f0)) +* Log version check errors (#858) ([6493118cae2720696c3d0097274edfd7fe2bce67](https://github.com/googleapis/google-cloud-python/commit/6493118cae2720696c3d0097274edfd7fe2bce67)) +* closes tailing streams in bidi classes. (#851) ([c97b3a004044ebf8b35c2a7ba97409d7795e11b0](https://github.com/googleapis/google-cloud-python/commit/c97b3a004044ebf8b35c2a7ba97409d7795e11b0)) + ## [2.28.1](https://github.com/googleapis/python-api-core/compare/v2.28.0...v2.28.1) (2025-10-28) diff --git a/google/api_core/version.py b/google/api_core/version.py index 967959b05..c8ba30be0 100644 --- a/google/api_core/version.py +++ b/google/api_core/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.28.1" +__version__ = "2.29.0"