diff options
author | robot-piglet <robot-piglet@yandex-team.com> | 2024-10-06 13:42:43 +0300 |
---|---|---|
committer | robot-piglet <robot-piglet@yandex-team.com> | 2024-10-06 13:52:30 +0300 |
commit | 52aed29f744afda4549ef5d64acd0fa8c2092789 (patch) | |
tree | e40c9abd25653990d13b68936aee518454df424e | |
parent | 813943fcad905eee1235d764be4268dddd07ce64 (diff) | |
download | ydb-52aed29f744afda4549ef5d64acd0fa8c2092789.tar.gz |
Intermediate changes
commit_hash:cc4365f5a0e443b92d87079a9c91e77fea2ddcaf
49 files changed, 3925 insertions, 146 deletions
diff --git a/contrib/python/aioresponses/.dist-info/METADATA b/contrib/python/aioresponses/.dist-info/METADATA new file mode 100644 index 0000000000..54b686eb71 --- /dev/null +++ b/contrib/python/aioresponses/.dist-info/METADATA @@ -0,0 +1,333 @@ +Metadata-Version: 2.1 +Name: aioresponses +Version: 0.7.6 +Summary: Mock out requests made by ClientSession from aiohttp package +Home-page: https://github.com/pnuckowski/aioresponses +Author: Pawel Nuckowski +Author-email: p.nuckowski@gmail.com +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: Operating System :: OS Independent +Classifier: Topic :: Internet :: WWW/HTTP +Classifier: Topic :: Software Development :: Testing +Classifier: Topic :: Software Development :: Testing :: Mocking +Classifier: License :: OSI Approved :: MIT License +Classifier: Natural Language :: English +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +License-File: LICENSE +License-File: AUTHORS +License-File: AUTHORS.rst +Requires-Dist: aiohttp (<4.0.0,>=3.3.0) + +=============================== +aioresponses +=============================== + +.. image:: https://travis-ci.org/pnuckowski/aioresponses.svg?branch=master + :target: https://travis-ci.org/pnuckowski/aioresponses + +.. image:: https://coveralls.io/repos/github/pnuckowski/aioresponses/badge.svg?branch=master + :target: https://coveralls.io/github/pnuckowski/aioresponses?branch=master + +.. image:: https://landscape.io/github/pnuckowski/aioresponses/master/landscape.svg?style=flat + :target: https://landscape.io/github/pnuckowski/aioresponses/master + :alt: Code Health + +.. image:: https://pyup.io/repos/github/pnuckowski/aioresponses/shield.svg + :target: https://pyup.io/repos/github/pnuckowski/aioresponses/ + :alt: Updates + +.. image:: https://img.shields.io/pypi/v/aioresponses.svg + :target: https://pypi.python.org/pypi/aioresponses + +.. image:: https://readthedocs.org/projects/aioresponses/badge/?version=latest + :target: https://aioresponses.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + + +Aioresponses is a helper to mock/fake web requests in python aiohttp package. + +For *requests* module there are a lot of packages that help us with testing (eg. *httpretty*, *responses*, *requests-mock*). + +When it comes to testing asynchronous HTTP requests it is a bit harder (at least at the beginning). +The purpose of this package is to provide an easy way to test asynchronous HTTP requests. + +Installing +---------- + +.. code:: bash + + $ pip install aioresponses + +Supported versions +------------------ +- Python 3.7+ +- aiohttp>=3.3.0,<4.0.0 + +Usage +-------- + +To mock out HTTP request use *aioresponses* as a method decorator or as a context manager. + +Response *status* code, *body*, *payload* (for json response) and *headers* can be mocked. + +Supported HTTP methods: **GET**, **POST**, **PUT**, **PATCH**, **DELETE** and **OPTIONS**. + +.. code:: python + + import aiohttp + import asyncio + from aioresponses import aioresponses + + @aioresponses() + def test_request(mocked): + loop = asyncio.get_event_loop() + mocked.get('http://example.com', status=200, body='test') + session = aiohttp.ClientSession() + resp = loop.run_until_complete(session.get('http://example.com')) + + assert resp.status == 200 + mocked.assert_called_once_with('http://example.com') + + +for convenience use *payload* argument to mock out json response. Example below. + +**as a context manager** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + def test_ctx(): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + with aioresponses() as m: + m.get('http://test.example.com', payload=dict(foo='bar')) + + resp = loop.run_until_complete(session.get('http://test.example.com')) + data = loop.run_until_complete(resp.json()) + + assert dict(foo='bar') == data + m.assert_called_once_with('http://test.example.com') + +**aioresponses allows to mock out any HTTP headers** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + @aioresponses() + def test_http_headers(m): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + m.post( + 'http://example.com', + payload=dict(), + headers=dict(connection='keep-alive'), + ) + + resp = loop.run_until_complete(session.post('http://example.com')) + + # note that we pass 'connection' but get 'Connection' (capitalized) + # under the neath `multidict` is used to work with HTTP headers + assert resp.headers['Connection'] == 'keep-alive' + m.assert_called_once_with('http://example.com', method='POST') + +**allows to register different responses for the same url** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + @aioresponses() + def test_multiple_responses(m): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + m.get('http://example.com', status=500) + m.get('http://example.com', status=200) + + resp1 = loop.run_until_complete(session.get('http://example.com')) + resp2 = loop.run_until_complete(session.get('http://example.com')) + + assert resp1.status == 500 + assert resp2.status == 200 + + +**Repeat response for the same url** + +E.g. for cases you want to test retrying mechanisms + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + @aioresponses() + def test_multiple_responses(m): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + m.get('http://example.com', status=500, repeat=True) + m.get('http://example.com', status=200) # will not take effect + + resp1 = loop.run_until_complete(session.get('http://example.com')) + resp2 = loop.run_until_complete(session.get('http://example.com')) + + assert resp1.status == 500 + assert resp2.status == 500 + + +**match URLs with regular expressions** + +.. code:: python + + import asyncio + import aiohttp + import re + from aioresponses import aioresponses + + @aioresponses() + def test_regexp_example(m): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + pattern = re.compile(r'^http://example\.com/api\?foo=.*$') + m.get(pattern, status=200) + + resp = loop.run_until_complete(session.get('http://example.com/api?foo=bar')) + + assert resp.status == 200 + +**allows to make redirects responses** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + @aioresponses() + def test_redirect_example(m): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + + # absolute urls are supported + m.get( + 'http://example.com/', + headers={'Location': 'http://another.com/'}, + status=307 + ) + + resp = loop.run_until_complete( + session.get('http://example.com/', allow_redirects=True) + ) + assert resp.url == 'http://another.com/' + + # and also relative + m.get( + 'http://example.com/', + headers={'Location': '/test'}, + status=307 + ) + resp = loop.run_until_complete( + session.get('http://example.com/', allow_redirects=True) + ) + assert resp.url == 'http://example.com/test' + +**allows to passthrough to a specified list of servers** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + @aioresponses(passthrough=['http://backend']) + def test_passthrough(m, test_client): + session = aiohttp.ClientSession() + # this will actually perform a request + resp = loop.run_until_complete(session.get('http://backend/api')) + + +**aioresponses allows to throw an exception** + +.. code:: python + + import asyncio + from aiohttp import ClientSession + from aiohttp.http_exceptions import HttpProcessingError + from aioresponses import aioresponses + + @aioresponses() + def test_how_to_throw_an_exception(m, test_client): + loop = asyncio.get_event_loop() + session = ClientSession() + m.get('http://example.com/api', exception=HttpProcessingError('test')) + + # calling + # loop.run_until_complete(session.get('http://example.com/api')) + # will throw an exception. + + +**aioresponses allows to use callbacks to provide dynamic responses** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import CallbackResult, aioresponses + + def callback(url, **kwargs): + return CallbackResult(status=418) + + @aioresponses() + def test_callback(m, test_client): + loop = asyncio.get_event_loop() + session = ClientSession() + m.get('http://example.com', callback=callback) + + resp = loop.run_until_complete(session.get('http://example.com')) + + assert resp.status == 418 + + +**aioresponses can be used in a pytest fixture** + +.. code:: python + + import pytest + from aioresponses import aioresponses + + @pytest.fixture + def mock_aioresponse(): + with aioresponses() as m: + yield m + + +Features +-------- +* Easy to mock out HTTP requests made by *aiohttp.ClientSession* + + +License +------- +* Free software: MIT license + +Credits +------- + +This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. + +.. _Cookiecutter: https://github.com/audreyr/cookiecutter +.. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage + diff --git a/contrib/python/aioresponses/.dist-info/top_level.txt b/contrib/python/aioresponses/.dist-info/top_level.txt new file mode 100644 index 0000000000..46cd566df0 --- /dev/null +++ b/contrib/python/aioresponses/.dist-info/top_level.txt @@ -0,0 +1 @@ +aioresponses diff --git a/contrib/python/aioresponses/AUTHORS b/contrib/python/aioresponses/AUTHORS new file mode 100644 index 0000000000..3854a29412 --- /dev/null +++ b/contrib/python/aioresponses/AUTHORS @@ -0,0 +1,51 @@ +Alan Briolat <alan.briolat@gmail.com> +Aleksei Maslakov <lesha.maslakov@gmail.com> +Alexey Nikitenko <wblxyxolb.khv@mail.ru> +Alexey Sveshnikov <a.sveshnikov@rambler-co.ru> +Alexey Sveshnikov <alexey.sveshnikov@gmail.com> +Allisson Azevedo <allisson@gmail.com> +Andrew Grinevich <andrew.grinevich@pandadoc.com> +Anthony Lukach <anthonylukach@gmail.com> +Ben Greiner <code@bnavigator.de> +Brett Wandel <brett.wandel@interferex.com> +Bryce Drennan <github@accounts.brycedrennan.com> +Colin-b <Colin-b@users.noreply.github.com> +Daniel Hahler <git@thequod.de> +Daniel Tan <danieltanjiawang@gmail.com> +David Buxton <david@gasmark6.com> +Fred Thomsen <fred.thomsen@sciencelogic.com> +Georg Sauthoff <mail@gms.tf> +Gordon Rogers <gordonrogers@skyscanner.net> +Hadrien David <hadrien.david@dialogue.co> +Hadrien David <hadrien@ectobal.com> +Ibrahim <8592115+iamibi@users.noreply.github.com> +Ilaï Deutel <ilai-deutel@users.noreply.github.com> +Jakub Boukal <www.bagr@gmail.com> +Joongi Kim <me@daybreaker.info> +Jordi Soucheiron <jordi@soucheiron.cat> +Jordi Soucheiron <jsoucheiron@users.noreply.github.com> +Joshua Coats <joshu@fearchar.net> +Juan Cruz <juancruzmencia@gmail.com> +Lee Treveil <leetreveil@gmail.com> +Louis Sautier <sautier.louis@gmail.com> +Lukasz Jernas <lukasz.jernas@allegrogroup.com> +Marat Sharafutdinov <decaz89@gmail.com> +Marcin Sulikowski <marcin.k.sulikowski@gmail.com> +Marek Kowalski <kowalski0123@gmail.com> +Pavel Savchenko <asfaltboy@gmail.com> +Pawel Nuckowski <p.nuckowski@gmail.com> +Petr Belskiy <petr.belskiy@gmail.com> +Rémy HUBSCHER <rhubscher@mozilla.com> +Sam Bull <aa6bs0@sambull.org> +TyVik <tyvik8@gmail.com> +Ulrik Johansson <ulrik.johansson@blocket.se> +Ville Skyttä <ville.skytta@iki.fi> +d-ryzhikov <d.ryzhykau@gmail.com> +iamnotaprogrammer <iamnotaprogrammer@yandex.ru> +iamnotaprogrammer <issmirnov@domclick.ru> +konstantin <konstantin.klein@hochfrequenz.de> +oren0e <countx@gmail.com> +pnuckowski <p.nuckowski@gmail.com> +pnuckowski <pnuckowski@users.noreply.github.com> +pyup-bot <github-bot@pyup.io> +vangheem <vangheem@gmail.com> diff --git a/contrib/python/aioresponses/AUTHORS.rst b/contrib/python/aioresponses/AUTHORS.rst new file mode 100644 index 0000000000..3b1fc8e0ec --- /dev/null +++ b/contrib/python/aioresponses/AUTHORS.rst @@ -0,0 +1,13 @@ +======= +Credits +======= + +Development Lead +---------------- + +* Pawel Nuckowski <p.nuckowski@gmail.com> + +Contributors +------------ + +None yet. Why not be the first? diff --git a/contrib/python/aioresponses/LICENSE b/contrib/python/aioresponses/LICENSE new file mode 100644 index 0000000000..fe5490da64 --- /dev/null +++ b/contrib/python/aioresponses/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 pnuckowski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/contrib/python/aioresponses/README.rst b/contrib/python/aioresponses/README.rst new file mode 100644 index 0000000000..ae63650d0a --- /dev/null +++ b/contrib/python/aioresponses/README.rst @@ -0,0 +1,306 @@ +=============================== +aioresponses +=============================== + +.. image:: https://travis-ci.org/pnuckowski/aioresponses.svg?branch=master + :target: https://travis-ci.org/pnuckowski/aioresponses + +.. image:: https://coveralls.io/repos/github/pnuckowski/aioresponses/badge.svg?branch=master + :target: https://coveralls.io/github/pnuckowski/aioresponses?branch=master + +.. image:: https://landscape.io/github/pnuckowski/aioresponses/master/landscape.svg?style=flat + :target: https://landscape.io/github/pnuckowski/aioresponses/master + :alt: Code Health + +.. image:: https://pyup.io/repos/github/pnuckowski/aioresponses/shield.svg + :target: https://pyup.io/repos/github/pnuckowski/aioresponses/ + :alt: Updates + +.. image:: https://img.shields.io/pypi/v/aioresponses.svg + :target: https://pypi.python.org/pypi/aioresponses + +.. image:: https://readthedocs.org/projects/aioresponses/badge/?version=latest + :target: https://aioresponses.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + + +Aioresponses is a helper to mock/fake web requests in python aiohttp package. + +For *requests* module there are a lot of packages that help us with testing (eg. *httpretty*, *responses*, *requests-mock*). + +When it comes to testing asynchronous HTTP requests it is a bit harder (at least at the beginning). +The purpose of this package is to provide an easy way to test asynchronous HTTP requests. + +Installing +---------- + +.. code:: bash + + $ pip install aioresponses + +Supported versions +------------------ +- Python 3.7+ +- aiohttp>=3.3.0,<4.0.0 + +Usage +-------- + +To mock out HTTP request use *aioresponses* as a method decorator or as a context manager. + +Response *status* code, *body*, *payload* (for json response) and *headers* can be mocked. + +Supported HTTP methods: **GET**, **POST**, **PUT**, **PATCH**, **DELETE** and **OPTIONS**. + +.. code:: python + + import aiohttp + import asyncio + from aioresponses import aioresponses + + @aioresponses() + def test_request(mocked): + loop = asyncio.get_event_loop() + mocked.get('http://example.com', status=200, body='test') + session = aiohttp.ClientSession() + resp = loop.run_until_complete(session.get('http://example.com')) + + assert resp.status == 200 + mocked.assert_called_once_with('http://example.com') + + +for convenience use *payload* argument to mock out json response. Example below. + +**as a context manager** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + def test_ctx(): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + with aioresponses() as m: + m.get('http://test.example.com', payload=dict(foo='bar')) + + resp = loop.run_until_complete(session.get('http://test.example.com')) + data = loop.run_until_complete(resp.json()) + + assert dict(foo='bar') == data + m.assert_called_once_with('http://test.example.com') + +**aioresponses allows to mock out any HTTP headers** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + @aioresponses() + def test_http_headers(m): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + m.post( + 'http://example.com', + payload=dict(), + headers=dict(connection='keep-alive'), + ) + + resp = loop.run_until_complete(session.post('http://example.com')) + + # note that we pass 'connection' but get 'Connection' (capitalized) + # under the neath `multidict` is used to work with HTTP headers + assert resp.headers['Connection'] == 'keep-alive' + m.assert_called_once_with('http://example.com', method='POST') + +**allows to register different responses for the same url** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + @aioresponses() + def test_multiple_responses(m): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + m.get('http://example.com', status=500) + m.get('http://example.com', status=200) + + resp1 = loop.run_until_complete(session.get('http://example.com')) + resp2 = loop.run_until_complete(session.get('http://example.com')) + + assert resp1.status == 500 + assert resp2.status == 200 + + +**Repeat response for the same url** + +E.g. for cases you want to test retrying mechanisms + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + @aioresponses() + def test_multiple_responses(m): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + m.get('http://example.com', status=500, repeat=True) + m.get('http://example.com', status=200) # will not take effect + + resp1 = loop.run_until_complete(session.get('http://example.com')) + resp2 = loop.run_until_complete(session.get('http://example.com')) + + assert resp1.status == 500 + assert resp2.status == 500 + + +**match URLs with regular expressions** + +.. code:: python + + import asyncio + import aiohttp + import re + from aioresponses import aioresponses + + @aioresponses() + def test_regexp_example(m): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + pattern = re.compile(r'^http://example\.com/api\?foo=.*$') + m.get(pattern, status=200) + + resp = loop.run_until_complete(session.get('http://example.com/api?foo=bar')) + + assert resp.status == 200 + +**allows to make redirects responses** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + @aioresponses() + def test_redirect_example(m): + loop = asyncio.get_event_loop() + session = aiohttp.ClientSession() + + # absolute urls are supported + m.get( + 'http://example.com/', + headers={'Location': 'http://another.com/'}, + status=307 + ) + + resp = loop.run_until_complete( + session.get('http://example.com/', allow_redirects=True) + ) + assert resp.url == 'http://another.com/' + + # and also relative + m.get( + 'http://example.com/', + headers={'Location': '/test'}, + status=307 + ) + resp = loop.run_until_complete( + session.get('http://example.com/', allow_redirects=True) + ) + assert resp.url == 'http://example.com/test' + +**allows to passthrough to a specified list of servers** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import aioresponses + + @aioresponses(passthrough=['http://backend']) + def test_passthrough(m, test_client): + session = aiohttp.ClientSession() + # this will actually perform a request + resp = loop.run_until_complete(session.get('http://backend/api')) + + +**aioresponses allows to throw an exception** + +.. code:: python + + import asyncio + from aiohttp import ClientSession + from aiohttp.http_exceptions import HttpProcessingError + from aioresponses import aioresponses + + @aioresponses() + def test_how_to_throw_an_exception(m, test_client): + loop = asyncio.get_event_loop() + session = ClientSession() + m.get('http://example.com/api', exception=HttpProcessingError('test')) + + # calling + # loop.run_until_complete(session.get('http://example.com/api')) + # will throw an exception. + + +**aioresponses allows to use callbacks to provide dynamic responses** + +.. code:: python + + import asyncio + import aiohttp + from aioresponses import CallbackResult, aioresponses + + def callback(url, **kwargs): + return CallbackResult(status=418) + + @aioresponses() + def test_callback(m, test_client): + loop = asyncio.get_event_loop() + session = ClientSession() + m.get('http://example.com', callback=callback) + + resp = loop.run_until_complete(session.get('http://example.com')) + + assert resp.status == 418 + + +**aioresponses can be used in a pytest fixture** + +.. code:: python + + import pytest + from aioresponses import aioresponses + + @pytest.fixture + def mock_aioresponse(): + with aioresponses() as m: + yield m + + +Features +-------- +* Easy to mock out HTTP requests made by *aiohttp.ClientSession* + + +License +------- +* Free software: MIT license + +Credits +------- + +This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. + +.. _Cookiecutter: https://github.com/audreyr/cookiecutter +.. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage diff --git a/contrib/python/aioresponses/aioresponses/__init__.py b/contrib/python/aioresponses/aioresponses/__init__.py new file mode 100644 index 0000000000..c61652c9aa --- /dev/null +++ b/contrib/python/aioresponses/aioresponses/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +from .core import CallbackResult, aioresponses + +__version__ = '0.7.3' + +__all__ = [ + 'CallbackResult', + 'aioresponses', +] diff --git a/contrib/python/aioresponses/aioresponses/compat.py b/contrib/python/aioresponses/aioresponses/compat.py new file mode 100644 index 0000000000..aa8771d8d6 --- /dev/null +++ b/contrib/python/aioresponses/aioresponses/compat.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +import asyncio # noqa: F401 +import sys +from typing import Dict, Optional, Union # noqa +from urllib.parse import parse_qsl, urlencode + +from aiohttp import __version__ as aiohttp_version, StreamReader +from aiohttp.client_proto import ResponseHandler +from multidict import MultiDict +from packaging.version import Version +from yarl import URL + +if sys.version_info < (3, 7): + from re import _pattern_type as Pattern +else: + from re import Pattern + +AIOHTTP_VERSION = Version(aiohttp_version) + + +def stream_reader_factory( # noqa + loop: 'Optional[asyncio.AbstractEventLoop]' = None +) -> StreamReader: + protocol = ResponseHandler(loop=loop) + return StreamReader(protocol, limit=2 ** 16, loop=loop) + + +def merge_params( + url: 'Union[URL, str]', + params: Optional[Dict] = None +) -> 'URL': + url = URL(url) + if params: + query_params = MultiDict(url.query) + query_params.extend(url.with_query(params).query) + return url.with_query(query_params) + return url + + +def normalize_url(url: 'Union[URL, str]') -> 'URL': + """Normalize url to make comparisons.""" + url = URL(url) + return url.with_query(urlencode(sorted(parse_qsl(url.query_string)))) + + +try: + from aiohttp import RequestInfo +except ImportError: + class RequestInfo(object): + __slots__ = ('url', 'method', 'headers', 'real_url') + + def __init__( + self, url: URL, method: str, headers: Dict, real_url: str + ): + self.url = url + self.method = method + self.headers = headers + self.real_url = real_url + +__all__ = [ + 'URL', + 'Pattern', + 'RequestInfo', + 'AIOHTTP_VERSION', + 'merge_params', + 'stream_reader_factory', + 'normalize_url', +] diff --git a/contrib/python/aioresponses/aioresponses/core.py b/contrib/python/aioresponses/aioresponses/core.py new file mode 100644 index 0000000000..2bb6d57365 --- /dev/null +++ b/contrib/python/aioresponses/aioresponses/core.py @@ -0,0 +1,549 @@ +# -*- coding: utf-8 -*- +import asyncio +import copy +import inspect +import json +from collections import namedtuple +from functools import wraps +from typing import ( + Any, + Callable, + cast, + Dict, + List, + Optional, + Tuple, + Type, + TypeVar, + Union, +) +from unittest.mock import Mock, patch +from uuid import uuid4 + +from aiohttp import ( + ClientConnectionError, + ClientResponse, + ClientSession, + hdrs, + http +) +from aiohttp.helpers import TimerNoop +from multidict import CIMultiDict, CIMultiDictProxy + +from .compat import ( + URL, + Pattern, + stream_reader_factory, + merge_params, + normalize_url, + RequestInfo, +) + + +_FuncT = TypeVar("_FuncT", bound=Callable[..., Any]) + + +class CallbackResult: + + def __init__(self, method: str = hdrs.METH_GET, + status: int = 200, + body: Union[str, bytes] = '', + content_type: str = 'application/json', + payload: Optional[Dict] = None, + headers: Optional[Dict] = None, + response_class: Optional[Type[ClientResponse]] = None, + reason: Optional[str] = None): + self.method = method + self.status = status + self.body = body + self.content_type = content_type + self.payload = payload + self.headers = headers + self.response_class = response_class + self.reason = reason + + +class RequestMatch(object): + url_or_pattern = None # type: Union[URL, Pattern] + + def __init__(self, url: Union[URL, str, Pattern], + method: str = hdrs.METH_GET, + status: int = 200, + body: Union[str, bytes] = '', + payload: Optional[Dict] = None, + exception: Optional[Exception] = None, + headers: Optional[Dict] = None, + content_type: str = 'application/json', + response_class: Optional[Type[ClientResponse]] = None, + timeout: bool = False, + repeat: bool = False, + reason: Optional[str] = None, + callback: Optional[Callable] = None): + if isinstance(url, Pattern): + self.url_or_pattern = url + self.match_func = self.match_regexp + else: + self.url_or_pattern = normalize_url(url) + self.match_func = self.match_str + self.method = method.lower() + self.status = status + self.body = body + self.payload = payload + self.exception = exception + if timeout: + self.exception = asyncio.TimeoutError('Connection timeout test') + self.headers = headers + self.content_type = content_type + self.response_class = response_class + self.repeat = repeat + self.reason = reason + if self.reason is None: + try: + self.reason = http.RESPONSES[self.status][0] + except (IndexError, KeyError): + self.reason = '' + self.callback = callback + + def match_str(self, url: URL) -> bool: + return self.url_or_pattern == url + + def match_regexp(self, url: URL) -> bool: + # This method is used if and only if self.url_or_pattern is a pattern. + return bool( + self.url_or_pattern.match(str(url)) # type:ignore[union-attr] + ) + + def match(self, method: str, url: URL) -> bool: + if self.method != method.lower(): + return False + return self.match_func(url) + + def _build_raw_headers(self, headers: Dict) -> Tuple: + """ + Convert a dict of headers to a tuple of tuples + + Mimics the format of ClientResponse. + """ + raw_headers = [] + for k, v in headers.items(): + raw_headers.append((k.encode('utf8'), v.encode('utf8'))) + return tuple(raw_headers) + + def _build_response(self, url: 'Union[URL, str]', + method: str = hdrs.METH_GET, + request_headers: Optional[Dict] = None, + status: int = 200, + body: Union[str, bytes] = '', + content_type: str = 'application/json', + payload: Optional[Dict] = None, + headers: Optional[Dict] = None, + response_class: Optional[Type[ClientResponse]] = None, + reason: Optional[str] = None) -> ClientResponse: + if response_class is None: + response_class = ClientResponse + if payload is not None: + body = json.dumps(payload) + if not isinstance(body, bytes): + body = str.encode(body) + if request_headers is None: + request_headers = {} + loop = Mock() + loop.get_debug = Mock() + loop.get_debug.return_value = True + kwargs = {} # type: Dict[str, Any] + kwargs['request_info'] = RequestInfo( + url=url, + method=method, + headers=CIMultiDictProxy(CIMultiDict(**request_headers)), + ) + kwargs['writer'] = None + kwargs['continue100'] = None + kwargs['timer'] = TimerNoop() + kwargs['traces'] = [] + kwargs['loop'] = loop + kwargs['session'] = None + + # We need to initialize headers manually + _headers = CIMultiDict({hdrs.CONTENT_TYPE: content_type}) + if headers: + _headers.update(headers) + raw_headers = self._build_raw_headers(_headers) + resp = response_class(method, url, **kwargs) + + for hdr in _headers.getall(hdrs.SET_COOKIE, ()): + resp.cookies.load(hdr) + + # Reified attributes + resp._headers = _headers + resp._raw_headers = raw_headers + + resp.status = status + resp.reason = reason + resp.content = stream_reader_factory(loop) + resp.content.feed_data(body) + resp.content.feed_eof() + return resp + + async def build_response( + self, url: URL, **kwargs: Any + ) -> 'Union[ClientResponse, Exception]': + if callable(self.callback): + if asyncio.iscoroutinefunction(self.callback): + result = await self.callback(url, **kwargs) + else: + result = self.callback(url, **kwargs) + else: + result = None + + if self.exception is not None: + return self.exception + + result = self if result is None else result + resp = self._build_response( + url=url, + method=result.method, + request_headers=kwargs.get("headers"), + status=result.status, + body=result.body, + content_type=result.content_type, + payload=result.payload, + headers=result.headers, + response_class=result.response_class, + reason=result.reason) + return resp + + +RequestCall = namedtuple('RequestCall', ['args', 'kwargs']) + + +class aioresponses(object): + """Mock aiohttp requests made by ClientSession.""" + _matches = None # type: Dict[str, RequestMatch] + _responses = None # type: List[ClientResponse] + requests = None # type: Dict + + def __init__(self, **kwargs: Any): + self._param = kwargs.pop('param', None) + self._passthrough = kwargs.pop('passthrough', []) + self.patcher = patch('aiohttp.client.ClientSession._request', + side_effect=self._request_mock, + autospec=True) + self.requests = {} + + def __enter__(self) -> 'aioresponses': + self.start() + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + self.stop() + + def __call__(self, f: _FuncT) -> _FuncT: + def _pack_arguments(ctx, *args, **kwargs) -> Tuple[Tuple, Dict]: + if self._param: + kwargs[self._param] = ctx + else: + args += (ctx,) + return args, kwargs + + if asyncio.iscoroutinefunction(f): + @wraps(f) + async def wrapped(*args, **kwargs): + with self as ctx: + args, kwargs = _pack_arguments(ctx, *args, **kwargs) + return await f(*args, **kwargs) + else: + @wraps(f) + def wrapped(*args, **kwargs): + with self as ctx: + args, kwargs = _pack_arguments(ctx, *args, **kwargs) + return f(*args, **kwargs) + return cast(_FuncT, wrapped) + + def clear(self) -> None: + self._responses.clear() + self._matches.clear() + + def start(self) -> None: + self._responses = [] + self._matches = {} + self.patcher.start() + self.patcher.return_value = self._request_mock + + def stop(self) -> None: + for response in self._responses: + response.close() + self.patcher.stop() + self.clear() + + def head(self, url: 'Union[URL, str, Pattern]', **kwargs: Any) -> None: + self.add(url, method=hdrs.METH_HEAD, **kwargs) + + def get(self, url: 'Union[URL, str, Pattern]', **kwargs: Any) -> None: + self.add(url, method=hdrs.METH_GET, **kwargs) + + def post(self, url: 'Union[URL, str, Pattern]', **kwargs: Any) -> None: + self.add(url, method=hdrs.METH_POST, **kwargs) + + def put(self, url: 'Union[URL, str, Pattern]', **kwargs: Any) -> None: + self.add(url, method=hdrs.METH_PUT, **kwargs) + + def patch(self, url: 'Union[URL, str, Pattern]', **kwargs: Any) -> None: + self.add(url, method=hdrs.METH_PATCH, **kwargs) + + def delete(self, url: 'Union[URL, str, Pattern]', **kwargs: Any) -> None: + self.add(url, method=hdrs.METH_DELETE, **kwargs) + + def options(self, url: 'Union[URL, str, Pattern]', **kwargs: Any) -> None: + self.add(url, method=hdrs.METH_OPTIONS, **kwargs) + + def add(self, url: 'Union[URL, str, Pattern]', method: str = hdrs.METH_GET, + status: int = 200, + body: Union[str, bytes] = '', + exception: Optional[Exception] = None, + content_type: str = 'application/json', + payload: Optional[Dict] = None, + headers: Optional[Dict] = None, + response_class: Optional[Type[ClientResponse]] = None, + repeat: bool = False, + timeout: bool = False, + reason: Optional[str] = None, + callback: Optional[Callable] = None) -> None: + + self._matches[str(uuid4())] = (RequestMatch( + url, + method=method, + status=status, + content_type=content_type, + body=body, + exception=exception, + payload=payload, + headers=headers, + response_class=response_class, + repeat=repeat, + timeout=timeout, + reason=reason, + callback=callback, + )) + + def _format_call_signature(self, *args, **kwargs) -> str: + message = '%s(%%s)' % self.__class__.__name__ or 'mock' + formatted_args = '' + args_string = ', '.join([repr(arg) for arg in args]) + kwargs_string = ', '.join([ + '%s=%r' % (key, value) for key, value in kwargs.items() + ]) + if args_string: + formatted_args = args_string + if kwargs_string: + if formatted_args: + formatted_args += ', ' + formatted_args += kwargs_string + + return message % formatted_args + + def assert_not_called(self): + """assert that the mock was never called. + """ + if len(self.requests) != 0: + msg = ("Expected '%s' to not have been called. Called %s times." + % (self.__class__.__name__, + len(self._responses))) + raise AssertionError(msg) + + def assert_called(self): + """assert that the mock was called at least once. + """ + if len(self.requests) == 0: + msg = ("Expected '%s' to have been called." + % (self.__class__.__name__,)) + raise AssertionError(msg) + + def assert_called_once(self): + """assert that the mock was called only once. + """ + call_count = len(self.requests) + if call_count == 1: + call_count = len(list(self.requests.values())[0]) + if not call_count == 1: + msg = ("Expected '%s' to have been called once. Called %s times." + % (self.__class__.__name__, + call_count)) + + raise AssertionError(msg) + + def assert_called_with(self, url: 'Union[URL, str, Pattern]', + method: str = hdrs.METH_GET, + *args: Any, + **kwargs: Any): + """assert that the last call was made with the specified arguments. + + Raises an AssertionError if the args and keyword args passed in are + different to the last call to the mock.""" + url = normalize_url(merge_params(url, kwargs.get('params'))) + method = method.upper() + key = (method, url) + try: + expected = self.requests[key][-1] + except KeyError: + expected_string = self._format_call_signature( + url, method=method, *args, **kwargs + ) + raise AssertionError( + '%s call not found' % expected_string + ) + actual = self._build_request_call(method, *args, **kwargs) + if not expected == actual: + expected_string = self._format_call_signature( + expected, + ) + actual_string = self._format_call_signature( + actual + ) + raise AssertionError( + '%s != %s' % (expected_string, actual_string) + ) + + def assert_any_call(self, url: 'Union[URL, str, Pattern]', + method: str = hdrs.METH_GET, + *args: Any, + **kwargs: Any): + """assert the mock has been called with the specified arguments. + The assert passes if the mock has *ever* been called, unlike + `assert_called_with` and `assert_called_once_with` that only pass if + the call is the most recent one.""" + url = normalize_url(merge_params(url, kwargs.get('params'))) + method = method.upper() + key = (method, url) + + try: + self.requests[key] + except KeyError: + expected_string = self._format_call_signature( + url, method=method, *args, **kwargs + ) + raise AssertionError( + '%s call not found' % expected_string + ) + + def assert_called_once_with(self, *args: Any, **kwargs: Any): + """assert that the mock was called once with the specified arguments. + Raises an AssertionError if the args and keyword args passed in are + different to the only call to the mock.""" + self.assert_called_once() + self.assert_called_with(*args, **kwargs) + + @staticmethod + def is_exception(resp_or_exc: Union[ClientResponse, Exception]) -> bool: + if inspect.isclass(resp_or_exc): + parent_classes = set(inspect.getmro(resp_or_exc)) + if {Exception, BaseException} & parent_classes: + return True + else: + if isinstance(resp_or_exc, (Exception, BaseException)): + return True + return False + + async def match( + self, method: str, + url: URL, + allow_redirects: bool = True, + **kwargs: Any + ) -> Optional['ClientResponse']: + history = [] + while True: + for key, matcher in self._matches.items(): + if matcher.match(method, url): + response_or_exc = await matcher.build_response( + url, allow_redirects=allow_redirects, **kwargs + ) + break + else: + return None + + if matcher.repeat is False: + del self._matches[key] + + if self.is_exception(response_or_exc): + raise response_or_exc + # If response_or_exc was an exception, it would have been raised. + # At this point we can be sure it's a ClientResponse + response: ClientResponse + response = response_or_exc # type:ignore[assignment] + is_redirect = response.status in (301, 302, 303, 307, 308) + if is_redirect and allow_redirects: + if hdrs.LOCATION not in response.headers: + break + history.append(response) + redirect_url = URL(response.headers[hdrs.LOCATION]) + if redirect_url.is_absolute(): + url = redirect_url + else: + url = url.join(redirect_url) + method = 'get' + continue + else: + break + + response._history = tuple(history) + return response + + async def _request_mock(self, orig_self: ClientSession, + method: str, url: 'Union[URL, str]', + *args: Tuple, + **kwargs: Any) -> 'ClientResponse': + """Return mocked response object or raise connection error.""" + if orig_self.closed: + raise RuntimeError('Session is closed') + + url_origin = url + url = normalize_url(merge_params(url, kwargs.get('params'))) + url_str = str(url) + for prefix in self._passthrough: + if url_str.startswith(prefix): + return (await self.patcher.temp_original( + orig_self, method, url_origin, *args, **kwargs + )) + + key = (method, url) + self.requests.setdefault(key, []) + request_call = self._build_request_call(method, *args, **kwargs) + self.requests[key].append(request_call) + + response = await self.match(method, url, **kwargs) + + if response is None: + raise ClientConnectionError( + 'Connection refused: {} {}'.format(method, url) + ) + self._responses.append(response) + + # Automatically call response.raise_for_status() on a request if the + # request was initialized with raise_for_status=True. Also call + # response.raise_for_status() if the client session was initialized + # with raise_for_status=True, unless the request was called with + # raise_for_status=False. + raise_for_status = kwargs.get('raise_for_status') + if raise_for_status is None: + raise_for_status = getattr( + orig_self, '_raise_for_status', False + ) + if raise_for_status: + response.raise_for_status() + + return response + + def _build_request_call(self, method: str = hdrs.METH_GET, + *args: Any, + allow_redirects: bool = True, + **kwargs: Any): + """Return request call.""" + kwargs.setdefault('allow_redirects', allow_redirects) + if method == 'POST': + kwargs.setdefault('data', None) + + try: + kwargs_copy = copy.deepcopy(kwargs) + except (TypeError, ValueError): + # Handle the fact that some values cannot be deep copied + kwargs_copy = kwargs + return RequestCall(args, kwargs_copy) diff --git a/contrib/python/aioresponses/aioresponses/py.typed b/contrib/python/aioresponses/aioresponses/py.typed new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/aioresponses/aioresponses/py.typed diff --git a/contrib/python/aioresponses/ya.make b/contrib/python/aioresponses/ya.make new file mode 100644 index 0000000000..574b5f85f1 --- /dev/null +++ b/contrib/python/aioresponses/ya.make @@ -0,0 +1,33 @@ +# Generated by devtools/yamaker (pypi). + +PY3_LIBRARY() + +VERSION(0.7.6) + +LICENSE(MIT) + +PEERDIR( + contrib/python/aiohttp +) + +NO_LINT() + +PY_SRCS( + TOP_LEVEL + aioresponses/__init__.py + aioresponses/compat.py + aioresponses/core.py +) + +RESOURCE_FILES( + PREFIX contrib/python/aioresponses/ + .dist-info/METADATA + .dist-info/top_level.txt + aioresponses/py.typed +) + +END() + +RECURSE_FOR_TESTS( + tests +) diff --git a/contrib/python/google-auth/py3/.dist-info/METADATA b/contrib/python/google-auth/py3/.dist-info/METADATA index 26b8a4974a..261e2a0276 100644 --- a/contrib/python/google-auth/py3/.dist-info/METADATA +++ b/contrib/python/google-auth/py3/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: google-auth -Version: 2.34.0 +Version: 2.35.0 Summary: Google Authentication Library Home-page: https://github.com/googleapis/google-auth-library-python Author: Google Cloud Platform diff --git a/contrib/python/google-auth/py3/google/auth/_default.py b/contrib/python/google-auth/py3/google/auth/_default.py index 63009dfb86..7bbcf85914 100644 --- a/contrib/python/google-auth/py3/google/auth/_default.py +++ b/contrib/python/google-auth/py3/google/auth/_default.py @@ -237,6 +237,7 @@ def _get_gcloud_sdk_credentials(quota_project_id=None): credentials, project_id = load_credentials_from_file( credentials_filename, quota_project_id=quota_project_id ) + credentials._cred_file_path = credentials_filename if not project_id: project_id = _cloud_sdk.get_project_id() @@ -270,6 +271,7 @@ def _get_explicit_environ_credentials(quota_project_id=None): credentials, project_id = load_credentials_from_file( os.environ[environment_vars.CREDENTIALS], quota_project_id=quota_project_id ) + credentials._cred_file_path = f"{explicit_file} file via the GOOGLE_APPLICATION_CREDENTIALS environment variable" return credentials, project_id diff --git a/contrib/python/google-auth/py3/google/auth/_exponential_backoff.py b/contrib/python/google-auth/py3/google/auth/_exponential_backoff.py index 04f9f97641..89853448f9 100644 --- a/contrib/python/google-auth/py3/google/auth/_exponential_backoff.py +++ b/contrib/python/google-auth/py3/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 @@ an HTTP request. """ -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 @@ class ExponentialBackoff: 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 @@ class ExponentialBackoff: 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/contrib/python/google-auth/py3/google/auth/aio/transport/__init__.py b/contrib/python/google-auth/py3/google/auth/aio/transport/__init__.py new file mode 100644 index 0000000000..166a3be509 --- /dev/null +++ b/contrib/python/google-auth/py3/google/auth/aio/transport/__init__.py @@ -0,0 +1,144 @@ +# 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 - 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 +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`. +""" + +import abc +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_MAX_RETRY_ATTEMPTS = 3 +"""int: How many times to retry a request.""" + + +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) -> Mapping[str, str]: + """The HTTP response headers. + + Returns: + Mapping[str, str]: The HTTP response headers. + """ + raise NotImplementedError("headers must be implemented.") + + @abc.abstractmethod + async def content(self, chunk_size: int) -> AsyncGenerator[bytes, None]: + """The raw response content. + + Args: + chunk_size (int): The size of each chunk. + + 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.") + + +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: Optional[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 (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 + 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/contrib/python/google-auth/py3/google/auth/aio/transport/aiohttp.py b/contrib/python/google-auth/py3/google/auth/aio/transport/aiohttp.py new file mode 100644 index 0000000000..074d1491c7 --- /dev/null +++ b/contrib/python/google-auth/py3/google/auth/aio/transport/aiohttp.py @@ -0,0 +1,184 @@ +# 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 based on aiohttp. +""" + +import asyncio +from typing import AsyncGenerator, Mapping, Optional + +try: + 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 + +from google.auth import _helpers +from google.auth import exceptions +from google.auth.aio import transport + + +class Response(transport.Response): + """ + 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. + + Attributes: + status_code (int): The HTTP status code of the response. + headers (Mapping[str, str]): The HTTP headers of the 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) -> Mapping[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]: + try: + async for chunk in self._response.content.iter_chunked( + chunk_size + ): # pragma: no branch + 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: + 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): + 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:`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 + 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.AsyncAuthorizedSession(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 + self._closed = False + + async def __call__( + self, + url: str, + method: str = "GET", + body: Optional[bytes] = None, + headers: Optional[Mapping[str, str]] = None, + timeout: float = transport._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 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( + method, + url, + data=body, + headers=headers, + timeout=client_timeout, + **kwargs, + ) + return Response(response) + + except aiohttp.ClientError as 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: + timeout_exc = exceptions.TimeoutError( + f"Request timed out after {timeout} seconds." + ) + raise timeout_exc from caught_exc + + async def close(self) -> None: + """ + Close the underlying aiohttp session to release the acquired resources. + """ + if not self._closed and self._session: + await self._session.close() + self._closed = True diff --git a/contrib/python/google-auth/py3/google/auth/aio/transport/sessions.py b/contrib/python/google-auth/py3/google/auth/aio/transport/sessions.py new file mode 100644 index 0000000000..fea7cbbb2c --- /dev/null +++ b/contrib/python/google-auth/py3/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 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 + 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.AsyncAuthorizedSession(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_RETRY_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/contrib/python/google-auth/py3/google/auth/compute_engine/credentials.py b/contrib/python/google-auth/py3/google/auth/compute_engine/credentials.py index 008b991bb9..f0126c0a80 100644 --- a/contrib/python/google-auth/py3/google/auth/compute_engine/credentials.py +++ b/contrib/python/google-auth/py3/google/auth/compute_engine/credentials.py @@ -157,6 +157,14 @@ class Credentials( self._universe_domain_cached = True return self._universe_domain + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + return { + "credential_source": "metadata server", + "credential_type": "VM credentials", + "principal": self.service_account_email, + } + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): creds = self.__class__( diff --git a/contrib/python/google-auth/py3/google/auth/credentials.py b/contrib/python/google-auth/py3/google/auth/credentials.py index e31930311b..2c67e04432 100644 --- a/contrib/python/google-auth/py3/google/auth/credentials.py +++ b/contrib/python/google-auth/py3/google/auth/credentials.py @@ -128,6 +128,17 @@ class Credentials(_BaseCredentials): """The universe domain value.""" return self._universe_domain + def get_cred_info(self): + """The credential information JSON. + + The credential information will be added to auth related error messages + by client library. + + Returns: + Mapping[str, str]: The credential information JSON. + """ + return None + @abc.abstractmethod def refresh(self, request): """Refreshes the access token. diff --git a/contrib/python/google-auth/py3/google/auth/exceptions.py b/contrib/python/google-auth/py3/google/auth/exceptions.py index fcbe61b746..feb9f7411e 100644 --- a/contrib/python/google-auth/py3/google/auth/exceptions.py +++ b/contrib/python/google-auth/py3/google/auth/exceptions.py @@ -98,3 +98,11 @@ 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.""" + + +class ResponseError(GoogleAuthError): + """Used to indicate an error occurred when reading an HTTP response.""" diff --git a/contrib/python/google-auth/py3/google/auth/external_account.py b/contrib/python/google-auth/py3/google/auth/external_account.py index df0511f255..161e6c50ce 100644 --- a/contrib/python/google-auth/py3/google/auth/external_account.py +++ b/contrib/python/google-auth/py3/google/auth/external_account.py @@ -186,6 +186,7 @@ class Credentials( self._supplier_context = SupplierContext( self._subject_token_type, self._audience ) + self._cred_file_path = None if not self.is_workforce_pool and self._workforce_pool_user_project: # Workload identity pools do not support workforce pool user projects. @@ -321,11 +322,24 @@ class Credentials( return self._token_info_url + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + cred_info_json = { + "credential_source": self._cred_file_path, + "credential_type": "external account credentials", + } + if self.service_account_email: + cred_info_json["principal"] = self.service_account_email + return cred_info_json + return None + @_helpers.copy_docstring(credentials.Scoped) def with_scopes(self, scopes, default_scopes=None): kwargs = self._constructor_args() kwargs.update(scopes=scopes, default_scopes=default_scopes) scoped = self.__class__(**kwargs) + scoped._cred_file_path = self._cred_file_path scoped._metrics_options = self._metrics_options return scoped @@ -442,30 +456,31 @@ class Credentials( self.expiry = now + lifetime - @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) - def with_quota_project(self, quota_project_id): - # Return copy of instance with the provided quota project ID. + def _make_copy(self): kwargs = self._constructor_args() - kwargs.update(quota_project_id=quota_project_id) new_cred = self.__class__(**kwargs) + new_cred._cred_file_path = self._cred_file_path new_cred._metrics_options = self._metrics_options return new_cred + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + # Return copy of instance with the provided quota project ID. + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred + @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) def with_token_uri(self, token_uri): - kwargs = self._constructor_args() - kwargs.update(token_url=token_uri) - new_cred = self.__class__(**kwargs) - new_cred._metrics_options = self._metrics_options - return new_cred + cred = self._make_copy() + cred._token_url = token_uri + return cred @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) def with_universe_domain(self, universe_domain): - kwargs = self._constructor_args() - kwargs.update(universe_domain=universe_domain) - new_cred = self.__class__(**kwargs) - new_cred._metrics_options = self._metrics_options - return new_cred + cred = self._make_copy() + cred._universe_domain = universe_domain + return cred def _should_initialize_impersonated_credentials(self): return ( diff --git a/contrib/python/google-auth/py3/google/auth/external_account_authorized_user.py b/contrib/python/google-auth/py3/google/auth/external_account_authorized_user.py index f73387172c..4d0c3c6806 100644 --- a/contrib/python/google-auth/py3/google/auth/external_account_authorized_user.py +++ b/contrib/python/google-auth/py3/google/auth/external_account_authorized_user.py @@ -120,6 +120,7 @@ class Credentials( self._quota_project_id = quota_project_id self._scopes = scopes self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN + self._cred_file_path = None if not self.valid and not self.can_refresh: raise exceptions.InvalidOperation( @@ -290,23 +291,38 @@ class Credentials( def _make_sts_request(self, request): return self._sts_client.refresh_token(request, self._refresh_token) + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + return { + "credential_source": self._cred_file_path, + "credential_type": "external account authorized user credentials", + } + return None + + def _make_copy(self): + kwargs = self.constructor_args() + cred = self.__class__(**kwargs) + cred._cred_file_path = self._cred_file_path + return cred + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): - kwargs = self.constructor_args() - kwargs.update(quota_project_id=quota_project_id) - return self.__class__(**kwargs) + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) def with_token_uri(self, token_uri): - kwargs = self.constructor_args() - kwargs.update(token_url=token_uri) - return self.__class__(**kwargs) + cred = self._make_copy() + cred._token_url = token_uri + return cred @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) def with_universe_domain(self, universe_domain): - kwargs = self.constructor_args() - kwargs.update(universe_domain=universe_domain) - return self.__class__(**kwargs) + cred = self._make_copy() + cred._universe_domain = universe_domain + return cred @classmethod def from_info(cls, info, **kwargs): diff --git a/contrib/python/google-auth/py3/google/auth/impersonated_credentials.py b/contrib/python/google-auth/py3/google/auth/impersonated_credentials.py index 3c6f8712a9..c42a936433 100644 --- a/contrib/python/google-auth/py3/google/auth/impersonated_credentials.py +++ b/contrib/python/google-auth/py3/google/auth/impersonated_credentials.py @@ -226,6 +226,7 @@ class Credentials( self.expiry = _helpers.utcnow() self._quota_project_id = quota_project_id self._iam_endpoint_override = iam_endpoint_override + self._cred_file_path = None def _metric_header_for_usage(self): return metrics.CRED_TYPE_SA_IMPERSONATE @@ -316,29 +317,40 @@ class Credentials( def requires_scopes(self): return not self._target_scopes - @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) - def with_quota_project(self, quota_project_id): - return self.__class__( + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + return { + "credential_source": self._cred_file_path, + "credential_type": "impersonated credentials", + "principal": self._target_principal, + } + return None + + def _make_copy(self): + cred = self.__class__( self._source_credentials, target_principal=self._target_principal, target_scopes=self._target_scopes, delegates=self._delegates, lifetime=self._lifetime, - quota_project_id=quota_project_id, + quota_project_id=self._quota_project_id, iam_endpoint_override=self._iam_endpoint_override, ) + cred._cred_file_path = self._cred_file_path + return cred + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred @_helpers.copy_docstring(credentials.Scoped) def with_scopes(self, scopes, default_scopes=None): - return self.__class__( - self._source_credentials, - target_principal=self._target_principal, - target_scopes=scopes or default_scopes, - delegates=self._delegates, - lifetime=self._lifetime, - quota_project_id=self._quota_project_id, - iam_endpoint_override=self._iam_endpoint_override, - ) + cred = self._make_copy() + cred._target_scopes = scopes or default_scopes + return cred class IDTokenCredentials(credentials.CredentialsWithQuotaProject): diff --git a/contrib/python/google-auth/py3/google/auth/version.py b/contrib/python/google-auth/py3/google/auth/version.py index 297e18a45f..6610120c69 100644 --- a/contrib/python/google-auth/py3/google/auth/version.py +++ b/contrib/python/google-auth/py3/google/auth/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.34.0" +__version__ = "2.35.0" diff --git a/contrib/python/google-auth/py3/google/oauth2/credentials.py b/contrib/python/google-auth/py3/google/oauth2/credentials.py index 5ca00d4c5a..6e158089f3 100644 --- a/contrib/python/google-auth/py3/google/oauth2/credentials.py +++ b/contrib/python/google-auth/py3/google/oauth2/credentials.py @@ -50,6 +50,9 @@ _LOGGER = logging.getLogger(__name__) # The Google OAuth 2.0 token endpoint. Used for authorized user credentials. _GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" +# The Google OAuth 2.0 token info endpoint. Used for getting token info JSON from access tokens. +_GOOGLE_OAUTH2_TOKEN_INFO_ENDPOINT = "https://oauth2.googleapis.com/tokeninfo" + class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject): """Credentials using OAuth 2.0 access and refresh tokens. @@ -151,6 +154,7 @@ class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaPr self._trust_boundary = trust_boundary self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN self._account = account or "" + self._cred_file_path = None def __getstate__(self): """A __getstate__ method must exist for the __setstate__ to be called @@ -189,6 +193,7 @@ class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaPr self._universe_domain = ( d.get("_universe_domain") or credentials.DEFAULT_UNIVERSE_DOMAIN ) + self._cred_file_path = d.get("_cred_file_path") # The refresh_handler setter should be used to repopulate this. self._refresh_handler = None self._refresh_worker = None @@ -278,10 +283,8 @@ class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaPr """str: The user account associated with the credential. If the account is unknown an empty string is returned.""" return self._account - @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) - def with_quota_project(self, quota_project_id): - - return self.__class__( + def _make_copy(self): + cred = self.__class__( self.token, refresh_token=self.refresh_token, id_token=self.id_token, @@ -291,34 +294,39 @@ class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaPr scopes=self.scopes, default_scopes=self.default_scopes, granted_scopes=self.granted_scopes, - quota_project_id=quota_project_id, + quota_project_id=self.quota_project_id, rapt_token=self.rapt_token, enable_reauth_refresh=self._enable_reauth_refresh, trust_boundary=self._trust_boundary, universe_domain=self._universe_domain, account=self._account, ) + cred._cred_file_path = self._cred_file_path + return cred + + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + cred_info = { + "credential_source": self._cred_file_path, + "credential_type": "user credentials", + } + if self.account: + cred_info["principal"] = self.account + return cred_info + return None + + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) + def with_quota_project(self, quota_project_id): + cred = self._make_copy() + cred._quota_project_id = quota_project_id + return cred @_helpers.copy_docstring(credentials.CredentialsWithTokenUri) def with_token_uri(self, token_uri): - - return self.__class__( - self.token, - refresh_token=self.refresh_token, - id_token=self.id_token, - token_uri=token_uri, - client_id=self.client_id, - client_secret=self.client_secret, - scopes=self.scopes, - default_scopes=self.default_scopes, - granted_scopes=self.granted_scopes, - quota_project_id=self.quota_project_id, - rapt_token=self.rapt_token, - enable_reauth_refresh=self._enable_reauth_refresh, - trust_boundary=self._trust_boundary, - universe_domain=self._universe_domain, - account=self._account, - ) + cred = self._make_copy() + cred._token_uri = token_uri + return cred def with_account(self, account): """Returns a copy of these credentials with a modified account. @@ -329,45 +337,15 @@ class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaPr Returns: google.oauth2.credentials.Credentials: A new credentials instance. """ - - return self.__class__( - self.token, - refresh_token=self.refresh_token, - id_token=self.id_token, - token_uri=self._token_uri, - client_id=self.client_id, - client_secret=self.client_secret, - scopes=self.scopes, - default_scopes=self.default_scopes, - granted_scopes=self.granted_scopes, - quota_project_id=self.quota_project_id, - rapt_token=self.rapt_token, - enable_reauth_refresh=self._enable_reauth_refresh, - trust_boundary=self._trust_boundary, - universe_domain=self._universe_domain, - account=account, - ) + cred = self._make_copy() + cred._account = account + return cred @_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain) def with_universe_domain(self, universe_domain): - - return self.__class__( - self.token, - refresh_token=self.refresh_token, - id_token=self.id_token, - token_uri=self._token_uri, - client_id=self.client_id, - client_secret=self.client_secret, - scopes=self.scopes, - default_scopes=self.default_scopes, - granted_scopes=self.granted_scopes, - quota_project_id=self.quota_project_id, - rapt_token=self.rapt_token, - enable_reauth_refresh=self._enable_reauth_refresh, - trust_boundary=self._trust_boundary, - universe_domain=universe_domain, - account=self._account, - ) + cred = self._make_copy() + cred._universe_domain = universe_domain + return cred def _metric_header_for_usage(self): return metrics.CRED_TYPE_USER diff --git a/contrib/python/google-auth/py3/google/oauth2/service_account.py b/contrib/python/google-auth/py3/google/oauth2/service_account.py index 0e12868f14..98dafa3e38 100644 --- a/contrib/python/google-auth/py3/google/oauth2/service_account.py +++ b/contrib/python/google-auth/py3/google/oauth2/service_account.py @@ -173,6 +173,7 @@ class Credentials( """ super(Credentials, self).__init__() + self._cred_file_path = None self._scopes = scopes self._default_scopes = default_scopes self._signer = signer @@ -220,7 +221,7 @@ class Credentials( "universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN ), trust_boundary=info.get("trust_boundary"), - **kwargs + **kwargs, ) @classmethod @@ -294,6 +295,7 @@ class Credentials( always_use_jwt_access=self._always_use_jwt_access, universe_domain=self._universe_domain, ) + cred._cred_file_path = self._cred_file_path return cred @_helpers.copy_docstring(credentials.Scoped) @@ -503,6 +505,16 @@ class Credentials( def signer_email(self): return self._service_account_email + @_helpers.copy_docstring(credentials.Credentials) + def get_cred_info(self): + if self._cred_file_path: + return { + "credential_source": self._cred_file_path, + "credential_type": "service account credentials", + "principal": self.service_account_email, + } + return None + class IDTokenCredentials( credentials.Signing, diff --git a/contrib/python/google-auth/py3/tests/compute_engine/test_credentials.py b/contrib/python/google-auth/py3/tests/compute_engine/test_credentials.py index bb29f8c6e2..662210fa41 100644 --- a/contrib/python/google-auth/py3/tests/compute_engine/test_credentials.py +++ b/contrib/python/google-auth/py3/tests/compute_engine/test_credentials.py @@ -72,6 +72,13 @@ class TestCredentials(object): universe_domain=FAKE_UNIVERSE_DOMAIN, ) + def test_get_cred_info(self): + assert self.credentials.get_cred_info() == { + "credential_source": "metadata server", + "credential_type": "VM credentials", + "principal": "default", + } + def test_default_state(self): assert not self.credentials.valid # Expiration hasn't been set yet diff --git a/contrib/python/google-auth/py3/tests/oauth2/test_credentials.py b/contrib/python/google-auth/py3/tests/oauth2/test_credentials.py index 67b6b9c1ad..a4cac7a463 100644 --- a/contrib/python/google-auth/py3/tests/oauth2/test_credentials.py +++ b/contrib/python/google-auth/py3/tests/oauth2/test_credentials.py @@ -72,6 +72,34 @@ class TestCredentials(object): assert credentials.rapt_token == self.RAPT_TOKEN assert credentials.refresh_handler is None + def test_get_cred_info(self): + credentials = self.make_credentials() + credentials._account = "fake-account" + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "user credentials", + "principal": "fake-account", + } + + def test_get_cred_info_no_account(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "user credentials", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + def test_token_usage_metrics(self): credentials = self.make_credentials() credentials.token = "token" diff --git a/contrib/python/google-auth/py3/tests/oauth2/test_service_account.py b/contrib/python/google-auth/py3/tests/oauth2/test_service_account.py index 0dbe316a0f..fe02e828e7 100644 --- a/contrib/python/google-auth/py3/tests/oauth2/test_service_account.py +++ b/contrib/python/google-auth/py3/tests/oauth2/test_service_account.py @@ -69,6 +69,23 @@ class TestCredentials(object): universe_domain=universe_domain, ) + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "service account credentials", + "principal": "service-account@example.com", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + def test_constructor_no_universe_domain(self): credentials = service_account.Credentials( SIGNER, self.SERVICE_ACCOUNT_EMAIL, self.TOKEN_URI, universe_domain=None diff --git a/contrib/python/google-auth/py3/tests/test__default.py b/contrib/python/google-auth/py3/tests/test__default.py index aaf892f6d0..3147d505da 100644 --- a/contrib/python/google-auth/py3/tests/test__default.py +++ b/contrib/python/google-auth/py3/tests/test__default.py @@ -884,6 +884,38 @@ def test_default_early_out(unused_get): @mock.patch( + "google.auth._default.load_credentials_from_file", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_cred_file_path_env_var(unused_load_cred, monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, "/path/to/file") + cred, _ = _default.default() + assert ( + cred._cred_file_path + == "/path/to/file file via the GOOGLE_APPLICATION_CREDENTIALS environment variable" + ) + + +@mock.patch("os.path.isfile", return_value=True, autospec=True) +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", + return_value="/path/to/adc/file", + autospec=True, +) +@mock.patch( + "google.auth._default.load_credentials_from_file", + return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), + autospec=True, +) +def test_default_cred_file_path_gcloud( + unused_load_cred, unused_get_adc_file, unused_isfile +): + cred, _ = _default.default() + assert cred._cred_file_path == "/path/to/adc/file" + + +@mock.patch( "google.auth._default._get_explicit_environ_credentials", return_value=(MOCK_CREDENTIALS, mock.sentinel.project_id), autospec=True, diff --git a/contrib/python/google-auth/py3/tests/test__exponential_backoff.py b/contrib/python/google-auth/py3/tests/test__exponential_backoff.py index 95422502b0..b7b6877b2c 100644 --- a/contrib/python/google-auth/py3/tests/test__exponential_backoff.py +++ b/contrib/python/google-auth/py3/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/contrib/python/google-auth/py3/tests/test_credentials.py b/contrib/python/google-auth/py3/tests/test_credentials.py index 8e6bbc9633..e11bcb4e55 100644 --- a/contrib/python/google-auth/py3/tests/test_credentials.py +++ b/contrib/python/google-auth/py3/tests/test_credentials.py @@ -52,6 +52,11 @@ def test_credentials_constructor(): assert not credentials._use_non_blocking_refresh +def test_credentials_get_cred_info(): + credentials = CredentialsImpl() + assert not credentials.get_cred_info() + + def test_with_non_blocking_refresh(): c = CredentialsImpl() c.with_non_blocking_refresh() diff --git a/contrib/python/google-auth/py3/tests/test_external_account.py b/contrib/python/google-auth/py3/tests/test_external_account.py index 3c372e6291..bddcb4afa1 100644 --- a/contrib/python/google-auth/py3/tests/test_external_account.py +++ b/contrib/python/google-auth/py3/tests/test_external_account.py @@ -275,6 +275,31 @@ class TestCredentials(object): assert request_kwargs["headers"] == headers assert "body" not in request_kwargs + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "external account credentials", + } + + credentials._service_account_impersonation_url = ( + self.SERVICE_ACCOUNT_IMPERSONATION_URL + ) + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "external account credentials", + "principal": SERVICE_ACCOUNT_EMAIL, + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + def test_default_state(self): credentials = self.make_credentials( service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL @@ -469,25 +494,29 @@ class TestCredentials(object): with mock.patch.object( external_account.Credentials, "__init__", return_value=None ) as mock_init: - credentials.with_quota_project("project-foo") + new_cred = credentials.with_quota_project("project-foo") - # Confirm with_quota_project initialized the credential with the - # expected parameters and quota project ID. - mock_init.assert_called_once_with( - audience=self.AUDIENCE, - subject_token_type=self.SUBJECT_TOKEN_TYPE, - token_url=self.TOKEN_URL, - token_info_url=self.TOKEN_INFO_URL, - credential_source=self.CREDENTIAL_SOURCE, - service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, - service_account_impersonation_options={"token_lifetime_seconds": 2800}, - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - quota_project_id="project-foo", - scopes=self.SCOPES, - default_scopes=["default1"], - universe_domain=DEFAULT_UNIVERSE_DOMAIN, - ) + # Confirm with_quota_project initialized the credential with the + # expected parameters. + mock_init.assert_called_once_with( + audience=self.AUDIENCE, + subject_token_type=self.SUBJECT_TOKEN_TYPE, + token_url=self.TOKEN_URL, + token_info_url=self.TOKEN_INFO_URL, + credential_source=self.CREDENTIAL_SOURCE, + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + quota_project_id=self.QUOTA_PROJECT_ID, + scopes=self.SCOPES, + default_scopes=["default1"], + universe_domain=DEFAULT_UNIVERSE_DOMAIN, + ) + + # Confirm with_quota_project sets the correct quota project after + # initialization. + assert new_cred.quota_project_id == "project-foo" def test_info(self): credentials = self.make_credentials(universe_domain="dummy_universe.com") diff --git a/contrib/python/google-auth/py3/tests/test_external_account_authorized_user.py b/contrib/python/google-auth/py3/tests/test_external_account_authorized_user.py index 743ee9c848..93926a1314 100644 --- a/contrib/python/google-auth/py3/tests/test_external_account_authorized_user.py +++ b/contrib/python/google-auth/py3/tests/test_external_account_authorized_user.py @@ -83,6 +83,22 @@ class TestCredentials(object): return request + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "external account authorized user credentials", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + def test_default_state(self): creds = self.make_credentials() diff --git a/contrib/python/google-auth/py3/tests/test_impersonated_credentials.py b/contrib/python/google-auth/py3/tests/test_impersonated_credentials.py index 7295bba429..4fb68103a8 100644 --- a/contrib/python/google-auth/py3/tests/test_impersonated_credentials.py +++ b/contrib/python/google-auth/py3/tests/test_impersonated_credentials.py @@ -136,6 +136,23 @@ class TestImpersonatedCredentials(object): iam_endpoint_override=iam_endpoint_override, ) + def test_get_cred_info(self): + credentials = self.make_credentials() + assert not credentials.get_cred_info() + + credentials._cred_file_path = "/path/to/file" + assert credentials.get_cred_info() == { + "credential_source": "/path/to/file", + "credential_type": "impersonated credentials", + "principal": "impersonated@project.iam.gserviceaccount.com", + } + + def test__make_copy_get_cred_info(self): + credentials = self.make_credentials() + credentials._cred_file_path = "/path/to/file" + cred_copy = credentials._make_copy() + assert cred_copy._cred_file_path == "/path/to/file" + def test_make_from_user_credentials(self): credentials = self.make_credentials( source_credentials=self.USER_SOURCE_CREDENTIALS diff --git a/contrib/python/google-auth/py3/tests/transport/aio/test_aiohttp.py b/contrib/python/google-auth/py3/tests/transport/aio/test_aiohttp.py new file mode 100644 index 0000000000..632abff25a --- /dev/null +++ b/contrib/python/google-auth/py3/tests/transport/aio/test_aiohttp.py @@ -0,0 +1,170 @@ +# 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 aioresponses import aioresponses # type: ignore +from mock import AsyncMock, Mock, patch +import pytest # type: ignore +import pytest_asyncio # type: ignore + +from google.auth import exceptions +import google.auth.aio.transport.aiohttp as auth_aiohttp + + +try: + 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 +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_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() + 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." + + +@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_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: + 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.") + + 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 diff --git a/contrib/python/google-auth/py3/tests/transport/aio/test_sessions.py b/contrib/python/google-auth/py3/tests/transport/aio/test_sessions.py new file mode 100644 index 0000000000..c91a7c40ae --- /dev/null +++ b/contrib/python/google-auth/py3/tests/transport/aio/test_sessions.py @@ -0,0 +1,311 @@ +# 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_RETRY_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 TestAsyncAuthorizedSession(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.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.AsyncAuthorizedSession( + 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.AsyncAuthorizedSession(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.AsyncAuthorizedSession(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.AsyncAuthorizedSession(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.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"} + 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.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.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.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) + + @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.AsyncAuthorizedSession( + self.credentials, auth_request + ) + await authed_session.request("GET", self.TEST_URL) + 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.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) + 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.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) + 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.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) + 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.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) + 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.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) + assert await response.read() == expected_payload + response = await authed_session.close() diff --git a/contrib/python/google-auth/py3/tests/ya.make b/contrib/python/google-auth/py3/tests/ya.make index 6c6db898c4..23e821bb9a 100644 --- a/contrib/python/google-auth/py3/tests/ya.make +++ b/contrib/python/google-auth/py3/tests/ya.make @@ -9,6 +9,8 @@ PEERDIR( contrib/python/pytest-localserver contrib/python/oauth2client contrib/python/freezegun + contrib/python/aioresponses + contrib/python/pytest-asyncio ) DATA( @@ -22,16 +24,16 @@ PY_SRCS( ) TEST_SRCS( - __init__.py compute_engine/__init__.py - compute_engine/test__metadata.py compute_engine/test_credentials.py + compute_engine/test__metadata.py conftest.py crypt/__init__.py crypt/test__cryptography_rsa.py - crypt/test__python_rsa.py crypt/test_crypt.py crypt/test_es256.py + crypt/test__python_rsa.py + __init__.py oauth2/__init__.py oauth2/test__client.py # oauth2/test_challenges.py - need pyu2f @@ -42,35 +44,38 @@ TEST_SRCS( oauth2/test_service_account.py oauth2/test_sts.py oauth2/test_utils.py - oauth2/test_webauthn_handler.py oauth2/test_webauthn_handler_factory.py + oauth2/test_webauthn_handler.py oauth2/test_webauthn_types.py - test__cloud_sdk.py - test__default.py - test__exponential_backoff.py - test__helpers.py - test__oauth2client.py - test__refresh_worker.py - test__service_account_info.py test_api_key.py test_app_engine.py test_aws.py + test__cloud_sdk.py + test_credentials_async.py test_credentials.py + test__default.py test_downscoped.py test_exceptions.py - test_external_account.py + test__exponential_backoff.py test_external_account_authorized_user.py + test_external_account.py + test__helpers.py test_iam.py test_identity_pool.py test_impersonated_credentials.py test_jwt.py test_metrics.py + test__oauth2client.py test_packaging.py test_pluggable.py + test__refresh_worker.py + test__service_account_info.py + transport/aio/test_aiohttp.py + # transport/aio/test_sessions.py # transport/test__custom_tls_signer.py + transport/test_grpc.py transport/test__http_client.py transport/test__mtls_helper.py - transport/test_grpc.py transport/test_mtls.py # transport/test_requests.py # transport/test_urllib3.py diff --git a/contrib/python/google-auth/py3/ya.make b/contrib/python/google-auth/py3/ya.make index 4f5c4e4ad8..60146f91a4 100644 --- a/contrib/python/google-auth/py3/ya.make +++ b/contrib/python/google-auth/py3/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(2.34.0) +VERSION(2.35.0) LICENSE(Apache-2.0) @@ -20,6 +20,7 @@ NO_LINT() NO_CHECK_IMPORTS( google.auth._oauth2client + google.auth.aio.transport.aiohttp google.auth.transport._aiohttp_requests ) @@ -39,6 +40,9 @@ PY_SRCS( google/auth/_service_account_info.py google/auth/aio/__init__.py google/auth/aio/credentials.py + google/auth/aio/transport/__init__.py + google/auth/aio/transport/aiohttp.py + google/auth/aio/transport/sessions.py google/auth/api_key.py google/auth/app_engine.py google/auth/aws.py diff --git a/contrib/python/pytest-asyncio/.dist-info/METADATA b/contrib/python/pytest-asyncio/.dist-info/METADATA new file mode 100644 index 0000000000..c73b027ba5 --- /dev/null +++ b/contrib/python/pytest-asyncio/.dist-info/METADATA @@ -0,0 +1,91 @@ +Metadata-Version: 2.1 +Name: pytest-asyncio +Version: 0.21.1 +Summary: Pytest support for asyncio +Home-page: https://github.com/pytest-dev/pytest-asyncio +Author: Tin Tvrtković <tinchester@gmail.com> +Author-email: tinchester@gmail.com +License: Apache 2.0 +Project-URL: Documentation, https://pytest-asyncio.readthedocs.io +Project-URL: Changelog, https://pytest-asyncio.readthedocs.io/en/latest/reference/changelog.html +Project-URL: Source Code, https://github.com/pytest-dev/pytest-asyncio +Project-URL: Bug Tracker, https://github.com/pytest-dev/pytest-asyncio/issues +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Topic :: Software Development :: Testing +Classifier: Framework :: AsyncIO +Classifier: Framework :: Pytest +Classifier: Typing :: Typed +Requires-Python: >=3.7 +Description-Content-Type: text/x-rst +License-File: LICENSE +Requires-Dist: pytest (>=7.0.0) +Requires-Dist: typing-extensions (>=3.7.2) ; python_version < "3.8" +Provides-Extra: docs +Requires-Dist: sphinx (>=5.3) ; extra == 'docs' +Requires-Dist: sphinx-rtd-theme (>=1.0) ; extra == 'docs' +Provides-Extra: testing +Requires-Dist: coverage (>=6.2) ; extra == 'testing' +Requires-Dist: hypothesis (>=5.7.1) ; extra == 'testing' +Requires-Dist: flaky (>=3.5.0) ; extra == 'testing' +Requires-Dist: mypy (>=0.931) ; extra == 'testing' +Requires-Dist: pytest-trio (>=0.7.0) ; extra == 'testing' + +pytest-asyncio +============== + +.. image:: https://img.shields.io/pypi/v/pytest-asyncio.svg + :target: https://pypi.python.org/pypi/pytest-asyncio +.. image:: https://github.com/pytest-dev/pytest-asyncio/workflows/CI/badge.svg + :target: https://github.com/pytest-dev/pytest-asyncio/actions?workflow=CI +.. image:: https://codecov.io/gh/pytest-dev/pytest-asyncio/branch/main/graph/badge.svg + :target: https://codecov.io/gh/pytest-dev/pytest-asyncio +.. image:: https://img.shields.io/pypi/pyversions/pytest-asyncio.svg + :target: https://github.com/pytest-dev/pytest-asyncio + :alt: Supported Python versions +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/ambv/black + +`pytest-asyncio <https://pytest-asyncio.readthedocs.io/en/latest/>`_ is a `pytest <https://docs.pytest.org/en/latest/contents.html>`_ plugin. It facilitates testing of code that uses the `asyncio <https://docs.python.org/3/library/asyncio.html>`_ library. + +Specifically, pytest-asyncio provides support for coroutines as test functions. This allows users to *await* code inside their tests. For example, the following code is executed as a test item by pytest: + +.. code-block:: python + + @pytest.mark.asyncio + async def test_some_asyncio_code(): + res = await library.do_something() + assert b"expected result" == res + +More details can be found in the `documentation <https://pytest-asyncio.readthedocs.io/en/latest/>`_. + +Note that test classes subclassing the standard `unittest <https://docs.python.org/3/library/unittest.html>`__ library are not supported. Users +are advised to use `unittest.IsolatedAsyncioTestCase <https://docs.python.org/3/library/unittest.html#unittest.IsolatedAsyncioTestCase>`__ +or an async framework such as `asynctest <https://asynctest.readthedocs.io/en/latest>`__. + + +pytest-asyncio is available under the `Apache License 2.0 <https://github.com/pytest-dev/pytest-asyncio/blob/main/LICENSE>`_. + + +Installation +------------ + +To install pytest-asyncio, simply: + +.. code-block:: bash + + $ pip install pytest-asyncio + +This is enough for pytest to pick up pytest-asyncio. + + +Contributing +------------ +Contributions are very welcome. Tests can be run with ``tox``, please ensure +the coverage at least stays the same before you submit a pull request. diff --git a/contrib/python/pytest-asyncio/.dist-info/entry_points.txt b/contrib/python/pytest-asyncio/.dist-info/entry_points.txt new file mode 100644 index 0000000000..88db714dad --- /dev/null +++ b/contrib/python/pytest-asyncio/.dist-info/entry_points.txt @@ -0,0 +1,2 @@ +[pytest11] +asyncio = pytest_asyncio.plugin diff --git a/contrib/python/pytest-asyncio/.dist-info/top_level.txt b/contrib/python/pytest-asyncio/.dist-info/top_level.txt new file mode 100644 index 0000000000..08d05d1ecf --- /dev/null +++ b/contrib/python/pytest-asyncio/.dist-info/top_level.txt @@ -0,0 +1 @@ +pytest_asyncio diff --git a/contrib/python/pytest-asyncio/LICENSE b/contrib/python/pytest-asyncio/LICENSE new file mode 100644 index 0000000000..5c304d1a4a --- /dev/null +++ b/contrib/python/pytest-asyncio/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + 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. diff --git a/contrib/python/pytest-asyncio/README.rst b/contrib/python/pytest-asyncio/README.rst new file mode 100644 index 0000000000..0682b74430 --- /dev/null +++ b/contrib/python/pytest-asyncio/README.rst @@ -0,0 +1,52 @@ +pytest-asyncio +============== + +.. image:: https://img.shields.io/pypi/v/pytest-asyncio.svg + :target: https://pypi.python.org/pypi/pytest-asyncio +.. image:: https://github.com/pytest-dev/pytest-asyncio/workflows/CI/badge.svg + :target: https://github.com/pytest-dev/pytest-asyncio/actions?workflow=CI +.. image:: https://codecov.io/gh/pytest-dev/pytest-asyncio/branch/main/graph/badge.svg + :target: https://codecov.io/gh/pytest-dev/pytest-asyncio +.. image:: https://img.shields.io/pypi/pyversions/pytest-asyncio.svg + :target: https://github.com/pytest-dev/pytest-asyncio + :alt: Supported Python versions +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/ambv/black + +`pytest-asyncio <https://pytest-asyncio.readthedocs.io/en/latest/>`_ is a `pytest <https://docs.pytest.org/en/latest/contents.html>`_ plugin. It facilitates testing of code that uses the `asyncio <https://docs.python.org/3/library/asyncio.html>`_ library. + +Specifically, pytest-asyncio provides support for coroutines as test functions. This allows users to *await* code inside their tests. For example, the following code is executed as a test item by pytest: + +.. code-block:: python + + @pytest.mark.asyncio + async def test_some_asyncio_code(): + res = await library.do_something() + assert b"expected result" == res + +More details can be found in the `documentation <https://pytest-asyncio.readthedocs.io/en/latest/>`_. + +Note that test classes subclassing the standard `unittest <https://docs.python.org/3/library/unittest.html>`__ library are not supported. Users +are advised to use `unittest.IsolatedAsyncioTestCase <https://docs.python.org/3/library/unittest.html#unittest.IsolatedAsyncioTestCase>`__ +or an async framework such as `asynctest <https://asynctest.readthedocs.io/en/latest>`__. + + +pytest-asyncio is available under the `Apache License 2.0 <https://github.com/pytest-dev/pytest-asyncio/blob/main/LICENSE>`_. + + +Installation +------------ + +To install pytest-asyncio, simply: + +.. code-block:: bash + + $ pip install pytest-asyncio + +This is enough for pytest to pick up pytest-asyncio. + + +Contributing +------------ +Contributions are very welcome. Tests can be run with ``tox``, please ensure +the coverage at least stays the same before you submit a pull request. diff --git a/contrib/python/pytest-asyncio/pytest_asyncio/__init__.py b/contrib/python/pytest-asyncio/pytest_asyncio/__init__.py new file mode 100644 index 0000000000..1bc2811d93 --- /dev/null +++ b/contrib/python/pytest-asyncio/pytest_asyncio/__init__.py @@ -0,0 +1,5 @@ +"""The main point for importing pytest-asyncio items.""" +from ._version import version as __version__ # noqa +from .plugin import fixture + +__all__ = ("fixture",) diff --git a/contrib/python/pytest-asyncio/pytest_asyncio/_version.py b/contrib/python/pytest-asyncio/pytest_asyncio/_version.py new file mode 100644 index 0000000000..11f23015fb --- /dev/null +++ b/contrib/python/pytest-asyncio/pytest_asyncio/_version.py @@ -0,0 +1,4 @@ +# file generated by setuptools_scm +# don't change, don't track in version control +__version__ = version = '0.21.1' +__version_tuple__ = version_tuple = (0, 21, 1) diff --git a/contrib/python/pytest-asyncio/pytest_asyncio/plugin.py b/contrib/python/pytest-asyncio/pytest_asyncio/plugin.py new file mode 100644 index 0000000000..db93b851de --- /dev/null +++ b/contrib/python/pytest-asyncio/pytest_asyncio/plugin.py @@ -0,0 +1,624 @@ +"""pytest-asyncio implementation.""" +import asyncio +import contextlib +import enum +import functools +import inspect +import socket +import sys +import warnings +from textwrap import dedent +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Dict, + Iterable, + Iterator, + List, + Optional, + Set, + TypeVar, + Union, + cast, + overload, +) + +import pytest +from pytest import ( + Config, + FixtureRequest, + Function, + Item, + Parser, + PytestPluginManager, + Session, +) + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +_R = TypeVar("_R") + +_ScopeName = Literal["session", "package", "module", "class", "function"] +_T = TypeVar("_T") + +SimpleFixtureFunction = TypeVar( + "SimpleFixtureFunction", bound=Callable[..., Awaitable[_R]] +) +FactoryFixtureFunction = TypeVar( + "FactoryFixtureFunction", bound=Callable[..., AsyncIterator[_R]] +) +FixtureFunction = Union[SimpleFixtureFunction, FactoryFixtureFunction] +FixtureFunctionMarker = Callable[[FixtureFunction], FixtureFunction] + +# https://github.com/pytest-dev/pytest/pull/9510 +FixtureDef = Any +SubRequest = Any + + +class Mode(str, enum.Enum): + AUTO = "auto" + STRICT = "strict" + + +ASYNCIO_MODE_HELP = """\ +'auto' - for automatically handling all async functions by the plugin +'strict' - for autoprocessing disabling (useful if different async frameworks \ +should be tested together, e.g. \ +both pytest-asyncio and pytest-trio are used in the same project) +""" + + +def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None: + group = parser.getgroup("asyncio") + group.addoption( + "--asyncio-mode", + dest="asyncio_mode", + default=None, + metavar="MODE", + help=ASYNCIO_MODE_HELP, + ) + parser.addini( + "asyncio_mode", + help="default value for --asyncio-mode", + default="auto", + ) + + +@overload +def fixture( + fixture_function: FixtureFunction, + *, + scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., + params: Optional[Iterable[object]] = ..., + autouse: bool = ..., + ids: Union[ + Iterable[Union[str, float, int, bool, None]], + Callable[[Any], Optional[object]], + None, + ] = ..., + name: Optional[str] = ..., +) -> FixtureFunction: + ... + + +@overload +def fixture( + fixture_function: None = ..., + *, + scope: "Union[_ScopeName, Callable[[str, Config], _ScopeName]]" = ..., + params: Optional[Iterable[object]] = ..., + autouse: bool = ..., + ids: Union[ + Iterable[Union[str, float, int, bool, None]], + Callable[[Any], Optional[object]], + None, + ] = ..., + name: Optional[str] = None, +) -> FixtureFunctionMarker: + ... + + +def fixture( + fixture_function: Optional[FixtureFunction] = None, **kwargs: Any +) -> Union[FixtureFunction, FixtureFunctionMarker]: + if fixture_function is not None: + _make_asyncio_fixture_function(fixture_function) + return pytest.fixture(fixture_function, **kwargs) + + else: + + @functools.wraps(fixture) + def inner(fixture_function: FixtureFunction) -> FixtureFunction: + return fixture(fixture_function, **kwargs) + + return inner + + +def _is_asyncio_fixture_function(obj: Any) -> bool: + obj = getattr(obj, "__func__", obj) # instance method maybe? + return getattr(obj, "_force_asyncio_fixture", False) + + +def _make_asyncio_fixture_function(obj: Any) -> None: + if hasattr(obj, "__func__"): + # instance method, check the function object + obj = obj.__func__ + obj._force_asyncio_fixture = True + + +def _is_coroutine(obj: Any) -> bool: + """Check to see if an object is really an asyncio coroutine.""" + return asyncio.iscoroutinefunction(obj) + + +def _is_coroutine_or_asyncgen(obj: Any) -> bool: + return _is_coroutine(obj) or inspect.isasyncgenfunction(obj) + + +def _get_asyncio_mode(config: Config) -> Mode: + val = config.getoption("asyncio_mode") + if val is None: + val = config.getini("asyncio_mode") + try: + return Mode(val) + except ValueError: + modes = ", ".join(m.value for m in Mode) + raise pytest.UsageError( + f"{val!r} is not a valid asyncio_mode. Valid modes: {modes}." + ) + + +def pytest_configure(config: Config) -> None: + """Inject documentation.""" + config.addinivalue_line( + "markers", + "asyncio: " + "mark the test as a coroutine, it will be " + "run using an asyncio event loop", + ) + + +@pytest.hookimpl(tryfirst=True) +def pytest_report_header(config: Config) -> List[str]: + """Add asyncio config to pytest header.""" + mode = _get_asyncio_mode(config) + return [f"asyncio: mode={mode}"] + + +def _preprocess_async_fixtures( + config: Config, + processed_fixturedefs: Set[FixtureDef], +) -> None: + asyncio_mode = _get_asyncio_mode(config) + fixturemanager = config.pluginmanager.get_plugin("funcmanage") + for fixtures in fixturemanager._arg2fixturedefs.values(): + for fixturedef in fixtures: + func = fixturedef.func + if fixturedef in processed_fixturedefs or not _is_coroutine_or_asyncgen( + func + ): + continue + if not _is_asyncio_fixture_function(func) and asyncio_mode == Mode.STRICT: + # Ignore async fixtures without explicit asyncio mark in strict mode + # This applies to pytest_trio fixtures, for example + continue + _make_asyncio_fixture_function(func) + _inject_fixture_argnames(fixturedef) + _synchronize_async_fixture(fixturedef) + assert _is_asyncio_fixture_function(fixturedef.func) + processed_fixturedefs.add(fixturedef) + + +def _inject_fixture_argnames(fixturedef: FixtureDef) -> None: + """ + Ensures that `request` and `event_loop` are arguments of the specified fixture. + """ + to_add = [] + for name in ("request", "event_loop"): + if name not in fixturedef.argnames: + to_add.append(name) + if to_add: + fixturedef.argnames += tuple(to_add) + + +def _synchronize_async_fixture(fixturedef: FixtureDef) -> None: + """ + Wraps the fixture function of an async fixture in a synchronous function. + """ + if inspect.isasyncgenfunction(fixturedef.func): + _wrap_asyncgen_fixture(fixturedef) + elif inspect.iscoroutinefunction(fixturedef.func): + _wrap_async_fixture(fixturedef) + + +def _add_kwargs( + func: Callable[..., Any], + kwargs: Dict[str, Any], + event_loop: asyncio.AbstractEventLoop, + request: SubRequest, +) -> Dict[str, Any]: + sig = inspect.signature(func) + ret = kwargs.copy() + if "request" in sig.parameters: + ret["request"] = request + if "event_loop" in sig.parameters: + ret["event_loop"] = event_loop + return ret + + +def _perhaps_rebind_fixture_func( + func: _T, instance: Optional[Any], unittest: bool +) -> _T: + if instance is not None: + # The fixture needs to be bound to the actual request.instance + # so it is bound to the same object as the test method. + unbound, cls = func, None + try: + unbound, cls = func.__func__, type(func.__self__) # type: ignore + except AttributeError: + pass + # If unittest is true, the fixture is bound unconditionally. + # otherwise, only if the fixture was bound before to an instance of + # the same type. + if unittest or (cls is not None and isinstance(instance, cls)): + func = unbound.__get__(instance) # type: ignore + return func + + +def _wrap_asyncgen_fixture(fixturedef: FixtureDef) -> None: + fixture = fixturedef.func + + @functools.wraps(fixture) + def _asyncgen_fixture_wrapper( + event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any + ): + func = _perhaps_rebind_fixture_func( + fixture, request.instance, fixturedef.unittest + ) + gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request)) + + async def setup(): + res = await gen_obj.__anext__() + return res + + def finalizer() -> None: + """Yield again, to finalize.""" + + async def async_finalizer() -> None: + try: + await gen_obj.__anext__() + except StopAsyncIteration: + pass + else: + msg = "Async generator fixture didn't stop." + msg += "Yield only once." + raise ValueError(msg) + + event_loop.run_until_complete(async_finalizer()) + + result = event_loop.run_until_complete(setup()) + request.addfinalizer(finalizer) + return result + + fixturedef.func = _asyncgen_fixture_wrapper + + +def _wrap_async_fixture(fixturedef: FixtureDef) -> None: + fixture = fixturedef.func + + @functools.wraps(fixture) + def _async_fixture_wrapper( + event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any + ): + func = _perhaps_rebind_fixture_func( + fixture, request.instance, fixturedef.unittest + ) + + async def setup(): + res = await func(**_add_kwargs(func, kwargs, event_loop, request)) + return res + + return event_loop.run_until_complete(setup()) + + fixturedef.func = _async_fixture_wrapper + + +_HOLDER: Set[FixtureDef] = set() + + +@pytest.hookimpl(tryfirst=True) +def pytest_pycollect_makeitem( + collector: Union[pytest.Module, pytest.Class], name: str, obj: object +) -> Union[ + pytest.Item, pytest.Collector, List[Union[pytest.Item, pytest.Collector]], None +]: + """A pytest hook to collect asyncio coroutines.""" + if not collector.funcnamefilter(name): + return None + _preprocess_async_fixtures(collector.config, _HOLDER) + return None + + +def pytest_collection_modifyitems( + session: Session, config: Config, items: List[Item] +) -> None: + """ + Marks collected async test items as `asyncio` tests. + + The mark is only applied in `AUTO` mode. It is applied to: + + - coroutines + - staticmethods wrapping coroutines + - Hypothesis tests wrapping coroutines + + """ + if _get_asyncio_mode(config) != Mode.AUTO: + return + function_items = (item for item in items if isinstance(item, Function)) + for function_item in function_items: + function = function_item.obj + if isinstance(function, staticmethod): + # staticmethods need to be unwrapped. + function = function.__func__ + if ( + _is_coroutine(function) + or _is_hypothesis_test(function) + and _hypothesis_test_wraps_coroutine(function) + ): + function_item.add_marker("asyncio") + + +def _hypothesis_test_wraps_coroutine(function: Any) -> bool: + return _is_coroutine(function.hypothesis.inner_test) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_fixture_setup( + fixturedef: FixtureDef, request: SubRequest +) -> Optional[object]: + """Adjust the event loop policy when an event loop is produced.""" + if fixturedef.argname == "event_loop": + # The use of a fixture finalizer is preferred over the + # pytest_fixture_post_finalizer hook. The fixture finalizer is invoked once + # for each fixture, whereas the hook may be invoked multiple times for + # any specific fixture. + # see https://github.com/pytest-dev/pytest/issues/5848 + _add_finalizers( + fixturedef, + _close_event_loop, + _provide_clean_event_loop, + ) + outcome = yield + loop = outcome.get_result() + policy = asyncio.get_event_loop_policy() + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + old_loop = policy.get_event_loop() + if old_loop is not loop: + old_loop.close() + except RuntimeError: + # Either the current event loop has been set to None + # or the loop policy doesn't specify to create new loops + # or we're not in the main thread + pass + policy.set_event_loop(loop) + return + + yield + + +def _add_finalizers(fixturedef: FixtureDef, *finalizers: Callable[[], object]) -> None: + """ + Regsiters the specified fixture finalizers in the fixture. + + Finalizers need to specified in the exact order in which they should be invoked. + + :param fixturedef: Fixture definition which finalizers should be added to + :param finalizers: Finalizers to be added + """ + for finalizer in reversed(finalizers): + fixturedef.addfinalizer(finalizer) + + +_UNCLOSED_EVENT_LOOP_WARNING = dedent( + """\ + pytest-asyncio detected an unclosed event loop when tearing down the event_loop + fixture: %r + pytest-asyncio will close the event loop for you, but future versions of the + library will no longer do so. In order to ensure compatibility with future + versions, please make sure that: + 1. Any custom "event_loop" fixture properly closes the loop after yielding it + 2. The scopes of your custom "event_loop" fixtures do not overlap + 3. Your code does not modify the event loop in async fixtures or tests + """ +) + + +def _close_event_loop() -> None: + policy = asyncio.get_event_loop_policy() + try: + loop = policy.get_event_loop() + except RuntimeError: + loop = None + if loop is not None: + if not loop.is_closed(): + warnings.warn( + _UNCLOSED_EVENT_LOOP_WARNING % loop, + DeprecationWarning, + ) + loop.close() + + +def _provide_clean_event_loop() -> None: + # At this point, the event loop for the current thread is closed. + # When a user calls asyncio.get_event_loop(), they will get a closed loop. + # In order to avoid this side effect from pytest-asyncio, we need to replace + # the current loop with a fresh one. + # Note that we cannot set the loop to None, because get_event_loop only creates + # a new loop, when set_event_loop has not been called. + policy = asyncio.get_event_loop_policy() + new_loop = policy.new_event_loop() + policy.set_event_loop(new_loop) + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Optional[object]: + """ + Pytest hook called before a test case is run. + + Wraps marked tests in a synchronous function + where the wrapped test coroutine is executed in an event loop. + """ + marker = pyfuncitem.get_closest_marker("asyncio") + if marker is not None: + funcargs: Dict[str, object] = pyfuncitem.funcargs # type: ignore[name-defined] + loop = cast(asyncio.AbstractEventLoop, funcargs["event_loop"]) + if _is_hypothesis_test(pyfuncitem.obj): + pyfuncitem.obj.hypothesis.inner_test = wrap_in_sync( + pyfuncitem, + pyfuncitem.obj.hypothesis.inner_test, + _loop=loop, + ) + else: + pyfuncitem.obj = wrap_in_sync( + pyfuncitem, + pyfuncitem.obj, + _loop=loop, + ) + yield + + +def _is_hypothesis_test(function: Any) -> bool: + return getattr(function, "is_hypothesis_test", False) + + +def wrap_in_sync( + pyfuncitem: pytest.Function, + func: Callable[..., Awaitable[Any]], + _loop: asyncio.AbstractEventLoop, +): + """Return a sync wrapper around an async function executing it in the + current event loop.""" + + # if the function is already wrapped, we rewrap using the original one + # not using __wrapped__ because the original function may already be + # a wrapped one + raw_func = getattr(func, "_raw_test_func", None) + if raw_func is not None: + func = raw_func + + @functools.wraps(func) + def inner(*args, **kwargs): + coro = func(*args, **kwargs) + if not inspect.isawaitable(coro): + pyfuncitem.warn( + pytest.PytestWarning( + f"The test {pyfuncitem} is marked with '@pytest.mark.asyncio' " + "but it is not an async function. " + "Please remove asyncio marker. " + "If the test is not marked explicitly, " + "check for global markers applied via 'pytestmark'." + ) + ) + return + task = asyncio.ensure_future(coro, loop=_loop) + try: + return _loop.run_until_complete(task) + except BaseException: + # run_until_complete doesn't get the result from exceptions + # that are not subclasses of `Exception`. Consume all + # exceptions to prevent asyncio's warning from logging. + if task.done() and not task.cancelled(): + task.exception() + raise + + inner._raw_test_func = func # type: ignore[attr-defined] + return inner + + +def pytest_runtest_setup(item: pytest.Item) -> None: + marker = item.get_closest_marker("asyncio") + if marker is None: + return + fixturenames = item.fixturenames # type: ignore[attr-defined] + # inject an event loop fixture for all async tests + if "event_loop" in fixturenames: + fixturenames.remove("event_loop") + fixturenames.insert(0, "event_loop") + obj = getattr(item, "obj", None) + if not getattr(obj, "hypothesis", False) and getattr( + obj, "is_hypothesis_test", False + ): + pytest.fail( + "test function `%r` is using Hypothesis, but pytest-asyncio " + "only works with Hypothesis 3.64.0 or later." % item + ) + + +@pytest.fixture +def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]: + """Create an instance of the default event loop for each test case.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +def _unused_port(socket_type: int) -> int: + """Find an unused localhost port from 1024-65535 and return it.""" + with contextlib.closing(socket.socket(type=socket_type)) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@pytest.fixture +def unused_tcp_port() -> int: + return _unused_port(socket.SOCK_STREAM) + + +@pytest.fixture +def unused_udp_port() -> int: + return _unused_port(socket.SOCK_DGRAM) + + +@pytest.fixture(scope="session") +def unused_tcp_port_factory() -> Callable[[], int]: + """A factory function, producing different unused TCP ports.""" + produced = set() + + def factory(): + """Return an unused port.""" + port = _unused_port(socket.SOCK_STREAM) + + while port in produced: + port = _unused_port(socket.SOCK_STREAM) + + produced.add(port) + + return port + + return factory + + +@pytest.fixture(scope="session") +def unused_udp_port_factory() -> Callable[[], int]: + """A factory function, producing different unused UDP ports.""" + produced = set() + + def factory(): + """Return an unused port.""" + port = _unused_port(socket.SOCK_DGRAM) + + while port in produced: + port = _unused_port(socket.SOCK_DGRAM) + + produced.add(port) + + return port + + return factory diff --git a/contrib/python/pytest-asyncio/pytest_asyncio/py.typed b/contrib/python/pytest-asyncio/pytest_asyncio/py.typed new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/contrib/python/pytest-asyncio/pytest_asyncio/py.typed diff --git a/contrib/python/pytest-asyncio/ya.make b/contrib/python/pytest-asyncio/ya.make new file mode 100644 index 0000000000..e3918ea4b0 --- /dev/null +++ b/contrib/python/pytest-asyncio/ya.make @@ -0,0 +1,30 @@ +# Generated by devtools/yamaker (pypi). + +PY3_LIBRARY() + +VERSION(0.21.1) + +LICENSE(Apache-2.0) + +PEERDIR( + contrib/python/pytest +) + +NO_LINT() + +PY_SRCS( + TOP_LEVEL + pytest_asyncio/__init__.py + pytest_asyncio/_version.py + pytest_asyncio/plugin.py +) + +RESOURCE_FILES( + PREFIX contrib/python/pytest-asyncio/ + .dist-info/METADATA + .dist-info/entry_points.txt + .dist-info/top_level.txt + pytest_asyncio/py.typed +) + +END() |