From c766b0c4cb0a64a3d67625fd892af0afaa8c5dae Mon Sep 17 00:00:00 2001 From: hexoul Date: Sun, 7 Dec 2025 11:56:10 +0900 Subject: [PATCH] test: add tests for async `BaseClient` --- centraldogma/_async/base_client.py | 5 +- centraldogma/_sync/base_client.py | 3 + pyproject.toml | 1 + pytest.ini | 3 + tests/test_async_base_client.py | 205 +++++++++++++++++++++++++++++ utils/run-unasync.py | 1 + uv.lock | 60 ++++++++- 7 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 tests/test_async_base_client.py diff --git a/centraldogma/_async/base_client.py b/centraldogma/_async/base_client.py index 3b1b338..1d26b6e 100644 --- a/centraldogma/_async/base_client.py +++ b/centraldogma/_async/base_client.py @@ -59,6 +59,9 @@ def __init__( self.headers = self._get_headers(token) self.patch_headers = self._get_patch_headers(token) + async def __aenter__(self): + return self + async def __aexit__(self, *_: Any) -> None: await self.client.aclose() @@ -75,7 +78,7 @@ async def request( wait=wait_exponential(max=60), reraise=True, ) - return retryer(self._request, method, path, handler, **kwargs) + return await retryer(self._request, method, path, handler, **kwargs) def _set_request_headers(self, method: str, **kwargs) -> Dict: default_headers = self.patch_headers if method == "patch" else self.headers diff --git a/centraldogma/_sync/base_client.py b/centraldogma/_sync/base_client.py index a3f760a..d62d9f3 100644 --- a/centraldogma/_sync/base_client.py +++ b/centraldogma/_sync/base_client.py @@ -59,6 +59,9 @@ def __init__( self.headers = self._get_headers(token) self.patch_headers = self._get_patch_headers(token) + def __enter__(self): + return self + def __exit__(self, *_: Any) -> None: self.client.close() diff --git a/pyproject.toml b/pyproject.toml index faa3f28..a4193ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dev = [ "black", "codecov", "pytest", + "pytest-asyncio>=1.2.0", "pytest-cov", "pytest-mock", "respx", diff --git a/pytest.ini b/pytest.ini index 5039e36..faf45de 100644 --- a/pytest.ini +++ b/pytest.ini @@ -9,3 +9,6 @@ markers = log_format = %(asctime)s %(levelname)s %(message)s log_date_format = %Y-%m-%d %H:%M:%S log_cli=true + +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function diff --git a/tests/test_async_base_client.py b/tests/test_async_base_client.py new file mode 100644 index 0000000..34ef299 --- /dev/null +++ b/tests/test_async_base_client.py @@ -0,0 +1,205 @@ +# Copyright 2025 LINE Corporation +# +# LINE Corporation licenses this file to you under the Apache License, +# version 2.0 (the "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import AsyncGenerator + +from http import HTTPStatus +from httpx import ConnectError, NetworkError, Response +import json +import pytest + +from centraldogma._async.base_client import BaseClient +from centraldogma.exceptions import UnauthorizedException, NotFoundException + +base_url = "http://baseurl" +ok_handler = {HTTPStatus.OK: lambda resp: resp} + + +@pytest.fixture +async def client() -> AsyncGenerator[BaseClient, None]: + async with BaseClient(base_url, "token", retries=0) as async_client: + yield async_client + + +@pytest.fixture +async def client_with_configs() -> AsyncGenerator[BaseClient, None]: + configs = { + "timeout": 5, + "retries": 10, + "max_connections": 50, + "max_keepalive_connections": 10, + "trust_env": True, + } + async with BaseClient(base_url, "token", **configs) as async_client: + yield async_client + + +@pytest.mark.asyncio +async def test_set_request_headers(client_with_configs): + for method in ["get", "post", "delete", "patch"]: + kwargs = client_with_configs._set_request_headers( + method, params={"a": "b"}, follow_redirects=True + ) + content_type = ( + "application/json-patch+json" if method == "patch" else "application/json" + ) + assert kwargs["headers"] == { + "Authorization": "bearer token", + "Content-Type": content_type, + } + assert kwargs["params"] == {"a": "b"} + assert kwargs["follow_redirects"] + assert "limits" not in kwargs + assert "event_hooks" not in kwargs + assert "transport" not in kwargs + assert "app" not in kwargs + + +@pytest.mark.asyncio +async def test_request_with_configs(respx_mock, client, client_with_configs): + methods = ["get", "post", "put", "delete", "patch", "options"] + for method in methods: + getattr(respx_mock, method)(f"{base_url}/api/v1/path").mock( + return_value=Response(200, text="success") + ) + + await client.request( + method, + "/path", + timeout=5, + cookies=None, + auth=None, + ) + await client.request(method, "/path", timeout=(3.05, 27)) + await client_with_configs.request(method, "/path") + + assert respx_mock.calls.call_count == len(methods) * 3 + + +@pytest.mark.asyncio +async def test_delete(respx_mock, client): + route = respx_mock.delete(f"{base_url}/api/v1/path").mock( + return_value=Response(200, text="success") + ) + resp = await client.request("delete", "/path", params={"a": "b"}) + + assert route.called + assert resp.request.headers["Authorization"] == "bearer token" + assert resp.request.headers["Content-Type"] == "application/json" + assert resp.request.url.params.multi_items() == [("a", "b")] + + +@pytest.mark.asyncio +async def test_delete_exception_authorization(respx_mock, client): + with pytest.raises(UnauthorizedException): + respx_mock.delete(f"{base_url}/api/v1/path").mock(return_value=Response(401)) + await client.request("delete", "/path", handler=ok_handler) + + +@pytest.mark.asyncio +async def test_get(respx_mock, client): + route = respx_mock.get(f"{base_url}/api/v1/path").mock( + return_value=Response(200, text="success") + ) + resp = await client.request("get", "/path", params={"a": "b"}, handler=ok_handler) + + assert route.called + assert route.call_count == 1 + assert resp.request.headers["Authorization"] == "bearer token" + assert resp.request.headers["Content-Type"] == "application/json" + assert resp.request.url.params.multi_items() == [("a", "b")] + + +@pytest.mark.asyncio +async def test_get_exception_authorization(respx_mock, client): + with pytest.raises(UnauthorizedException): + respx_mock.get(f"{base_url}/api/v1/path").mock(return_value=Response(401)) + await client.request("get", "/path", handler=ok_handler) + + +@pytest.mark.asyncio +async def test_get_exception_not_found(respx_mock, client): + with pytest.raises(NotFoundException): + respx_mock.get(f"{base_url}/api/v1/path").mock(return_value=Response(404)) + await client.request("get", "/path", handler=ok_handler) + + +@pytest.mark.asyncio +async def test_get_with_retry_by_response(respx_mock): + async with BaseClient(base_url, "token", retries=2) as retry_client: + route = respx_mock.get(f"{base_url}/api/v1/path").mock( + side_effect=[Response(503), Response(404), Response(200)], + ) + + await retry_client.request("get", "/path", handler=ok_handler) + + assert route.called + assert route.call_count == 3 + + +@pytest.mark.asyncio +async def test_get_with_retry_by_client(respx_mock): + async with BaseClient(base_url, "token", retries=10) as retry_client: + route = respx_mock.get(f"{base_url}/api/v1/path").mock( + side_effect=[ConnectError, ConnectError, NetworkError, Response(200)], + ) + + await retry_client.request("get", "/path", handler=ok_handler) + + assert route.called + assert route.call_count == 4 + + +@pytest.mark.asyncio +async def test_patch(respx_mock, client): + route = respx_mock.patch(f"{base_url}/api/v1/path").mock( + return_value=Response(200, text="success") + ) + given = {"a": "b"} + resp = await client.request("patch", "/path", json=given, handler=ok_handler) + + assert route.called + assert resp.request.headers["Authorization"] == "bearer token" + assert resp.request.headers["Content-Type"] == "application/json-patch+json" + got = json.loads(resp.request.content) + assert got == given + + +@pytest.mark.asyncio +async def test_patch_exception_authorization(respx_mock, client): + with pytest.raises(UnauthorizedException): + respx_mock.patch(f"{base_url}/api/v1/path").mock(return_value=Response(401)) + await client.request("patch", "/path", json={"a": "b"}, handler=ok_handler) + + +@pytest.mark.asyncio +async def test_post(respx_mock, client): + route = respx_mock.post(f"{base_url}/api/v1/path").mock( + return_value=Response(200, text="success") + ) + given = {"a": "b"} + resp = await client.request("post", "/path", json=given, handler=ok_handler) + + assert route.called + assert resp.request.headers["Authorization"] == "bearer token" + assert resp.request.headers["Content-Type"] == "application/json" + got = json.loads(resp.request.content) + assert got == given + + +@pytest.mark.asyncio +async def test_post_exception_authorization(respx_mock, client): + with pytest.raises(UnauthorizedException): + respx_mock.post(f"{base_url}/api/v1/path").mock(return_value=Response(401)) + await client.request("post", "/path", handler=ok_handler) diff --git a/utils/run-unasync.py b/utils/run-unasync.py index 9eb4544..cf594e7 100644 --- a/utils/run-unasync.py +++ b/utils/run-unasync.py @@ -79,6 +79,7 @@ def main(check: bool = False): "AsyncClient": "Client", "AsyncDogma": "Dogma", "AsyncRetrying": "Retrying", + "__aenter__": "__enter__", "__aexit__": "__exit__", "aclose": "close", }, diff --git a/uv.lock b/uv.lock index acc93bf..236de14 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.9" resolution-markers = [ - "python_full_version < '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", + "python_full_version < '3.10'", "python_full_version >= '3.13'", ] @@ -48,6 +49,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599, upload-time = "2024-08-08T14:25:42.686Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "black" version = "24.10.0" @@ -112,6 +122,12 @@ docs = [ { name = "sphinx-rtd-theme" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'" }, @@ -127,10 +143,13 @@ requires-dist = [ { name = "respx", marker = "extra == 'dev'" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=3.0" }, { name = "tenacity", specifier = ">=9.0.0,<10.0.0" }, - { name = "unasync", specifier = ">=0.6.0" }, + { name = "unasync", marker = "extra == 'dev'", specifier = ">=0.6.0" }, ] provides-extras = ["dev", "docs"] +[package.metadata.requires-dev] +dev = [{ name = "pytest-asyncio", specifier = ">=1.2.0" }] + [[package]] name = "certifi" version = "2024.8.30" @@ -460,7 +479,7 @@ name = "importlib-metadata" version = "8.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.13'" }, + { name = "zipp", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ @@ -740,6 +759,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341, upload-time = "2024-09-10T10:52:12.54Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, + { name = "pytest", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10' and python_full_version < '3.13'", + "python_full_version >= '3.13'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, + { name = "pytest", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-cov" version = "6.0.0"