diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index 6dbc95e..234b70d 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -1,10 +1,10 @@ import os -import httpx +from httpx import AsyncClient, AsyncHTTPTransport, Client, HTTPTransport -class HTTPClient(httpx.Client): - """A client for interacting with SoftwareOne Marketplace Platform API.""" +class HTTPClient(Client): + """Sync HTTP client for interacting with SoftwareOne Marketplace Platform API.""" def __init__( self, @@ -33,9 +33,49 @@ def __init__( "User-Agent": "swo-marketplace-client/1.0", "Authorization": f"Bearer {api_token}", } - super().__init__( + Client.__init__( + self, base_url=base_url, headers=base_headers, timeout=timeout, - transport=httpx.HTTPTransport(retries=retries), + transport=HTTPTransport(retries=retries), + ) + + +class HTTPClientAsync(AsyncClient): + """Async HTTP client for interacting with SoftwareOne Marketplace Platform API.""" + + def __init__( + self, + *, + base_url: str | None = None, + api_token: str | None = None, + timeout: float = 5.0, + retries: int = 0, + ): + api_token = api_token or os.getenv("MPT_TOKEN") + if not api_token: + raise ValueError( + "API token is required. " + "Set it up as env variable MPT_TOKEN or pass it as `api_token` " + "argument to MPTClient." + ) + + base_url = base_url or os.getenv("MPT_URL") + if not base_url: + raise ValueError( + "Base URL is required. " + "Set it up as env variable MPT_URL or pass it as `base_url` " + "argument to MPTClient." + ) + base_headers = { + "User-Agent": "swo-marketplace-client/1.0", + "Authorization": f"Bearer {api_token}", + } + AsyncClient.__init__( + self, + base_url=base_url, + headers=base_headers, + timeout=timeout, + transport=AsyncHTTPTransport(retries=retries), ) diff --git a/pyproject.toml b/pyproject.toml index 07dba87..bc47902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dev = [ "mypy==1.15.*", "pre-commit==4.2.*", "pytest==8.3.*", + "pytest-asyncio==1.1.*", "pytest-cov==6.1.*", "pytest-deadfixtures==2.2.*", "pytest-mock==3.14.*", @@ -59,6 +60,7 @@ testpaths = "tests" pythonpath = "." addopts = "--cov=mpt_api_client --cov-report=term-missing --cov-report=html --cov-report=xml" log_cli = false +asyncio_mode = "auto" filterwarnings = [ "ignore:Support for class-based `config` is deprecated:DeprecationWarning", "ignore:pkg_resources is deprecated as an API:DeprecationWarning", diff --git a/tests/http/collection/test_collection_client_init.py b/tests/http/collection/test_collection_client_init.py index 2426037..488a2ae 100644 --- a/tests/http/collection/test_collection_client_init.py +++ b/tests/http/collection/test_collection_client_init.py @@ -15,8 +15,8 @@ def sample_rql_query(): return RQLQuery(status="active") -def test_init_defaults(mpt_client): - collection_client = DummyCollectionClient(client=mpt_client) +def test_init_defaults(http_client): + collection_client = DummyCollectionClient(client=http_client) assert collection_client.query_rql is None assert collection_client.query_order_by is None @@ -24,9 +24,9 @@ def test_init_defaults(mpt_client): assert collection_client.build_url() == "/api/v1/test" -def test_init_with_filter(mpt_client, sample_rql_query): +def test_init_with_filter(http_client, sample_rql_query): collection_client = DummyCollectionClient( - client=mpt_client, + client=http_client, query_rql=sample_rql_query, ) diff --git a/tests/http/conftest.py b/tests/http/conftest.py index 08a5ee7..d39508c 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -1,6 +1,6 @@ import pytest -from mpt_api_client.http.client import HTTPClient +from mpt_api_client.http.client import HTTPClient, HTTPClientAsync from mpt_api_client.http.collection import CollectionBaseClient from mpt_api_client.http.resource import ResourceBaseClient from mpt_api_client.models import Collection @@ -30,15 +30,20 @@ def api_token(): @pytest.fixture -def mpt_client(api_url, api_token): +def http_client(api_url, api_token): return HTTPClient(base_url=api_url, api_token=api_token) @pytest.fixture -def resource_client(mpt_client): - return DummyResourceClient(client=mpt_client, resource_id="RES-123") +def http_client_async(api_url, api_token): + return HTTPClientAsync(base_url=api_url, api_token=api_token) @pytest.fixture -def collection_client(mpt_client) -> DummyCollectionClient: - return DummyCollectionClient(client=mpt_client) +def resource_client(http_client): + return DummyResourceClient(client=http_client, resource_id="RES-123") + + +@pytest.fixture +def collection_client(http_client) -> DummyCollectionClient: + return DummyCollectionClient(client=http_client) diff --git a/tests/http/test_async_client.py b/tests/http/test_async_client.py new file mode 100644 index 0000000..c320e79 --- /dev/null +++ b/tests/http/test_async_client.py @@ -0,0 +1,57 @@ +import pytest +import respx +from httpx import ConnectTimeout, Response, codes + +from mpt_api_client.http.client import HTTPClientAsync +from tests.conftest import API_TOKEN, API_URL + + +def test_mpt_client_initialization(): + client = HTTPClientAsync(base_url=API_URL, api_token=API_TOKEN) + + assert client.base_url == API_URL + assert client.headers["Authorization"] == "Bearer test-token" + assert client.headers["User-Agent"] == "swo-marketplace-client/1.0" + + +def test_env_initialization(monkeypatch): + monkeypatch.setenv("MPT_TOKEN", API_TOKEN) + monkeypatch.setenv("MPT_URL", API_URL) + + client = HTTPClientAsync() + + assert client.base_url == API_URL + assert client.headers["Authorization"] == f"Bearer {API_TOKEN}" + + +def test_mpt_client_without_token(): + with pytest.raises(ValueError): + HTTPClientAsync(base_url=API_URL) + + +def test_mpt_client_without_url(): + with pytest.raises(ValueError): + HTTPClientAsync(api_token=API_TOKEN) + + +@respx.mock +async def test_mock_call_success(http_client_async): + success_route = respx.get(f"{API_URL}/").mock( + return_value=Response(200, json={"message": "Hello, World!"}) + ) + + success_response = await http_client_async.get("/") + + assert success_response.status_code == codes.OK + assert success_response.json() == {"message": "Hello, World!"} + assert success_route.called + + +@respx.mock +async def test_mock_call_failure(http_client_async): + timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) + + with pytest.raises(ConnectTimeout): + await http_client_async.get("/timeout") + + assert timeout_route.called diff --git a/tests/http/test_client.py b/tests/http/test_client.py index 1a482bc..d388523 100644 --- a/tests/http/test_client.py +++ b/tests/http/test_client.py @@ -35,12 +35,12 @@ def test_mpt_client_without_url(): @respx.mock -def test_mock_call_success(mpt_client): +def test_mock_call_success(http_client): success_route = respx.get(f"{API_URL}/").mock( return_value=Response(200, json={"message": "Hello, World!"}) ) - success_response = mpt_client.get("/") + success_response = http_client.get("/") assert success_response.status_code == codes.OK assert success_response.json() == {"message": "Hello, World!"} @@ -48,22 +48,10 @@ def test_mock_call_success(mpt_client): @respx.mock -def test_mock_call_failure(mpt_client): +def test_mock_call_failure(http_client): timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) with pytest.raises(ConnectTimeout): - mpt_client.get("/timeout") + http_client.get("/timeout") assert timeout_route.called - - -@respx.mock -def test_mock_call_failure_with_retries(mpt_client): - not_found_route = respx.get(f"{API_URL}/not-found").mock( - side_effect=Response(codes.NOT_FOUND, json={"message": "Not Found"}) - ) - - not_found_response = mpt_client.get("/not-found") - - assert not_found_response.status_code == codes.NOT_FOUND - assert not_found_route.called diff --git a/uv.lock b/uv.lock index 6511b92..941b914 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12, <4" [[package]] @@ -391,6 +391,7 @@ dev = [ { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-deadfixtures" }, { name = "pytest-mock" }, @@ -417,6 +418,7 @@ dev = [ { name = "mypy", specifier = "==1.15.*" }, { name = "pre-commit", specifier = "==4.2.*" }, { name = "pytest", specifier = "==8.3.*" }, + { name = "pytest-asyncio", specifier = "==1.1.*" }, { name = "pytest-cov", specifier = "==6.1.*" }, { name = "pytest-deadfixtures", specifier = "==2.2.*" }, { name = "pytest-mock", specifier = "==3.14.*" }, @@ -608,6 +610,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + [[package]] name = "pytest-cov" version = "6.1.1"