Skip to content

Commit 96870a1

Browse files
committed
MPT-12327 Implement collection client
2 parents 50cf12c + b5279fd commit 96870a1

16 files changed

Lines changed: 953 additions & 12 deletions

mpt_api_client/http/client.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
import httpx
24

35

@@ -7,12 +9,26 @@ class MPTClient(httpx.Client):
79
def __init__(
810
self,
911
*,
10-
base_url: str,
11-
api_token: str,
12+
base_url: str | None = None,
13+
api_token: str | None = None,
1214
timeout: float = 5.0,
1315
retries: int = 0,
1416
):
15-
self.api_token = api_token
17+
api_token = api_token or os.getenv("MPT_TOKEN")
18+
if not api_token:
19+
raise ValueError(
20+
"API token is required. "
21+
"Set it up as env variable MPT_TOKEN or pass it as `api_token` "
22+
"argument to MPTClient."
23+
)
24+
25+
base_url = base_url or os.getenv("MPT_URL")
26+
if not base_url:
27+
raise ValueError(
28+
"Base URL is required. "
29+
"Set it up as env variable MPT_URL or pass it as `base_url` "
30+
"argument to MPTClient."
31+
)
1632
base_headers = {
1733
"User-Agent": "swo-marketplace-client/1.0",
1834
"Authorization": f"Bearer {api_token}",

mpt_api_client/http/collection.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import copy
2+
from abc import ABC
3+
from collections.abc import Iterator
4+
from typing import Any, Self
5+
6+
import httpx
7+
8+
from mpt_api_client.http.client import MPTClient
9+
from mpt_api_client.models import Collection, Resource
10+
from mpt_api_client.rql.query_builder import RQLQuery
11+
12+
13+
class CollectionBaseClient[ResourceType: Resource](ABC): # noqa: WPS214
14+
"""Immutable Base client for RESTful resource collections.
15+
16+
Examples:
17+
active_orders_cc = order_collection.filter(RQLQuery(status="active"))
18+
active_orders = active_orders_cc.order_by("created").iterate()
19+
product_active_orders = active_orders_cc.filter(RQLQuery(product__id="PRD-1")).iterate()
20+
21+
new_order = order_collection.create(order_data)
22+
23+
"""
24+
25+
_endpoint: str
26+
_resource_class: type[ResourceType]
27+
_collection_class: type[Collection[ResourceType]]
28+
29+
def __init__(
30+
self,
31+
query_rql: RQLQuery | None = None,
32+
client: MPTClient | None = None,
33+
) -> None:
34+
self.mpt_client = client or MPTClient()
35+
self.query_rql: RQLQuery | None = query_rql
36+
self.query_order_by: list[str] | None = None
37+
self.query_select: list[str] | None = None
38+
39+
@classmethod
40+
def clone(cls, collection_client: "CollectionBaseClient[ResourceType]") -> Self:
41+
"""Create a copy of collection client for immutable operations.
42+
43+
Returns:
44+
New collection client with same settings.
45+
"""
46+
new_collection = cls(
47+
client=collection_client.mpt_client,
48+
query_rql=collection_client.query_rql,
49+
)
50+
new_collection.query_order_by = (
51+
copy.copy(collection_client.query_order_by)
52+
if collection_client.query_order_by
53+
else None
54+
)
55+
new_collection.query_select = (
56+
copy.copy(collection_client.query_select) if collection_client.query_select else None
57+
)
58+
return new_collection
59+
60+
def build_url(self, query_params: dict[str, Any] | None = None) -> str:
61+
"""Builds the endpoint URL with all the query parameters.
62+
63+
Returns:
64+
Partial URL with query parameters.
65+
"""
66+
query_params = query_params or {}
67+
query_parts = [
68+
f"{param_key}={param_value}" for param_key, param_value in query_params.items()
69+
] # noqa: WPS237
70+
if self.query_order_by:
71+
query_parts.append(f"order={','.join(self.query_order_by)}") # noqa: WPS237
72+
if self.query_select:
73+
query_parts.append(f"select={','.join(self.query_select)}") # noqa: WPS237
74+
if self.query_rql:
75+
query_parts.append(str(self.query_rql))
76+
if query_parts:
77+
return f"{self._endpoint}?{'&'.join(query_parts)}" # noqa: WPS237
78+
return self._endpoint
79+
80+
def order_by(self, *fields: str) -> Self:
81+
"""Returns new collection with ordering setup.
82+
83+
Returns:
84+
New collection with ordering setup.
85+
86+
Raises:
87+
ValueError: If ordering has already been set.
88+
"""
89+
if self.query_order_by is not None:
90+
raise ValueError("Ordering is already set. Cannot set ordering multiple times.")
91+
new_collection = self.clone(self)
92+
new_collection.query_order_by = list(fields)
93+
return new_collection
94+
95+
def filter(self, rql: RQLQuery) -> Self:
96+
"""Creates a new collection with the filter added to the filter collection.
97+
98+
Returns:
99+
New copy of the collection with the filter added.
100+
"""
101+
if self.query_rql:
102+
rql = self.query_rql & rql
103+
new_collection = self.clone(self)
104+
new_collection.query_rql = rql
105+
return new_collection
106+
107+
def select(self, *fields: str) -> Self:
108+
"""Set select fields. Raises ValueError if select fields are already set.
109+
110+
Returns:
111+
New copy of the collection with the select fields set.
112+
113+
Raises:
114+
ValueError: If select fields are already set.
115+
"""
116+
if self.query_select is not None:
117+
raise ValueError(
118+
"Select fields are already set. Cannot set select fields multiple times."
119+
)
120+
121+
new_client = self.clone(self)
122+
new_client.query_select = list(fields)
123+
return new_client
124+
125+
def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceType]:
126+
"""Fetch one page of resources.
127+
128+
Returns:
129+
Collection of resources.
130+
"""
131+
response = self._fetch_page_as_response(limit=limit, offset=offset)
132+
return Collection.from_response(response)
133+
134+
def fetch_one(self) -> ResourceType:
135+
"""Fetch one page, expect exactly one result.
136+
137+
Returns:
138+
One resource.
139+
140+
Raises:
141+
ValueError: If the total matching records are not exactly one.
142+
"""
143+
response = self._fetch_page_as_response(limit=1, offset=0)
144+
resource_list: Collection[ResourceType] = Collection.from_response(response)
145+
total_records = len(resource_list)
146+
if resource_list.meta:
147+
total_records = resource_list.meta.pagination.total
148+
if total_records == 0:
149+
raise ValueError("Expected one result, but got zero results")
150+
if total_records > 1:
151+
raise ValueError(f"Expected one result, but got {total_records} results")
152+
153+
return resource_list[0]
154+
155+
def iterate(self) -> Iterator[ResourceType]:
156+
"""Iterate over all resources, yielding GenericResource objects.
157+
158+
Returns:
159+
Iterator of resources.
160+
"""
161+
offset = 0
162+
limit = 100 # Default page size
163+
164+
while True:
165+
response = self._fetch_page_as_response(limit=limit, offset=offset)
166+
items_collection: Collection[ResourceType] = Collection.from_response(response)
167+
yield from items_collection
168+
169+
if not items_collection.meta:
170+
break
171+
if not items_collection.meta.pagination.has_next():
172+
break
173+
offset = items_collection.meta.pagination.next_offset()
174+
175+
def create(self, resource_data: dict[str, Any]) -> ResourceType:
176+
"""Create a new resource using `POST /endpoint`.
177+
178+
Returns:
179+
New resource created.
180+
"""
181+
response = self.mpt_client.post(self._endpoint, json=resource_data)
182+
response.raise_for_status()
183+
184+
return self._resource_class.from_response(response)
185+
186+
def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response:
187+
"""Fetch one page of resources.
188+
189+
Returns:
190+
httpx.Response object.
191+
192+
Raises:
193+
HTTPStatusError: if the response status code is not 200.
194+
"""
195+
pagination_params: dict[str, int] = {"limit": limit, "offset": offset}
196+
response = self.mpt_client.get(self.build_url(pagination_params))
197+
response.raise_for_status()
198+
199+
return response

