From 722415963f2765b6274cea0c685736b686e31717 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Tue, 6 Aug 2024 17:45:31 -0400 Subject: [PATCH 01/11] chore: initial setup for async auth sessions api (#1571) * chore: initial setup for async auth sessions api * fix whitespace * add init file * update file names to aiohttp * update import statement --- google/auth/aio/transport/__init__.py | 25 +++++++++++++++++++++++++ google/auth/aio/transport/aiohttp.py | 16 ++++++++++++++++ tests/transport/aio/test_aiohttp.py | 16 ++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 google/auth/aio/transport/__init__.py create mode 100644 google/auth/aio/transport/aiohttp.py create mode 100644 tests/transport/aio/test_aiohttp.py diff --git a/google/auth/aio/transport/__init__.py b/google/auth/aio/transport/__init__.py new file mode 100644 index 000000000..28223b843 --- /dev/null +++ b/google/auth/aio/transport/__init__.py @@ -0,0 +1,25 @@ +# Copyright 2024 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. + +"""Transport - Async HTTP client library support. + +:mod:`google.auth.aio` is designed to work with various asynchronous client libraries such +as aiohttp. In order to work across these libraries with different +interfaces some abstraction is needed. + +This module provides two interfaces that are implemented by transport adapters +to support HTTP libraries. :class:`Request` defines the interface expected by +:mod:`google.auth` to make asynchronous requests. :class:`Response` defines the interface +for the return value of :class:`Request`. +""" diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py new file mode 100644 index 000000000..3ff5644f1 --- /dev/null +++ b/google/auth/aio/transport/aiohttp.py @@ -0,0 +1,16 @@ +# Copyright 2024 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. + +"""Transport adapter for Asynchronous HTTP Requests. +""" diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py new file mode 100644 index 000000000..320eca4f2 --- /dev/null +++ b/tests/transport/aio/test_aiohttp.py @@ -0,0 +1,16 @@ +# Copyright 2024 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 google.auth.aio.transport.aiohttp as auth_aiohttp +import pytest # type: ignore From 681f6ea383bed7ffec8cdad28cd2125fe7ebe547 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 12 Aug 2024 19:02:51 -0400 Subject: [PATCH 02/11] feat: Implement asynchronous timeout context manager (#1569) * feat: implement async timeout guard * add docstring * clean whitespace * update import file name * add missing return statement * update test cases * update test cases * include underlying timeout exception in trace * avoid the cost of actual time --- google/auth/aio/transport/aiohttp.py | 51 ++++++++++++++++++++ google/auth/exceptions.py | 4 ++ tests/transport/aio/test_aiohttp.py | 69 +++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py index 3ff5644f1..749b8d83a 100644 --- a/google/auth/aio/transport/aiohttp.py +++ b/google/auth/aio/transport/aiohttp.py @@ -14,3 +14,54 @@ """Transport adapter for Asynchronous HTTP Requests. """ + + +from google.auth.exceptions import TimeoutError + +import asyncio +import time +from contextlib import asynccontextmanager + + +@asynccontextmanager +async def timeout_guard(timeout): + """ + timeout_guard is an asynchronous context manager to apply a timeout to an asynchronous block of code. + + Args: + timeout (float): The time in seconds before the context manager times out. + + Raises: + google.auth.exceptions.TimeoutError: If the code within the context exceeds the provided timeout. + + Usage: + async with timeout_guard(10) as with_timeout: + await with_timeout(async_function()) + """ + start = time.monotonic() + total_timeout = timeout + + def _remaining_time(): + elapsed = time.monotonic() - start + remaining = total_timeout - elapsed + if remaining <= 0: + raise TimeoutError( + f"Context manager exceeded the configured timeout of {total_timeout}s." + ) + return remaining + + async def with_timeout(coro): + try: + remaining = _remaining_time() + response = await asyncio.wait_for(coro, remaining) + return response + except (asyncio.TimeoutError, TimeoutError) as e: + raise TimeoutError( + f"The operation {coro} exceeded the configured timeout of {total_timeout}s." + ) from e + + try: + yield with_timeout + + finally: + _remaining_time() diff --git a/google/auth/exceptions.py b/google/auth/exceptions.py index fcbe61b74..707bac88f 100644 --- a/google/auth/exceptions.py +++ b/google/auth/exceptions.py @@ -98,3 +98,7 @@ class InvalidType(DefaultCredentialsError, TypeError): class OSError(DefaultCredentialsError, EnvironmentError): """Used to wrap EnvironmentError(OSError after python3.3).""" + + +class TimeoutError(GoogleAuthError): + """Used to indicate a timeout error occurred during an HTTP request.""" diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py index 320eca4f2..c37544d6f 100644 --- a/tests/transport/aio/test_aiohttp.py +++ b/tests/transport/aio/test_aiohttp.py @@ -13,4 +13,71 @@ # limitations under the License. import google.auth.aio.transport.aiohttp as auth_aiohttp -import pytest # type: ignore +import pytest # type: ignore +import asyncio +from google.auth.exceptions import TimeoutError +from unittest.mock import patch + + +@pytest.fixture +async def simple_async_task(): + return True + + +class TestTimeoutGuard(object): + default_timeout = 1 + + def make_timeout_guard(self, timeout): + return auth_aiohttp.timeout_guard(timeout) + + @pytest.mark.asyncio + async def test_timeout_with_simple_async_task_within_bounds( + self, simple_async_task + ): + task = False + with patch("time.monotonic", side_effect=[0, 0.25, 0.75]): + with patch("asyncio.wait_for", lambda coro, timeout: coro): + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + task = await with_timeout(simple_async_task) + + # Task succeeds. + assert task is True + + @pytest.mark.asyncio + async def test_timeout_with_simple_async_task_out_of_bounds( + self, simple_async_task + ): + task = False + with patch("time.monotonic", side_effect=[0, 1, 1]): + with patch("asyncio.wait_for", lambda coro, timeout: coro): + with pytest.raises(TimeoutError) as exc: + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + task = await with_timeout(simple_async_task) + + # Task does not succeed and the context manager times out i.e. no remaining time left. + assert task is False + assert exc.match( + f"Context manager exceeded the configured timeout of {self.default_timeout}s." + ) + + @pytest.mark.asyncio + async def test_timeout_with_async_task_timing_out_before_context( + self, simple_async_task + ): + task = False + with pytest.raises(TimeoutError) as exc: + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError): + task = await with_timeout(simple_async_task) + + # Task does not complete i.e. the operation times out. + assert task is False + assert exc.match( + f"The operation {simple_async_task} exceeded the configured timeout of {self.default_timeout}s." + ) From 8c479815e5302511d6fd17961cc7544eba71f47d Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 12 Aug 2024 19:57:19 -0400 Subject: [PATCH 03/11] feat: Implement asynchronous `AuthorizedSession` api response class (#1575) * feat: implement asynchronous response class for AuthorizedSessions API * check if aiohttp is installed and avoid tests dependency * update content to be async * update docstring to be specific to aiohttp * add type checking and avoid leaking underlying API responses * add test case for iterating chunks * add read method to response interface * address PR comments * fix lint issues --- google/auth/aio/transport/__init__.py | 56 ++++++++++++++++++++++++ google/auth/aio/transport/aiohttp.py | 51 +++++++++++++++++++++- tests/transport/aio/test_aiohttp.py | 62 ++++++++++++++++++++++++++- 3 files changed, 166 insertions(+), 3 deletions(-) diff --git a/google/auth/aio/transport/__init__.py b/google/auth/aio/transport/__init__.py index 28223b843..5b3fd084f 100644 --- a/google/auth/aio/transport/__init__.py +++ b/google/auth/aio/transport/__init__.py @@ -23,3 +23,59 @@ :mod:`google.auth` to make asynchronous requests. :class:`Response` defines the interface for the return value of :class:`Request`. """ + +import abc +from typing import AsyncGenerator, Dict + + +class Response(metaclass=abc.ABCMeta): + """Asynchronous HTTP Response Interface.""" + + @property + @abc.abstractmethod + def status_code(self) -> int: + """ + The HTTP response status code.. + + Returns: + int: The HTTP response status code. + + """ + raise NotImplementedError("status_code must be implemented.") + + @property + @abc.abstractmethod + def headers(self) -> Dict[str, str]: + """The HTTP response headers. + + Returns: + Dict[str, str]: The HTTP response headers. + """ + raise NotImplementedError("headers must be implemented.") + + @abc.abstractmethod + async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]: + """The raw response content. + + Args: + chunk_size (int): The size of each chunk. Defaults to 1024. + + Yields: + AsyncGenerator[bytes, None]: An asynchronous generator yielding + response chunks as bytes. + """ + raise NotImplementedError("content must be implemented.") + + @abc.abstractmethod + async def read(self) -> bytes: + """Read the entire response content as bytes. + + Returns: + bytes: The entire response content. + """ + raise NotImplementedError("read must be implemented.") + + @abc.abstractmethod + async def close(self): + """Close the response after it is fully consumed to resource.""" + raise NotImplementedError("close must be implemented.") diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py index 749b8d83a..7aa1ccef2 100644 --- a/google/auth/aio/transport/aiohttp.py +++ b/google/auth/aio/transport/aiohttp.py @@ -12,10 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Transport adapter for Asynchronous HTTP Requests. +"""Transport adapter for AIOHTTP Requests. """ +try: + import aiohttp +except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport." + ) from caught_exc +from typing import AsyncGenerator, Dict +from google.auth import _helpers +from google.auth.aio import transport from google.auth.exceptions import TimeoutError import asyncio @@ -65,3 +74,43 @@ async def with_timeout(coro): finally: _remaining_time() + + +class Response(transport.Response): + + """ + Represents an HTTP response and its data. It is returned by ``google.auth.aio.transport.sessions.AuthorizedSession``. + + Args: + response (aiohttp.ClientResponse): An instance of aiohttp.ClientResponse. + + Attributes: + status_code (int): The HTTP status code of the response. + headers (Dict[str, str]): A case-insensitive multidict proxy wiht HTTP headers of response. + """ + + def __init__(self, response: aiohttp.ClientResponse): + self._response = response + + @property + @_helpers.copy_docstring(transport.Response) + def status_code(self) -> int: + return self._response.status + + @property + @_helpers.copy_docstring(transport.Response) + def headers(self) -> Dict[str, str]: + return {key: value for key, value in self._response.headers.items()} + + @_helpers.copy_docstring(transport.Response) + async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]: + async for chunk in self._response.content.iter_chunked(chunk_size): + yield chunk + + @_helpers.copy_docstring(transport.Response) + async def read(self) -> bytes: + return await self._response.read() + + @_helpers.copy_docstring(transport.Response) + async def close(self): + return await self._response.close() diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py index c37544d6f..3a21969ee 100644 --- a/tests/transport/aio/test_aiohttp.py +++ b/tests/transport/aio/test_aiohttp.py @@ -12,11 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -import google.auth.aio.transport.aiohttp as auth_aiohttp +from unittest.mock import AsyncMock, Mock, patch + import pytest # type: ignore + import asyncio + +import google.auth.aio.transport.aiohttp as auth_aiohttp + from google.auth.exceptions import TimeoutError -from unittest.mock import patch @pytest.fixture @@ -24,6 +28,60 @@ async def simple_async_task(): return True +@pytest.fixture +def mock_response(): + response = Mock() + response.status = 200 + response.headers = {"Content-Type": "application/json", "Content-Length": "100"} + mock_iterator = AsyncMock() + mock_iterator.__aiter__.return_value = iter( + [b"Cavefish ", b"have ", b"no ", b"sight."] + ) + response.content.iter_chunked = lambda chunk_size: mock_iterator + response.read = AsyncMock(return_value=b"Cavefish have no sight.") + response.close = AsyncMock() + + return auth_aiohttp.Response(response) + + +class TestResponse(object): + @pytest.mark.asyncio + async def test_response_status_code(self, mock_response): + assert mock_response.status_code == 200 + + @pytest.mark.asyncio + async def test_response_headers(self, mock_response): + assert mock_response.headers["Content-Type"] == "application/json" + assert mock_response.headers["Content-Length"] == "100" + + @pytest.mark.asyncio + async def test_response_content(self, mock_response): + content = b"".join([chunk async for chunk in mock_response.content()]) + assert content == b"Cavefish have no sight." + + @pytest.mark.asyncio + async def test_response_read(self, mock_response): + content = await mock_response.read() + assert content == b"Cavefish have no sight." + + @pytest.mark.asyncio + async def test_response_close(self, mock_response): + await mock_response.close() + mock_response._response.close.assert_called_once() + + @pytest.mark.asyncio + async def test_response_content_stream(self, mock_response): + itr = mock_response.content().__aiter__() + content = [] + try: + while True: + chunk = await itr.__anext__() + content.append(chunk) + except StopAsyncIteration: + pass + assert b"".join(content) == b"Cavefish have no sight." + + class TestTimeoutGuard(object): default_timeout = 1 From 5f46b609ffafc331ed17faf7d7b434662fe5f70f Mon Sep 17 00:00:00 2001 From: ohmayr Date: Tue, 13 Aug 2024 13:23:15 -0400 Subject: [PATCH 04/11] feat: Implement asynchronous `AuthorizedSession` api request class (#1579) * feat: implement request class for asynchoronous AuthorizedSession API * add type checking and address TODOs * remove default values from interface methods * aiohttp reponse close method must not be awaited * cleanup * update Request class docstring --- google/auth/aio/transport/__init__.py | 54 ++++++++++++- google/auth/aio/transport/aiohttp.py | 111 ++++++++++++++++++++++++-- tests/transport/aio/test_aiohttp.py | 53 +++++++++++- 3 files changed, 207 insertions(+), 11 deletions(-) diff --git a/google/auth/aio/transport/__init__.py b/google/auth/aio/transport/__init__.py index 5b3fd084f..fb3143de3 100644 --- a/google/auth/aio/transport/__init__.py +++ b/google/auth/aio/transport/__init__.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Transport - Async HTTP client library support. +"""Transport - Asynchronous HTTP client library support. :mod:`google.auth.aio` is designed to work with various asynchronous client libraries such as aiohttp. In order to work across these libraries with different @@ -25,7 +25,7 @@ """ import abc -from typing import AsyncGenerator, Dict +from typing import AsyncGenerator, Dict, Mapping, Optional class Response(metaclass=abc.ABCMeta): @@ -79,3 +79,53 @@ async def read(self) -> bytes: async def close(self): """Close the response after it is fully consumed to resource.""" raise NotImplementedError("close must be implemented.") + + +class Request(metaclass=abc.ABCMeta): + """Interface for a callable that makes HTTP requests. + + Specific transport implementations should provide an implementation of + this that adapts their specific request / response API. + + .. automethod:: __call__ + """ + + @abc.abstractmethod + async def __call__( + self, + url: str, + method: str, + body: bytes, + headers: Optional[Mapping[str, str]], + timeout: float, + **kwargs + ) -> Response: + """Make an HTTP request. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (float): The number of seconds to wait for a + response from the server. If not specified or if None, the + transport-specific default timeout will be used. + kwargs: Additional arguments passed on to the transport's + request method. + + Returns: + google.auth.aio.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + # pylint: disable=redundant-returns-doc, missing-raises-doc + # (pylint doesn't play well with abstract docstrings.) + raise NotImplementedError("__call__ must be implemented.") + + async def close(self) -> None: + """ + Close the underlying session. + """ + raise NotImplementedError("close must be implemented.") diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py index 7aa1ccef2..a1bbbd639 100644 --- a/google/auth/aio/transport/aiohttp.py +++ b/google/auth/aio/transport/aiohttp.py @@ -15,21 +15,25 @@ """Transport adapter for AIOHTTP Requests. """ +import asyncio +from contextlib import asynccontextmanager +import time +from typing import AsyncGenerator, Dict, Mapping, Optional + try: import aiohttp except ImportError as caught_exc: # pragma: NO COVER raise ImportError( "The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport." ) from caught_exc -from typing import AsyncGenerator, Dict from google.auth import _helpers +from google.auth import exceptions from google.auth.aio import transport from google.auth.exceptions import TimeoutError -import asyncio -import time -from contextlib import asynccontextmanager + +_DEFAULT_TIMEOUT_SECONDS = 180 @asynccontextmanager @@ -77,7 +81,6 @@ async def with_timeout(coro): class Response(transport.Response): - """ Represents an HTTP response and its data. It is returned by ``google.auth.aio.transport.sessions.AuthorizedSession``. @@ -113,4 +116,100 @@ async def read(self) -> bytes: @_helpers.copy_docstring(transport.Response) async def close(self): - return await self._response.close() + self._response.close() + + +class Request(transport.Request): + """Asynchronous Requests request adapter. + + This class is used internally for making requests using aiohttp + in a consistent way. If you use :class:`AuthorizedSession` you do not need + to construct or use this class directly. + + This class can be useful if you want to configure a Request callable + with a custom ``aiohttp.ClientSession`` in :class:`AuthorizedSession` or if + you want to manually refresh a :class:`~google.auth.aio.credentials.Credentials` instance:: + + import aiohttp + import google.auth.aio.transport.aiohttp + + # Default example: + request = google.auth.aio.transport.aiohttp.Request() + await credentials.refresh(request) + + # Custom aiohttp Session Example: + session = session=aiohttp.ClientSession(auto_decompress=False) + request = google.auth.aio.transport.aiohttp.Request(session=session) + auth_sesion = google.auth.aio.transport.sessions.AuthorizedSession(auth_request=request) + + Args: + session (aiohttp.ClientSession): An instance :class:`aiohttp.ClientSession` used + to make HTTP requests. If not specified, a session will be created. + + .. automethod:: __call__ + """ + + def __init__(self, session: aiohttp.ClientSession = None): + self.session = session or aiohttp.ClientSession() + + async def __call__( + self, + url: str, + method: str = "GET", + body: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + timeout: float = _DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + """ + Make an HTTP request using aiohttp. + + Args: + url (str): The URL to be requested. + method (Optional[str]): + The HTTP method to use for the request. Defaults to 'GET'. + body (Optional[bytes]): + The payload or body in HTTP request. + headers (Optional[Mapping[str, str]]): + Request headers. + timeout (float): The number of seconds to wait for a + response from the server. If not specified or if None, the + requests default timeout will be used. + kwargs: Additional arguments passed through to the underlying + aiohttp :meth:`aiohttp.Session.request` method. + + Returns: + google.auth.aio.transport.Response: The HTTP response. + + Raises: + - google.auth.exceptions.TransportError: If the request fails. + - google.auth.exceptions.TimeoutError: If the request times out. + """ + + try: + client_timeout = aiohttp.ClientTimeout(total=timeout) + response = await self.session.request( + method, + url, + data=body, + headers=headers, + timeout=client_timeout, + **kwargs, + ) + return Response(response) + + except aiohttp.ClientError as caught_exc: + new_exc = exceptions.TransportError(f"Failed to send request to {url}.") + raise new_exc from caught_exc + + except asyncio.TimeoutError as caught_exc: + new_exc = exceptions.TimeoutError( + f"Request timed out after {timeout} seconds." + ) + raise new_exc from caught_exc + + async def close(self) -> None: + """ + Close the underlying aiohttp session to release the acquired resources. + """ + await self.session.close() diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py index 3a21969ee..4b1c65f93 100644 --- a/tests/transport/aio/test_aiohttp.py +++ b/tests/transport/aio/test_aiohttp.py @@ -12,17 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio from unittest.mock import AsyncMock, Mock, patch +from aioresponses import aioresponses import pytest # type: ignore +import pytest_asyncio -import asyncio - +from google.auth import exceptions import google.auth.aio.transport.aiohttp as auth_aiohttp - from google.auth.exceptions import TimeoutError +try: + import aiohttp +except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport." + ) from caught_exc + + @pytest.fixture async def simple_async_task(): return True @@ -139,3 +148,41 @@ async def test_timeout_with_async_task_timing_out_before_context( assert exc.match( f"The operation {simple_async_task} exceeded the configured timeout of {self.default_timeout}s." ) + + +@pytest.mark.asyncio +class TestRequest: + @pytest_asyncio.fixture + async def aiohttp_request(self): + request = auth_aiohttp.Request() + yield request + await request.close() + + async def test_request_call_success(self, aiohttp_request): + with aioresponses() as m: + mocked_chunks = [b"Cavefish ", b"have ", b"no ", b"sight."] + mocked_response = b"".join(mocked_chunks) + m.get("http://example.com", status=200, body=mocked_response) + response = await aiohttp_request("http://example.com") + assert response.status_code == 200 + assert response.headers == {"Content-Type": "application/json"} + content = b"".join([chunk async for chunk in response.content()]) + assert content == b"Cavefish have no sight." + + async def test_request_call_raises_client_error(self, aiohttp_request): + with aioresponses() as m: + m.get("http://example.com", exception=aiohttp.ClientError) + + with pytest.raises(exceptions.TransportError) as exc: + await aiohttp_request("http://example.com/api") + + exc.match("Failed to send request to http://example.com/api.") + + async def test_request_call_raises_timeout_error(self, aiohttp_request): + with aioresponses() as m: + m.get("http://example.com", exception=asyncio.TimeoutError) + + with pytest.raises(exceptions.TimeoutError) as exc: + await aiohttp_request("http://example.com") + + exc.match("Request timed out after 180 seconds.") From 8833ad6f92c3300d6645355994c7db2356bd30ad Mon Sep 17 00:00:00 2001 From: ohmayr Date: Thu, 15 Aug 2024 13:25:53 -0400 Subject: [PATCH 05/11] feat: Implement asynchronous `AuthorizedSession` class (#1580) * feat: Implement Asynchronous AuthorizedSession class * add comment for implementing locks within refresh * move timeout guard to sessions * add unit tests and code cleanup * implement async exponential backoff iterator * cleanup * add testing for http methods and cleanup * update number of retries to 3 * refactor test cases * fix linter and mypy issues * fix pytest code coverage --- google/auth/_exponential_backoff.py | 77 +++++-- google/auth/aio/transport/__init__.py | 29 ++- google/auth/aio/transport/aiohttp.py | 68 +----- google/auth/aio/transport/sessions.py | 268 ++++++++++++++++++++++ tests/test__exponential_backoff.py | 41 ++++ tests/transport/aio/test_aiohttp.py | 73 +----- tests/transport/aio/test_sessions.py | 309 ++++++++++++++++++++++++++ 7 files changed, 715 insertions(+), 150 deletions(-) create mode 100644 google/auth/aio/transport/sessions.py create mode 100644 tests/transport/aio/test_sessions.py diff --git a/google/auth/_exponential_backoff.py b/google/auth/_exponential_backoff.py index 04f9f9764..89853448f 100644 --- a/google/auth/_exponential_backoff.py +++ b/google/auth/_exponential_backoff.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio import random import time @@ -38,9 +39,8 @@ """ -class ExponentialBackoff: - """An exponential backoff iterator. This can be used in a for loop to - perform requests with exponential backoff. +class _BaseExponentialBackoff: + """An exponential backoff iterator base class. Args: total_attempts Optional[int]: @@ -84,9 +84,40 @@ def __init__( self._multiplier = multiplier self._backoff_count = 0 - def __iter__(self): + @property + def total_attempts(self): + """The total amount of backoff attempts that will be made.""" + return self._total_attempts + + @property + def backoff_count(self): + """The current amount of backoff attempts that have been made.""" + return self._backoff_count + + def _reset(self): self._backoff_count = 0 self._current_wait_in_seconds = self._initial_wait_seconds + + def _calculate_jitter(self): + jitter_variance = self._current_wait_in_seconds * self._randomization_factor + jitter = random.uniform( + self._current_wait_in_seconds - jitter_variance, + self._current_wait_in_seconds + jitter_variance, + ) + + return jitter + + +class ExponentialBackoff(_BaseExponentialBackoff): + """An exponential backoff iterator. This can be used in a for loop to + perform requests with exponential backoff. + """ + + def __init__(self, *args, **kwargs): + super(ExponentialBackoff, self).__init__(*args, **kwargs) + + def __iter__(self): + self._reset() return self def __next__(self): @@ -97,23 +128,37 @@ def __next__(self): if self._backoff_count <= 1: return self._backoff_count - jitter_variance = self._current_wait_in_seconds * self._randomization_factor - jitter = random.uniform( - self._current_wait_in_seconds - jitter_variance, - self._current_wait_in_seconds + jitter_variance, - ) + jitter = self._calculate_jitter() time.sleep(jitter) self._current_wait_in_seconds *= self._multiplier return self._backoff_count - @property - def total_attempts(self): - """The total amount of backoff attempts that will be made.""" - return self._total_attempts - @property - def backoff_count(self): - """The current amount of backoff attempts that have been made.""" +class AsyncExponentialBackoff(_BaseExponentialBackoff): + """An async exponential backoff iterator. This can be used in a for loop to + perform async requests with exponential backoff. + """ + + def __init__(self, *args, **kwargs): + super(AsyncExponentialBackoff, self).__init__(*args, **kwargs) + + def __aiter__(self): + self._reset() + return self + + async def __anext__(self): + if self._backoff_count >= self._total_attempts: + raise StopAsyncIteration + self._backoff_count += 1 + + if self._backoff_count <= 1: + return self._backoff_count + + jitter = self._calculate_jitter() + + await asyncio.sleep(jitter) + + self._current_wait_in_seconds *= self._multiplier return self._backoff_count diff --git a/google/auth/aio/transport/__init__.py b/google/auth/aio/transport/__init__.py index fb3143de3..7a037ef4f 100644 --- a/google/auth/aio/transport/__init__.py +++ b/google/auth/aio/transport/__init__.py @@ -25,7 +25,24 @@ """ import abc -from typing import AsyncGenerator, Dict, Mapping, Optional +from typing import AsyncGenerator, Mapping, Optional + +import google.auth.transport + + +_DEFAULT_TIMEOUT_SECONDS = 180 + +DEFAULT_RETRYABLE_STATUS_CODES = google.auth.transport.DEFAULT_RETRYABLE_STATUS_CODES +"""Sequence[int]: HTTP status codes indicating a request can be retried. +""" + +DEFAULT_REFRESH_STATUS_CODES = google.auth.transport.DEFAULT_REFRESH_STATUS_CODES +"""Sequence[int]: Which HTTP status code indicate that credentials should be +refreshed. +""" + +DEFAULT_MAX_REFRESH_ATTEMPTS = 3 +"""int: How many times to refresh the credentials and retry a request.""" class Response(metaclass=abc.ABCMeta): @@ -35,7 +52,7 @@ class Response(metaclass=abc.ABCMeta): @abc.abstractmethod def status_code(self) -> int: """ - The HTTP response status code.. + The HTTP response status code. Returns: int: The HTTP response status code. @@ -45,11 +62,11 @@ def status_code(self) -> int: @property @abc.abstractmethod - def headers(self) -> Dict[str, str]: + def headers(self) -> Mapping[str, str]: """The HTTP response headers. Returns: - Dict[str, str]: The HTTP response headers. + Mapping[str, str]: The HTTP response headers. """ raise NotImplementedError("headers must be implemented.") @@ -95,7 +112,7 @@ async def __call__( self, url: str, method: str, - body: bytes, + body: Optional[bytes], headers: Optional[Mapping[str, str]], timeout: float, **kwargs @@ -106,7 +123,7 @@ async def __call__( url (str): The URI to be requested. method (str): The HTTP method to use for the request. Defaults to 'GET'. - body (bytes): The payload / body in HTTP request. + body (Optional[bytes]): The payload / body in HTTP request. headers (Mapping[str, str]): Request headers. timeout (float): The number of seconds to wait for a response from the server. If not specified or if None, the diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py index a1bbbd639..89d3766f9 100644 --- a/google/auth/aio/transport/aiohttp.py +++ b/google/auth/aio/transport/aiohttp.py @@ -16,12 +16,10 @@ """ import asyncio -from contextlib import asynccontextmanager -import time -from typing import AsyncGenerator, Dict, Mapping, Optional +from typing import AsyncGenerator, Mapping, Optional try: - import aiohttp + import aiohttp # type: ignore except ImportError as caught_exc: # pragma: NO COVER raise ImportError( "The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport." @@ -30,54 +28,6 @@ from google.auth import _helpers from google.auth import exceptions from google.auth.aio import transport -from google.auth.exceptions import TimeoutError - - -_DEFAULT_TIMEOUT_SECONDS = 180 - - -@asynccontextmanager -async def timeout_guard(timeout): - """ - timeout_guard is an asynchronous context manager to apply a timeout to an asynchronous block of code. - - Args: - timeout (float): The time in seconds before the context manager times out. - - Raises: - google.auth.exceptions.TimeoutError: If the code within the context exceeds the provided timeout. - - Usage: - async with timeout_guard(10) as with_timeout: - await with_timeout(async_function()) - """ - start = time.monotonic() - total_timeout = timeout - - def _remaining_time(): - elapsed = time.monotonic() - start - remaining = total_timeout - elapsed - if remaining <= 0: - raise TimeoutError( - f"Context manager exceeded the configured timeout of {total_timeout}s." - ) - return remaining - - async def with_timeout(coro): - try: - remaining = _remaining_time() - response = await asyncio.wait_for(coro, remaining) - return response - except (asyncio.TimeoutError, TimeoutError) as e: - raise TimeoutError( - f"The operation {coro} exceeded the configured timeout of {total_timeout}s." - ) from e - - try: - yield with_timeout - - finally: - _remaining_time() class Response(transport.Response): @@ -89,7 +39,7 @@ class Response(transport.Response): Attributes: status_code (int): The HTTP status code of the response. - headers (Dict[str, str]): A case-insensitive multidict proxy wiht HTTP headers of response. + headers (Mapping[str, str]): The HTTP headers of the response. """ def __init__(self, response: aiohttp.ClientResponse): @@ -102,7 +52,7 @@ def status_code(self) -> int: @property @_helpers.copy_docstring(transport.Response) - def headers(self) -> Dict[str, str]: + def headers(self) -> Mapping[str, str]: return {key: value for key, value in self._response.headers.items()} @_helpers.copy_docstring(transport.Response) @@ -158,7 +108,7 @@ async def __call__( method: str = "GET", body: Optional[bytes] = None, headers: Optional[Mapping[str, str]] = None, - timeout: float = _DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, **kwargs, ) -> transport.Response: """ @@ -199,14 +149,14 @@ async def __call__( return Response(response) except aiohttp.ClientError as caught_exc: - new_exc = exceptions.TransportError(f"Failed to send request to {url}.") - raise new_exc from caught_exc + client_exc = exceptions.TransportError(f"Failed to send request to {url}.") + raise client_exc from caught_exc except asyncio.TimeoutError as caught_exc: - new_exc = exceptions.TimeoutError( + timeout_exc = exceptions.TimeoutError( f"Request timed out after {timeout} seconds." ) - raise new_exc from caught_exc + raise timeout_exc from caught_exc async def close(self) -> None: """ diff --git a/google/auth/aio/transport/sessions.py b/google/auth/aio/transport/sessions.py new file mode 100644 index 000000000..60d33df3e --- /dev/null +++ b/google/auth/aio/transport/sessions.py @@ -0,0 +1,268 @@ +# Copyright 2024 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 asyncio +from contextlib import asynccontextmanager +import functools +import time +from typing import Mapping, Optional + +from google.auth import _exponential_backoff, exceptions +from google.auth.aio import transport +from google.auth.aio.credentials import Credentials +from google.auth.exceptions import TimeoutError + +try: + from google.auth.aio.transport.aiohttp import Request as AiohttpRequest + + AIOHTTP_INSTALLED = True +except ImportError: # pragma: NO COVER + AIOHTTP_INSTALLED = False + + +@asynccontextmanager +async def timeout_guard(timeout): + """ + timeout_guard is an asynchronous context manager to apply a timeout to an asynchronous block of code. + + Args: + timeout (float): The time in seconds before the context manager times out. + + Raises: + google.auth.exceptions.TimeoutError: If the code within the context exceeds the provided timeout. + + Usage: + async with timeout_guard(10) as with_timeout: + await with_timeout(async_function()) + """ + start = time.monotonic() + total_timeout = timeout + + def _remaining_time(): + elapsed = time.monotonic() - start + remaining = total_timeout - elapsed + if remaining <= 0: + raise TimeoutError( + f"Context manager exceeded the configured timeout of {total_timeout}s." + ) + return remaining + + async def with_timeout(coro): + try: + remaining = _remaining_time() + response = await asyncio.wait_for(coro, remaining) + return response + except (asyncio.TimeoutError, TimeoutError) as e: + raise TimeoutError( + f"The operation {coro} exceeded the configured timeout of {total_timeout}s." + ) from e + + try: + yield with_timeout + + finally: + _remaining_time() + + +class AuthorizedSession: + """This is an asynchronous implementation of :class:`google.auth.requests.AuthorizedSession` class. + We utilize an instance of a class that implements :class:`google.auth.aio.transport.Request` configured + by the caller or otherwise default to `google.auth.aio.transport.aiohttp.Request` if the external aiohttp + package is installed. + + A Requests Session class with credentials. + + This class is used to perform asynchronous requests to API endpoints that require + authorization:: + + import aiohttp + from google.auth.aio.transport import sessions + + async with sessions.AuthorizedSession(credentials) as authed_session: + response = await authed_session.request( + 'GET', 'https://www.googleapis.com/storage/v1/b') + + The underlying :meth:`request` implementation handles adding the + credentials' headers to the request and refreshing credentials as needed. + + Args: + credentials (google.auth.aio.credentials.Credentials): + The credentials to add to the request. + auth_request (Optional[google.auth.aio.transport.Request]): + An instance of a class that implements + :class:`~google.auth.aio.transport.Request` used to make requests + and refresh credentials. If not passed, + an instance of :class:`~google.auth.aio.transport.aiohttp.Request` + is created. + + Raises: + - google.auth.exceptions.TransportError: If `auth_request` is `None` + and the external package `aiohttp` is not installed. + - google.auth.exceptions.InvalidType: If the provided credentials are + not of type `google.auth.aio.credentials.Credentials`. + """ + + def __init__( + self, credentials: Credentials, auth_request: Optional[transport.Request] = None + ): + if not isinstance(credentials, Credentials): + raise exceptions.InvalidType( + f"The configured credentials of type {type(credentials)} are invalid and must be of type `google.auth.aio.credentials.Credentials`" + ) + self._credentials = credentials + _auth_request = auth_request + if not _auth_request and AIOHTTP_INSTALLED: + _auth_request = AiohttpRequest() + if _auth_request is None: + raise exceptions.TransportError( + "`auth_request` must either be configured or the external package `aiohttp` must be installed to use the default value." + ) + self._auth_request = _auth_request + + async def request( + self, + method: str, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + """ + Args: + method (str): The http method used to make the request. + url (str): The URI to be requested. + data (Optional[bytes]): The payload or body in HTTP request. + headers (Optional[Mapping[str, str]]): Request headers. + timeout (float): + The amount of time in seconds to wait for the server response + with each individual request. + max_allowed_time (float): + If the method runs longer than this, a ``Timeout`` exception is + automatically raised. Unlike the ``timeout`` parameter, this + value applies to the total method execution time, even if + multiple requests are made under the hood. + + Mind that it is not guaranteed that the timeout error is raised + at ``max_allowed_time``. It might take longer, for example, if + an underlying request takes a lot of time, but the request + itself does not timeout, e.g. if a large file is being + transmitted. The timout error will be raised after such + request completes. + + Returns: + google.auth.aio.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TimeoutError: If the method does not complete within + the configured `max_allowed_time` or the request exceeds the configured + `timeout`. + """ + + retries = _exponential_backoff.AsyncExponentialBackoff( + total_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS + ) + async with timeout_guard(max_allowed_time) as with_timeout: + await with_timeout( + # Note: before_request will attempt to refresh credentials if expired. + self._credentials.before_request( + self._auth_request, method, url, headers + ) + ) + # Workaround issue in python 3.9 related to code coverage by adding `# pragma: no branch` + # See https://github.com/googleapis/gapic-generator-python/pull/1174#issuecomment-1025132372 + async for _ in retries: # pragma: no branch + response = await with_timeout( + self._auth_request(url, method, data, headers, timeout, **kwargs) + ) + if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES: + break + return response + + @functools.wraps(request) + async def get( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "GET", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def post( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "POST", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def put( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "PUT", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def patch( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "PATCH", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + @functools.wraps(request) + async def delete( + self, + url: str, + data: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS, + timeout: float = transport._DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ) -> transport.Response: + return await self.request( + "DELETE", url, data, headers, max_allowed_time, timeout, **kwargs + ) + + async def close(self) -> None: + """ + Close the underlying auth request session. + """ + await self._auth_request.close() diff --git a/tests/test__exponential_backoff.py b/tests/test__exponential_backoff.py index 95422502b..b7b6877b2 100644 --- a/tests/test__exponential_backoff.py +++ b/tests/test__exponential_backoff.py @@ -54,3 +54,44 @@ def test_minimum_total_attempts(): with pytest.raises(exceptions.InvalidValue): _exponential_backoff.ExponentialBackoff(total_attempts=-1) _exponential_backoff.ExponentialBackoff(total_attempts=1) + + +@pytest.mark.asyncio +@mock.patch("asyncio.sleep", return_value=None) +async def test_exponential_backoff_async(mock_time_async): + eb = _exponential_backoff.AsyncExponentialBackoff() + curr_wait = eb._current_wait_in_seconds + iteration_count = 0 + + # Workaround issue in python 3.9 related to code coverage by adding `# pragma: no branch` + # See https://github.com/googleapis/gapic-generator-python/pull/1174#issuecomment-1025132372 + async for attempt in eb: # pragma: no branch + if attempt == 1: + assert mock_time_async.call_count == 0 + else: + backoff_interval = mock_time_async.call_args[0][0] + jitter = curr_wait * eb._randomization_factor + + assert (curr_wait - jitter) <= backoff_interval <= (curr_wait + jitter) + assert attempt == iteration_count + 1 + assert eb.backoff_count == iteration_count + 1 + assert eb._current_wait_in_seconds == eb._multiplier ** iteration_count + + curr_wait = eb._current_wait_in_seconds + iteration_count += 1 + + assert eb.total_attempts == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS + assert eb.backoff_count == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS + assert iteration_count == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS + assert ( + mock_time_async.call_count + == _exponential_backoff._DEFAULT_RETRY_TOTAL_ATTEMPTS - 1 + ) + + +def test_minimum_total_attempts_async(): + with pytest.raises(exceptions.InvalidValue): + _exponential_backoff.AsyncExponentialBackoff(total_attempts=0) + with pytest.raises(exceptions.InvalidValue): + _exponential_backoff.AsyncExponentialBackoff(total_attempts=-1) + _exponential_backoff.AsyncExponentialBackoff(total_attempts=1) diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py index 4b1c65f93..00b92911c 100644 --- a/tests/transport/aio/test_aiohttp.py +++ b/tests/transport/aio/test_aiohttp.py @@ -13,30 +13,24 @@ # limitations under the License. import asyncio -from unittest.mock import AsyncMock, Mock, patch -from aioresponses import aioresponses +from aioresponses import aioresponses # type: ignore +from mock import AsyncMock, Mock import pytest # type: ignore -import pytest_asyncio +import pytest_asyncio # type: ignore from google.auth import exceptions import google.auth.aio.transport.aiohttp as auth_aiohttp -from google.auth.exceptions import TimeoutError try: - import aiohttp + import aiohttp # type: ignore except ImportError as caught_exc: # pragma: NO COVER raise ImportError( "The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport." ) from caught_exc -@pytest.fixture -async def simple_async_task(): - return True - - @pytest.fixture def mock_response(): response = Mock() @@ -91,65 +85,6 @@ async def test_response_content_stream(self, mock_response): assert b"".join(content) == b"Cavefish have no sight." -class TestTimeoutGuard(object): - default_timeout = 1 - - def make_timeout_guard(self, timeout): - return auth_aiohttp.timeout_guard(timeout) - - @pytest.mark.asyncio - async def test_timeout_with_simple_async_task_within_bounds( - self, simple_async_task - ): - task = False - with patch("time.monotonic", side_effect=[0, 0.25, 0.75]): - with patch("asyncio.wait_for", lambda coro, timeout: coro): - async with self.make_timeout_guard( - timeout=self.default_timeout - ) as with_timeout: - task = await with_timeout(simple_async_task) - - # Task succeeds. - assert task is True - - @pytest.mark.asyncio - async def test_timeout_with_simple_async_task_out_of_bounds( - self, simple_async_task - ): - task = False - with patch("time.monotonic", side_effect=[0, 1, 1]): - with patch("asyncio.wait_for", lambda coro, timeout: coro): - with pytest.raises(TimeoutError) as exc: - async with self.make_timeout_guard( - timeout=self.default_timeout - ) as with_timeout: - task = await with_timeout(simple_async_task) - - # Task does not succeed and the context manager times out i.e. no remaining time left. - assert task is False - assert exc.match( - f"Context manager exceeded the configured timeout of {self.default_timeout}s." - ) - - @pytest.mark.asyncio - async def test_timeout_with_async_task_timing_out_before_context( - self, simple_async_task - ): - task = False - with pytest.raises(TimeoutError) as exc: - async with self.make_timeout_guard( - timeout=self.default_timeout - ) as with_timeout: - with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError): - task = await with_timeout(simple_async_task) - - # Task does not complete i.e. the operation times out. - assert task is False - assert exc.match( - f"The operation {simple_async_task} exceeded the configured timeout of {self.default_timeout}s." - ) - - @pytest.mark.asyncio class TestRequest: @pytest_asyncio.fixture diff --git a/tests/transport/aio/test_sessions.py b/tests/transport/aio/test_sessions.py new file mode 100644 index 000000000..49567be9e --- /dev/null +++ b/tests/transport/aio/test_sessions.py @@ -0,0 +1,309 @@ +# Copyright 2024 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 asyncio +from typing import AsyncGenerator + +from aioresponses import aioresponses # type: ignore +from mock import Mock, patch +import pytest # type: ignore + +from google.auth.aio.credentials import AnonymousCredentials +from google.auth.aio.transport import ( + _DEFAULT_TIMEOUT_SECONDS, + DEFAULT_MAX_REFRESH_ATTEMPTS, + DEFAULT_RETRYABLE_STATUS_CODES, + Request, + Response, + sessions, +) +from google.auth.exceptions import InvalidType, TimeoutError, TransportError + + +@pytest.fixture +async def simple_async_task(): + return True + + +class MockRequest(Request): + def __init__(self, response=None, side_effect=None): + self._closed = False + self._response = response + self._side_effect = side_effect + self.call_count = 0 + + async def __call__( + self, + url, + method="GET", + body=None, + headers=None, + timeout=_DEFAULT_TIMEOUT_SECONDS, + **kwargs, + ): + self.call_count += 1 + if self._side_effect: + raise self._side_effect + return self._response + + async def close(self): + self._closed = True + return None + + +class MockResponse(Response): + def __init__(self, status_code, headers=None, content=None): + self._status_code = status_code + self._headers = headers + self._content = content + self._close = False + + @property + def status_code(self): + return self._status_code + + @property + def headers(self): + return self._headers + + async def read(self) -> bytes: + content = await self.content(1024) + return b"".join([chunk async for chunk in content]) + + async def content(self, chunk_size=None) -> AsyncGenerator: + return self._content + + async def close(self) -> None: + self._close = True + + +class TestTimeoutGuard(object): + default_timeout = 1 + + def make_timeout_guard(self, timeout): + return sessions.timeout_guard(timeout) + + @pytest.mark.asyncio + async def test_timeout_with_simple_async_task_within_bounds( + self, simple_async_task + ): + task = False + with patch("time.monotonic", side_effect=[0, 0.25, 0.75]): + with patch("asyncio.wait_for", lambda coro, _: coro): + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + task = await with_timeout(simple_async_task) + + # Task succeeds. + assert task is True + + @pytest.mark.asyncio + async def test_timeout_with_simple_async_task_out_of_bounds( + self, simple_async_task + ): + task = False + with patch("time.monotonic", side_effect=[0, 1, 1]): + with pytest.raises(TimeoutError) as exc: + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + task = await with_timeout(simple_async_task) + + # Task does not succeed and the context manager times out i.e. no remaining time left. + assert task is False + assert exc.match( + f"Context manager exceeded the configured timeout of {self.default_timeout}s." + ) + + @pytest.mark.asyncio + async def test_timeout_with_async_task_timing_out_before_context( + self, simple_async_task + ): + task = False + with pytest.raises(TimeoutError) as exc: + async with self.make_timeout_guard( + timeout=self.default_timeout + ) as with_timeout: + with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError): + task = await with_timeout(simple_async_task) + + # Task does not complete i.e. the operation times out. + assert task is False + assert exc.match( + f"The operation {simple_async_task} exceeded the configured timeout of {self.default_timeout}s." + ) + + +class TestAuthorizedSession(object): + TEST_URL = "http://example.com/" + credentials = AnonymousCredentials() + + @pytest.fixture + async def mocked_content(self): + content = [b"Cavefish ", b"have ", b"no ", b"sight."] + for chunk in content: + yield chunk + + @pytest.mark.asyncio + async def test_constructor_with_default_auth_request(self): + with patch("google.auth.aio.transport.sessions.AIOHTTP_INSTALLED", True): + authed_session = sessions.AuthorizedSession(self.credentials) + assert authed_session._credentials == self.credentials + await authed_session.close() + + @pytest.mark.asyncio + async def test_constructor_with_provided_auth_request(self): + auth_request = MockRequest() + authed_session = sessions.AuthorizedSession( + self.credentials, auth_request=auth_request + ) + + assert authed_session._auth_request is auth_request + await authed_session.close() + + @pytest.mark.asyncio + async def test_constructor_raises_no_auth_request_error(self): + with patch("google.auth.aio.transport.sessions.AIOHTTP_INSTALLED", False): + with pytest.raises(TransportError) as exc: + sessions.AuthorizedSession(self.credentials) + + exc.match( + "`auth_request` must either be configured or the external package `aiohttp` must be installed to use the default value." + ) + + @pytest.mark.asyncio + async def test_constructor_raises_incorrect_credentials_error(self): + credentials = Mock() + with pytest.raises(InvalidType) as exc: + sessions.AuthorizedSession(credentials) + + exc.match( + f"The configured credentials of type {type(credentials)} are invalid and must be of type `google.auth.aio.credentials.Credentials`" + ) + + @pytest.mark.asyncio + async def test_request_default_auth_request_success(self): + with aioresponses() as m: + mocked_chunks = [b"Cavefish ", b"have ", b"no ", b"sight."] + mocked_response = b"".join(mocked_chunks) + m.get(self.TEST_URL, status=200, body=mocked_response) + authed_session = sessions.AuthorizedSession(self.credentials) + response = await authed_session.request("GET", self.TEST_URL) + assert response.status_code == 200 + assert response.headers == {"Content-Type": "application/json"} + assert await response.read() == b"Cavefish have no sight." + await response.close() + + await authed_session.close() + + @pytest.mark.asyncio + async def test_request_provided_auth_request_success(self, mocked_content): + mocked_response = MockResponse( + status_code=200, + headers={"Content-Type": "application/json"}, + content=mocked_content, + ) + auth_request = MockRequest(mocked_response) + authed_session = sessions.AuthorizedSession(self.credentials, auth_request) + response = await authed_session.request("GET", self.TEST_URL) + assert response.status_code == 200 + assert response.headers == {"Content-Type": "application/json"} + assert await response.read() == b"Cavefish have no sight." + await response.close() + assert response._close + + await authed_session.close() + + @pytest.mark.asyncio + async def test_request_raises_timeout_error(self): + auth_request = MockRequest(side_effect=asyncio.TimeoutError) + authed_session = sessions.AuthorizedSession(self.credentials, auth_request) + with pytest.raises(TimeoutError): + await authed_session.request("GET", self.TEST_URL) + + @pytest.mark.asyncio + async def test_request_raises_transport_error(self): + auth_request = MockRequest(side_effect=TransportError) + authed_session = sessions.AuthorizedSession(self.credentials, auth_request) + with pytest.raises(TransportError): + await authed_session.request("GET", self.TEST_URL) + + @pytest.mark.asyncio + async def test_request_max_allowed_time_exceeded_error(self): + auth_request = MockRequest(side_effect=TransportError) + authed_session = sessions.AuthorizedSession(self.credentials, auth_request) + with patch("time.monotonic", side_effect=[0, 1, 1]): + with pytest.raises(TimeoutError): + await authed_session.request("GET", self.TEST_URL, max_allowed_time=1) + + @pytest.mark.parametrize("retry_status", DEFAULT_RETRYABLE_STATUS_CODES) + @pytest.mark.asyncio + async def test_request_max_retries(self, retry_status): + mocked_response = MockResponse(status_code=retry_status) + auth_request = MockRequest(mocked_response) + with patch("asyncio.sleep", return_value=None): + authed_session = sessions.AuthorizedSession(self.credentials, auth_request) + await authed_session.request("GET", self.TEST_URL) + assert auth_request.call_count == DEFAULT_MAX_REFRESH_ATTEMPTS + + @pytest.mark.asyncio + async def test_http_get_method_success(self): + expected_payload = b"content is retrieved." + authed_session = sessions.AuthorizedSession(self.credentials) + with aioresponses() as m: + m.get(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.get(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_post_method_success(self): + expected_payload = b"content is posted." + authed_session = sessions.AuthorizedSession(self.credentials) + with aioresponses() as m: + m.post(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.post(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_put_method_success(self): + expected_payload = b"content is retrieved." + authed_session = sessions.AuthorizedSession(self.credentials) + with aioresponses() as m: + m.put(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.put(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_patch_method_success(self): + expected_payload = b"content is retrieved." + authed_session = sessions.AuthorizedSession(self.credentials) + with aioresponses() as m: + m.patch(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.patch(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() + + @pytest.mark.asyncio + async def test_http_delete_method_success(self): + expected_payload = b"content is deleted." + authed_session = sessions.AuthorizedSession(self.credentials) + with aioresponses() as m: + m.delete(self.TEST_URL, status=200, body=expected_payload) + response = await authed_session.delete(self.TEST_URL) + assert await response.read() == expected_payload + response = await authed_session.close() From de6c476014c3e8e0a10441979da9d849973a6b39 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Fri, 23 Aug 2024 18:02:24 +0000 Subject: [PATCH 06/11] fix: avoid leaking api error for closed session --- google/auth/aio/transport/aiohttp.py | 17 +++++++++++++---- tests/transport/aio/test_aiohttp.py | 10 ++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py index 89d3766f9..a5e145e79 100644 --- a/google/auth/aio/transport/aiohttp.py +++ b/google/auth/aio/transport/aiohttp.py @@ -100,7 +100,8 @@ class Request(transport.Request): """ def __init__(self, session: aiohttp.ClientSession = None): - self.session = session or aiohttp.ClientSession() + self._session = session + self._closed = False async def __call__( self, @@ -132,13 +133,19 @@ async def __call__( google.auth.aio.transport.Response: The HTTP response. Raises: - - google.auth.exceptions.TransportError: If the request fails. + - google.auth.exceptions.TransportError: If the request fails or if the session is closed. - google.auth.exceptions.TimeoutError: If the request times out. """ try: + if self._closed: + raise exceptions.TransportError("session is closed.") + + if not self._session: + self._session = aiohttp.ClientSession() + client_timeout = aiohttp.ClientTimeout(total=timeout) - response = await self.session.request( + response = await self._session.request( method, url, data=body, @@ -162,4 +169,6 @@ async def close(self) -> None: """ Close the underlying aiohttp session to release the acquired resources. """ - await self.session.close() + if not self._closed and self._session: + await self._session.close() + self._closed = True diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py index 00b92911c..c4bde4e1b 100644 --- a/tests/transport/aio/test_aiohttp.py +++ b/tests/transport/aio/test_aiohttp.py @@ -121,3 +121,13 @@ async def test_request_call_raises_timeout_error(self, aiohttp_request): await aiohttp_request("http://example.com") exc.match("Request timed out after 180 seconds.") + + async def test_request_call_raises_transport_error_for_closed_session(self, aiohttp_request): + with aioresponses() as m: + m.get("http://example.com", exception=asyncio.TimeoutError) + aiohttp_request._closed = True + with pytest.raises(exceptions.TransportError) as exc: + await aiohttp_request("http://example.com") + + exc.match("session is closed.") + aiohttp_request._closed = False From c8099f017d74da0910eb7777728a36c9c1accca1 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Fri, 23 Aug 2024 19:00:11 +0000 Subject: [PATCH 07/11] add error handling for response --- google/auth/aio/transport/aiohttp.py | 14 ++++++++++--- google/auth/exceptions.py | 4 ++++ tests/transport/aio/test_aiohttp.py | 30 +++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py index a5e145e79..e19cacfa7 100644 --- a/google/auth/aio/transport/aiohttp.py +++ b/google/auth/aio/transport/aiohttp.py @@ -57,12 +57,20 @@ def headers(self) -> Mapping[str, str]: @_helpers.copy_docstring(transport.Response) async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]: - async for chunk in self._response.content.iter_chunked(chunk_size): - yield chunk + try: + async for chunk in self._response.content.iter_chunked(chunk_size): + yield chunk + except aiohttp.ClientPayloadError as exc: + raise exceptions.ResponseError( + "Failed to read from the payload stream." + ) from exc @_helpers.copy_docstring(transport.Response) async def read(self) -> bytes: - return await self._response.read() + try: + return await self._response.read() + except aiohttp.ClientResponseError as exc: + raise exceptions.ResponseError("Failed to read the response body.") from exc @_helpers.copy_docstring(transport.Response) async def close(self): diff --git a/google/auth/exceptions.py b/google/auth/exceptions.py index 707bac88f..feb9f7411 100644 --- a/google/auth/exceptions.py +++ b/google/auth/exceptions.py @@ -102,3 +102,7 @@ class OSError(DefaultCredentialsError, EnvironmentError): class TimeoutError(GoogleAuthError): """Used to indicate a timeout error occurred during an HTTP request.""" + + +class ResponseError(GoogleAuthError): + """Used to indicate an error occurred when reading an HTTP response.""" diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py index c4bde4e1b..6574fd94b 100644 --- a/tests/transport/aio/test_aiohttp.py +++ b/tests/transport/aio/test_aiohttp.py @@ -15,7 +15,7 @@ import asyncio from aioresponses import aioresponses # type: ignore -from mock import AsyncMock, Mock +from mock import AsyncMock, Mock, patch import pytest # type: ignore import pytest_asyncio # type: ignore @@ -62,11 +62,33 @@ async def test_response_content(self, mock_response): content = b"".join([chunk async for chunk in mock_response.content()]) assert content == b"Cavefish have no sight." + @pytest.mark.asyncio + async def test_response_content_raises_error(self, mock_response): + with patch.object( + mock_response._response.content, + "iter_chunked", + side_effect=aiohttp.ClientPayloadError, + ): + with pytest.raises(exceptions.ResponseError) as exc: + [chunk async for chunk in mock_response.content()] + exc.match("Failed to read from the payload stream") + @pytest.mark.asyncio async def test_response_read(self, mock_response): content = await mock_response.read() assert content == b"Cavefish have no sight." + @pytest.mark.asyncio + async def test_response_read_raises_error(self, mock_response): + with patch.object( + mock_response._response, + "read", + side_effect=aiohttp.ClientResponseError(None, None), + ): + with pytest.raises(exceptions.ResponseError) as exc: + await mock_response.read() + exc.match("Failed to read the response body.") + @pytest.mark.asyncio async def test_response_close(self, mock_response): await mock_response.close() @@ -121,8 +143,10 @@ async def test_request_call_raises_timeout_error(self, aiohttp_request): await aiohttp_request("http://example.com") exc.match("Request timed out after 180 seconds.") - - async def test_request_call_raises_transport_error_for_closed_session(self, aiohttp_request): + + async def test_request_call_raises_transport_error_for_closed_session( + self, aiohttp_request + ): with aioresponses() as m: m.get("http://example.com", exception=asyncio.TimeoutError) aiohttp_request._closed = True From d92e18d39ef81d5aac579448e62b1accfb28db82 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Fri, 23 Aug 2024 19:26:20 +0000 Subject: [PATCH 08/11] cleanup default values and add test coverage --- google/auth/aio/transport/__init__.py | 4 ++-- google/auth/aio/transport/aiohttp.py | 2 +- tests/transport/aio/test_aiohttp.py | 13 +++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/google/auth/aio/transport/__init__.py b/google/auth/aio/transport/__init__.py index 7a037ef4f..5c734c249 100644 --- a/google/auth/aio/transport/__init__.py +++ b/google/auth/aio/transport/__init__.py @@ -71,11 +71,11 @@ def headers(self) -> Mapping[str, str]: raise NotImplementedError("headers must be implemented.") @abc.abstractmethod - async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]: + async def content(self, chunk_size: int) -> AsyncGenerator[bytes, None]: """The raw response content. Args: - chunk_size (int): The size of each chunk. Defaults to 1024. + chunk_size (int): The size of each chunk. Yields: AsyncGenerator[bytes, None]: An asynchronous generator yielding diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py index e19cacfa7..a9663f206 100644 --- a/google/auth/aio/transport/aiohttp.py +++ b/google/auth/aio/transport/aiohttp.py @@ -58,7 +58,7 @@ def headers(self) -> Mapping[str, str]: @_helpers.copy_docstring(transport.Response) async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]: try: - async for chunk in self._response.content.iter_chunked(chunk_size): + async for chunk in self._response.content.iter_chunked(chunk_size): # pragma: no branch yield chunk except aiohttp.ClientPayloadError as exc: raise exceptions.ResponseError( diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py index 6574fd94b..271678e5f 100644 --- a/tests/transport/aio/test_aiohttp.py +++ b/tests/transport/aio/test_aiohttp.py @@ -125,6 +125,19 @@ async def test_request_call_success(self, aiohttp_request): assert response.headers == {"Content-Type": "application/json"} content = b"".join([chunk async for chunk in response.content()]) assert content == b"Cavefish have no sight." + + async def test_request_call_success_with_provided_session(self): + mock_session = aiohttp.ClientSession() + request = auth_aiohttp.Request(mock_session) + with aioresponses() as m: + mocked_chunks = [b"Cavefish ", b"have ", b"no ", b"sight."] + mocked_response = b"".join(mocked_chunks) + m.get("http://example.com", status=200, body=mocked_response) + response = await request("http://example.com") + assert response.status_code == 200 + assert response.headers == {"Content-Type": "application/json"} + content = b"".join([chunk async for chunk in response.content()]) + assert content == b"Cavefish have no sight." async def test_request_call_raises_client_error(self, aiohttp_request): with aioresponses() as m: From 8c80aaa5e3f1bbed3ca6f059c25dfaeebb5c44c7 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Fri, 23 Aug 2024 19:28:25 +0000 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/auth/aio/transport/aiohttp.py | 4 +++- tests/transport/aio/test_aiohttp.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py index a9663f206..b9d4529b6 100644 --- a/google/auth/aio/transport/aiohttp.py +++ b/google/auth/aio/transport/aiohttp.py @@ -58,7 +58,9 @@ def headers(self) -> Mapping[str, str]: @_helpers.copy_docstring(transport.Response) async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]: try: - async for chunk in self._response.content.iter_chunked(chunk_size): # pragma: no branch + async for chunk in self._response.content.iter_chunked( + chunk_size + ): # pragma: no branch yield chunk except aiohttp.ClientPayloadError as exc: raise exceptions.ResponseError( diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py index 271678e5f..632abff25 100644 --- a/tests/transport/aio/test_aiohttp.py +++ b/tests/transport/aio/test_aiohttp.py @@ -125,7 +125,7 @@ async def test_request_call_success(self, aiohttp_request): assert response.headers == {"Content-Type": "application/json"} content = b"".join([chunk async for chunk in response.content()]) assert content == b"Cavefish have no sight." - + async def test_request_call_success_with_provided_session(self): mock_session = aiohttp.ClientSession() request = auth_aiohttp.Request(mock_session) From c63a5ace8eac936fc0ab21397a770e8414d5096f Mon Sep 17 00:00:00 2001 From: ohmayr Date: Sat, 24 Aug 2024 18:49:03 -0400 Subject: [PATCH 10/11] cleanup: minor code cleanup (#1589) * chore: Add aiohttp requirements test constraint. (#1566) See https://github.com/googleapis/google-auth-library-python/issues/1565 for more information. * chore(main): release 2.33.0 (#1560) * chore(main): release 2.33.0 * fix: retry token request on retryable status code (#1563) * fix: retry token request on retryable status code * feat(auth): Update get_client_ssl_credentials to support X.509 workload certs (#1558) * feat(auth): Update get_client_ssl_credentials to support X.509 workload certs * feat(auth): Update has_default_client_cert_source * feat(auth): Fix formatting * feat(auth): Fix test__mtls_helper.py * feat(auth): Fix function name in tests * chore: Refresh system test creds. * feat(auth): Fix style * feat(auth): Fix casing * feat(auth): Fix linter issue * feat(auth): Fix coverage issue --------- Co-authored-by: Carl Lundin Co-authored-by: Carl Lundin <108372512+clundin25@users.noreply.github.com> * chore: Update ECP deps. (#1583) * chore(main): release 2.34.0 (#1574) * cleanup: minor code cleanup * fix lint issues --------- Co-authored-by: Carl Lundin <108372512+clundin25@users.noreply.github.com> Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> Co-authored-by: Tom Milligan Co-authored-by: Andy Zhao Co-authored-by: Carl Lundin --- google/auth/aio/transport/__init__.py | 8 ++---- google/auth/aio/transport/aiohttp.py | 10 +++---- google/auth/aio/transport/sessions.py | 6 ++--- tests/transport/aio/test_sessions.py | 38 ++++++++++++++------------- 4 files changed, 30 insertions(+), 32 deletions(-) diff --git a/google/auth/aio/transport/__init__.py b/google/auth/aio/transport/__init__.py index 5c734c249..166a3be50 100644 --- a/google/auth/aio/transport/__init__.py +++ b/google/auth/aio/transport/__init__.py @@ -36,13 +36,9 @@ """Sequence[int]: HTTP status codes indicating a request can be retried. """ -DEFAULT_REFRESH_STATUS_CODES = google.auth.transport.DEFAULT_REFRESH_STATUS_CODES -"""Sequence[int]: Which HTTP status code indicate that credentials should be -refreshed. -""" -DEFAULT_MAX_REFRESH_ATTEMPTS = 3 -"""int: How many times to refresh the credentials and retry a request.""" +DEFAULT_MAX_RETRY_ATTEMPTS = 3 +"""int: How many times to retry a request.""" class Response(metaclass=abc.ABCMeta): diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py index b9d4529b6..074d1491c 100644 --- a/google/auth/aio/transport/aiohttp.py +++ b/google/auth/aio/transport/aiohttp.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Transport adapter for AIOHTTP Requests. +"""Transport adapter for Asynchronous HTTP Requests based on aiohttp. """ import asyncio @@ -32,7 +32,7 @@ class Response(transport.Response): """ - Represents an HTTP response and its data. It is returned by ``google.auth.aio.transport.sessions.AuthorizedSession``. + Represents an HTTP response and its data. It is returned by ``google.auth.aio.transport.sessions.AsyncAuthorizedSession``. Args: response (aiohttp.ClientResponse): An instance of aiohttp.ClientResponse. @@ -83,8 +83,8 @@ class Request(transport.Request): """Asynchronous Requests request adapter. This class is used internally for making requests using aiohttp - in a consistent way. If you use :class:`AuthorizedSession` you do not need - to construct or use this class directly. + in a consistent way. If you use :class:`google.auth.aio.transport.sessions.AsyncAuthorizedSession` + you do not need to construct or use this class directly. This class can be useful if you want to configure a Request callable with a custom ``aiohttp.ClientSession`` in :class:`AuthorizedSession` or if @@ -100,7 +100,7 @@ class Request(transport.Request): # Custom aiohttp Session Example: session = session=aiohttp.ClientSession(auto_decompress=False) request = google.auth.aio.transport.aiohttp.Request(session=session) - auth_sesion = google.auth.aio.transport.sessions.AuthorizedSession(auth_request=request) + auth_sesion = google.auth.aio.transport.sessions.AsyncAuthorizedSession(auth_request=request) Args: session (aiohttp.ClientSession): An instance :class:`aiohttp.ClientSession` used diff --git a/google/auth/aio/transport/sessions.py b/google/auth/aio/transport/sessions.py index 60d33df3e..fea7cbbb2 100644 --- a/google/auth/aio/transport/sessions.py +++ b/google/auth/aio/transport/sessions.py @@ -75,7 +75,7 @@ async def with_timeout(coro): _remaining_time() -class AuthorizedSession: +class AsyncAuthorizedSession: """This is an asynchronous implementation of :class:`google.auth.requests.AuthorizedSession` class. We utilize an instance of a class that implements :class:`google.auth.aio.transport.Request` configured by the caller or otherwise default to `google.auth.aio.transport.aiohttp.Request` if the external aiohttp @@ -89,7 +89,7 @@ class AuthorizedSession: import aiohttp from google.auth.aio.transport import sessions - async with sessions.AuthorizedSession(credentials) as authed_session: + async with sessions.AsyncAuthorizedSession(credentials) as authed_session: response = await authed_session.request( 'GET', 'https://www.googleapis.com/storage/v1/b') @@ -172,7 +172,7 @@ async def request( """ retries = _exponential_backoff.AsyncExponentialBackoff( - total_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS + total_attempts=transport.DEFAULT_MAX_RETRY_ATTEMPTS ) async with timeout_guard(max_allowed_time) as with_timeout: await with_timeout( diff --git a/tests/transport/aio/test_sessions.py b/tests/transport/aio/test_sessions.py index 49567be9e..c91a7c40a 100644 --- a/tests/transport/aio/test_sessions.py +++ b/tests/transport/aio/test_sessions.py @@ -22,7 +22,7 @@ from google.auth.aio.credentials import AnonymousCredentials from google.auth.aio.transport import ( _DEFAULT_TIMEOUT_SECONDS, - DEFAULT_MAX_REFRESH_ATTEMPTS, + DEFAULT_MAX_RETRY_ATTEMPTS, DEFAULT_RETRYABLE_STATUS_CODES, Request, Response, @@ -146,7 +146,7 @@ async def test_timeout_with_async_task_timing_out_before_context( ) -class TestAuthorizedSession(object): +class TestAsyncAuthorizedSession(object): TEST_URL = "http://example.com/" credentials = AnonymousCredentials() @@ -159,14 +159,14 @@ async def mocked_content(self): @pytest.mark.asyncio async def test_constructor_with_default_auth_request(self): with patch("google.auth.aio.transport.sessions.AIOHTTP_INSTALLED", True): - authed_session = sessions.AuthorizedSession(self.credentials) + authed_session = sessions.AsyncAuthorizedSession(self.credentials) assert authed_session._credentials == self.credentials await authed_session.close() @pytest.mark.asyncio async def test_constructor_with_provided_auth_request(self): auth_request = MockRequest() - authed_session = sessions.AuthorizedSession( + authed_session = sessions.AsyncAuthorizedSession( self.credentials, auth_request=auth_request ) @@ -177,7 +177,7 @@ async def test_constructor_with_provided_auth_request(self): async def test_constructor_raises_no_auth_request_error(self): with patch("google.auth.aio.transport.sessions.AIOHTTP_INSTALLED", False): with pytest.raises(TransportError) as exc: - sessions.AuthorizedSession(self.credentials) + sessions.AsyncAuthorizedSession(self.credentials) exc.match( "`auth_request` must either be configured or the external package `aiohttp` must be installed to use the default value." @@ -187,7 +187,7 @@ async def test_constructor_raises_no_auth_request_error(self): async def test_constructor_raises_incorrect_credentials_error(self): credentials = Mock() with pytest.raises(InvalidType) as exc: - sessions.AuthorizedSession(credentials) + sessions.AsyncAuthorizedSession(credentials) exc.match( f"The configured credentials of type {type(credentials)} are invalid and must be of type `google.auth.aio.credentials.Credentials`" @@ -199,7 +199,7 @@ async def test_request_default_auth_request_success(self): mocked_chunks = [b"Cavefish ", b"have ", b"no ", b"sight."] mocked_response = b"".join(mocked_chunks) m.get(self.TEST_URL, status=200, body=mocked_response) - authed_session = sessions.AuthorizedSession(self.credentials) + authed_session = sessions.AsyncAuthorizedSession(self.credentials) response = await authed_session.request("GET", self.TEST_URL) assert response.status_code == 200 assert response.headers == {"Content-Type": "application/json"} @@ -216,7 +216,7 @@ async def test_request_provided_auth_request_success(self, mocked_content): content=mocked_content, ) auth_request = MockRequest(mocked_response) - authed_session = sessions.AuthorizedSession(self.credentials, auth_request) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) response = await authed_session.request("GET", self.TEST_URL) assert response.status_code == 200 assert response.headers == {"Content-Type": "application/json"} @@ -229,21 +229,21 @@ async def test_request_provided_auth_request_success(self, mocked_content): @pytest.mark.asyncio async def test_request_raises_timeout_error(self): auth_request = MockRequest(side_effect=asyncio.TimeoutError) - authed_session = sessions.AuthorizedSession(self.credentials, auth_request) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) with pytest.raises(TimeoutError): await authed_session.request("GET", self.TEST_URL) @pytest.mark.asyncio async def test_request_raises_transport_error(self): auth_request = MockRequest(side_effect=TransportError) - authed_session = sessions.AuthorizedSession(self.credentials, auth_request) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) with pytest.raises(TransportError): await authed_session.request("GET", self.TEST_URL) @pytest.mark.asyncio async def test_request_max_allowed_time_exceeded_error(self): auth_request = MockRequest(side_effect=TransportError) - authed_session = sessions.AuthorizedSession(self.credentials, auth_request) + authed_session = sessions.AsyncAuthorizedSession(self.credentials, auth_request) with patch("time.monotonic", side_effect=[0, 1, 1]): with pytest.raises(TimeoutError): await authed_session.request("GET", self.TEST_URL, max_allowed_time=1) @@ -254,14 +254,16 @@ async def test_request_max_retries(self, retry_status): mocked_response = MockResponse(status_code=retry_status) auth_request = MockRequest(mocked_response) with patch("asyncio.sleep", return_value=None): - authed_session = sessions.AuthorizedSession(self.credentials, auth_request) + authed_session = sessions.AsyncAuthorizedSession( + self.credentials, auth_request + ) await authed_session.request("GET", self.TEST_URL) - assert auth_request.call_count == DEFAULT_MAX_REFRESH_ATTEMPTS + assert auth_request.call_count == DEFAULT_MAX_RETRY_ATTEMPTS @pytest.mark.asyncio async def test_http_get_method_success(self): expected_payload = b"content is retrieved." - authed_session = sessions.AuthorizedSession(self.credentials) + authed_session = sessions.AsyncAuthorizedSession(self.credentials) with aioresponses() as m: m.get(self.TEST_URL, status=200, body=expected_payload) response = await authed_session.get(self.TEST_URL) @@ -271,7 +273,7 @@ async def test_http_get_method_success(self): @pytest.mark.asyncio async def test_http_post_method_success(self): expected_payload = b"content is posted." - authed_session = sessions.AuthorizedSession(self.credentials) + authed_session = sessions.AsyncAuthorizedSession(self.credentials) with aioresponses() as m: m.post(self.TEST_URL, status=200, body=expected_payload) response = await authed_session.post(self.TEST_URL) @@ -281,7 +283,7 @@ async def test_http_post_method_success(self): @pytest.mark.asyncio async def test_http_put_method_success(self): expected_payload = b"content is retrieved." - authed_session = sessions.AuthorizedSession(self.credentials) + authed_session = sessions.AsyncAuthorizedSession(self.credentials) with aioresponses() as m: m.put(self.TEST_URL, status=200, body=expected_payload) response = await authed_session.put(self.TEST_URL) @@ -291,7 +293,7 @@ async def test_http_put_method_success(self): @pytest.mark.asyncio async def test_http_patch_method_success(self): expected_payload = b"content is retrieved." - authed_session = sessions.AuthorizedSession(self.credentials) + authed_session = sessions.AsyncAuthorizedSession(self.credentials) with aioresponses() as m: m.patch(self.TEST_URL, status=200, body=expected_payload) response = await authed_session.patch(self.TEST_URL) @@ -301,7 +303,7 @@ async def test_http_patch_method_success(self): @pytest.mark.asyncio async def test_http_delete_method_success(self): expected_payload = b"content is deleted." - authed_session = sessions.AuthorizedSession(self.credentials) + authed_session = sessions.AsyncAuthorizedSession(self.credentials) with aioresponses() as m: m.delete(self.TEST_URL, status=200, body=expected_payload) response = await authed_session.delete(self.TEST_URL) From ddf454a064d1c1805f848a5d32400c37f0b2780b Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 16 Sep 2024 20:24:48 +0000 Subject: [PATCH 11/11] update secrets from forked repo --- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index c22d40f6adfb4c567bb21756a3be06c4191ec2e1..7f0fd8d86ea09a1a7c44877f3f2bf39ff3509c39 100644 GIT binary patch literal 10324 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