Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 45 additions & 5 deletions mpt_api_client/http/client.py
Original file line number Diff line number Diff line change
@@ -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__(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems repeating in both classes so could be refactored to a Base Class. I think this is what you did in PR #18?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #18 is about MPTClient, this one is about HTTPClient.

I would like to share that code, but that code is calling different parent depending on if it is a sync or an async client.

I do not know how to implement an init that calls a different parent depending on which "child" you are using, if you know how, please let me know.

Copy link

@jentyk jentyk Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you did it elsewhere, just call super().__init__() with any params if needed of course

self,
Expand Down Expand Up @@ -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),
)
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.*",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions tests/http/collection/test_collection_client_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,18 @@ 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
assert collection_client.query_select is None
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,
)

Expand Down
17 changes: 11 additions & 6 deletions tests/http/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
57 changes: 57 additions & 0 deletions tests/http/test_async_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import pytest
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering. Maybe, to avoid repetition, since you seem copied the same tests for both clients, you could parametrize tests with sync/async clients?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

once are shared, but the important ones we need to use await in the http methods

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
20 changes: 4 additions & 16 deletions tests/http/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,35 +35,23 @@ 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!"}
assert success_route.called


@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
16 changes: 15 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.