mpt_api_client/modules/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from mpt_api_client.modules.order import Order, OrderCollectionClient
2+
3+
__all__ = ["Order", "OrderCollectionClient"] # noqa: WPS410

mpt_api_client/modules/order.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from mpt_api_client.http.collection import CollectionBaseClient
2+
from mpt_api_client.models import Collection, Resource
3+
from mpt_api_client.register import mpt
4+
5+
6+
class Order(Resource):
7+
"""Order resource."""
8+
9+
10+
@mpt("orders")
11+
class OrderCollectionClient(CollectionBaseClient[Order]):
12+
"""Orders client."""
13+
14+
_endpoint = "/api/v1/orders"
15+
_resource_class = Order
16+
_collection_class = Collection[Order]

mpt_api_client/mpt.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from mpt_api_client.http.client import MPTClient
2+
3+
4+
class MPT:
5+
"""MPT API Client."""
6+
7+
def __init__(self, base_url: str | None = None, api_key: str | None = None):
8+
self.mpt_client = MPTClient(base_url=base_url, api_token=api_key)

mpt_api_client/register.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from collections.abc import Callable
2+
from typing import Any
3+
4+
from mpt_api_client.http.collection import CollectionBaseClient
5+
6+
ItemType = type[CollectionBaseClient[Any]]
7+
8+
9+
class Registry:
10+
"""Registry for MPT collection clients."""
11+
12+
def __init__(self) -> None:
13+
self.items: dict[str, ItemType] = {} # noqa: WPS110
14+
15+
def __call__(self, keyname: str) -> Callable[[ItemType], ItemType]:
16+
"""Decorator to register a CollectionBaseClient class.
17+
18+
Args:
19+
keyname: The key to register the class under
20+
21+
Returns:
22+
The decorator function
23+
24+
Examples:
25+
registry = Registry()
26+
@registry("orders")
27+
class OrderCollectionClient(CollectionBaseClient):
28+
_endpoint = "/api/v1/orders"
29+
_resource_class = Order
30+
31+
registry.get("orders") == OrderCollectionClient
32+
"""
33+
34+
def decorator(cls: ItemType) -> ItemType:
35+
self.register(keyname, cls)
36+
return cls
37+
38+
return decorator
39+
40+
def register(self, keyname: str, item: ItemType) -> None: # noqa: WPS110
41+
"""Register a collection client class with a keyname.
42+
43+
Args:
44+
keyname: The key to register the client under
45+
item: The collection client class to register
46+
"""
47+
self.items[keyname] = item
48+
49+
def get(self, keyname: str) -> ItemType:
50+
"""Get a registered collection client class by keyname.
51+
52+
Args:
53+
keyname: The key to look up
54+
55+
Returns:
56+
The registered collection client class
57+
58+
Raises:
59+
KeyError: If keyname is not registered
60+
"""
61+
if keyname not in self.items:
62+
raise KeyError(f"No collection client registered with keyname: {keyname}")
63+
return self.items[keyname]
64+
65+
def list_keys(self) -> list[str]:
66+
"""Get all registered keynames.
67+
68+
Returns:
69+
List of all registered keynames
70+
"""
71+
return list(self.items.keys())
72+
73+
74+
mpt = Registry()

setup.cfg

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ per-file-ignores =
3737
WPS110
3838
# Found `noqa` comments overuse
3939
WPS402
40+
tests/http/collection/test_collection_client_iterate.py:
41+
# Found too many module members
42+
WPS202
43+
tests/http/collection/test_collection_client_fetch.py:
44+
# Found too many module members
45+
WPS202
46+
# Found magic number
47+
WPS432
4048
tests/*:
4149
# Allow magic strings
4250
WPS432

tests/http/collection/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import pytest
2+
3+
from mpt_api_client.http.collection import CollectionBaseClient
4+
from mpt_api_client.models import Collection, Resource
5+
6+
7+
class DummyResource(Resource):
8+
"""Dummy resource for testing."""
9+
10+
11+
class DummyCollectionClient(CollectionBaseClient[DummyResource]):
12+
_endpoint = "/api/v1/test"
13+
_resource_class = DummyResource
14+
_collection_class = Collection[DummyResource]
15+
16+
17+
@pytest.fixture
18+
def collection_client(mpt_client):
19+
return DummyCollectionClient(client=mpt_client)

0 commit comments

Comments
 (0)