Skip to content

Commit f73bc14

Browse files
committed
feat: Implement async high level client
1 parent 33958d9 commit f73bc14

File tree

11 files changed

+1358
-446
lines changed

11 files changed

+1358
-446
lines changed

polarion_rest_api_client/clients/base_classes.py

Lines changed: 191 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,28 @@
2828
_max_sleep = 15
2929

3030
UT = t.TypeVar("UT", str, int, float, datetime.datetime, bool, None)
31+
NT = t.TypeVar("NT", str, int, float, datetime.datetime, bool, oa_types.Unset)
3132

3233

33-
class BaseClient:
34+
class BaseClient(t.Generic[T]):
3435
"""The overall base client for all project related clients."""
3536

36-
_retry_methods: t.ClassVar[list[str]] = []
37+
_retry_methods: t.ClassVar[set[str]] = set()
3738

3839
def __init__(
3940
self, project_id: str, client: "polarion_client.PolarionClient"
4041
):
4142
self._project_id = project_id
4243
self._client = client
44+
self._all_retry_methods: set[str] = set()
45+
for base in self.__class__.__mro__:
46+
if hasattr(base, "_retry_methods"):
47+
self._all_retry_methods.update(base._retry_methods)
4348

4449
def __getattribute__(self, name: str) -> t.Any:
4550
"""Retry method calls defined in _retry_methods."""
4651
attr = super().__getattribute__(name)
47-
retry_methods = super().__getattribute__("_retry_methods")
52+
retry_methods = super().__getattribute__("_all_retry_methods")
4853
if name in retry_methods and callable(attr):
4954
return functools.partial(
5055
super().__getattribute__("_retry_on_error"), attr
@@ -100,6 +105,20 @@ def unset_to_none(self, value: t.Any) -> t.Any:
100105
return None
101106
return value
102107

108+
@t.overload
109+
def none_to_unset(self, value: None) -> oa_types.Unset:
110+
"""Return UNSET if value is None, else the value."""
111+
112+
@t.overload
113+
def none_to_unset(self, value: NT) -> NT:
114+
"""Return UNSET if value is None, else the value."""
115+
116+
def none_to_unset(self, value: t.Any) -> t.Any:
117+
"""Return UNSET if value is None, else the value."""
118+
if value is None:
119+
return oa_types.UNSET
120+
return value
121+
103122
def _raise_on_error(self, response: oa_types.Response) -> None:
104123
def unexpected_error() -> errors.PolarionApiUnexpectedException:
105124
return errors.PolarionApiUnexpectedException(
@@ -152,16 +171,112 @@ def _retry_on_error(
152171
time.sleep(random.uniform(_min_sleep, _max_sleep))
153172
return call(*args, **kwargs)
154173

174+
def _pre_batching_grouping(
175+
self, items: list[T]
176+
) -> t.Generator[list[T], None, None]:
177+
yield items
155178

156-
class ItemsClient(BaseClient, t.Generic[T], abc.ABC):
157-
"""A client for items of a project, which can be created or requested."""
158179

159-
_retry_methods: t.ClassVar[list[str]] = [
160-
"get_multi",
180+
class SingleGetClient(BaseClient[T], abc.ABC):
181+
"""A client for items of a project, which can be created."""
182+
183+
_retry_methods: t.ClassVar[set[str]] = {
161184
"get",
185+
"a_get",
186+
}
187+
188+
@abc.abstractmethod
189+
def get(self, *args: t.Any, **kwargs: t.Any) -> T | None:
190+
"""Get a specific single item."""
191+
192+
@abc.abstractmethod
193+
async def a_get(self, *args: t.Any, **kwargs: t.Any) -> T | None:
194+
"""Get a specific single item."""
195+
196+
197+
class CreateClient(BaseClient[T], abc.ABC):
198+
"""A client for items of a project, which can be created."""
199+
200+
_retry_methods: t.ClassVar[set[str]] = {
162201
"_create",
202+
"_a_create",
203+
}
204+
_create_batch_size: int = 0
205+
206+
@abc.abstractmethod
207+
def _create(self, items: list[T]) -> None: ...
208+
209+
@abc.abstractmethod
210+
async def _a_create(self, items: list[T]) -> None: ...
211+
212+
def _split_into_create_batches(
213+
self, items: list[T]
214+
) -> t.Generator[list[T], None, None]:
215+
batch_size = self._create_batch_size or self._client.batch_size
216+
for it in self._pre_batching_grouping(items):
217+
for i in range(0, len(it), batch_size):
218+
yield it[i : i + batch_size]
219+
220+
def create(self, items: T | list[T]) -> None:
221+
"""Create one or multiple items."""
222+
if not isinstance(items, list):
223+
items = [items]
224+
225+
for batch in self._split_into_create_batches(items):
226+
self._create(batch)
227+
228+
async def a_create(self, items: T | list[T]) -> None:
229+
"""Create one or multiple items."""
230+
if not isinstance(items, list):
231+
items = [items]
232+
233+
for batch in self._split_into_create_batches(items):
234+
await self._a_create(batch)
235+
236+
237+
class DeleteClient(BaseClient[T], abc.ABC):
238+
"""A client for items of a project, which can be created."""
239+
240+
_retry_methods: t.ClassVar[set[str]] = {
163241
"_delete",
164-
]
242+
}
243+
_delete_batch_size: int = 0
244+
245+
def _split_into_delete_batches(
246+
self, items: list[T]
247+
) -> t.Generator[list[T], None, None]:
248+
batch_size = self._delete_batch_size or self._client.batch_size
249+
for it in self._pre_batching_grouping(items):
250+
for i in range(0, len(it), batch_size):
251+
yield it[i : i + batch_size]
252+
253+
@abc.abstractmethod
254+
def _delete(self, items: list[T]) -> None: ...
255+
256+
@abc.abstractmethod
257+
async def _a_delete(self, items: list[T]) -> None: ...
258+
259+
def delete(self, items: T | list[T]) -> None:
260+
"""Delete one or multiple items."""
261+
if not isinstance(items, list):
262+
items = [items]
263+
for batch in self._split_into_delete_batches(items):
264+
self._delete(batch)
265+
266+
async def a_delete(self, items: T | list[T]) -> None:
267+
"""Delete one or multiple items."""
268+
if not isinstance(items, list):
269+
items = [items]
270+
for batch in self._split_into_delete_batches(items):
271+
await self._a_delete(batch)
272+
273+
274+
class MultiGetClient(BaseClient[T], abc.ABC):
275+
"""A client for items of a project, which can be created."""
276+
277+
_retry_methods: t.ClassVar[set[str]] = {
278+
"get_multi",
279+
}
165280

166281
@abc.abstractmethod
167282
def get_multi(
@@ -178,9 +293,18 @@ def get_multi(
178293
"""
179294

180295
@abc.abstractmethod
181-
def get(self, *args: t.Any, **kwargs: t.Any) -> T | None:
182-
"""Get a specific single item."""
183-
return self._retry_on_error(self.get, *args, **kwargs)
296+
async def a_get_multi(
297+
self,
298+
*args: t.Any,
299+
page_size: int = 100,
300+
page_number: int = 1,
301+
**kwargs: t.Any,
302+
) -> tuple[list[T], bool]:
303+
"""Get multiple matching items for a specific page.
304+
305+
In addition, a flag whether a next page is available is
306+
returned.
307+
"""
184308

185309
def get_all(self, *args: t.Any, **kwargs: t.Any) -> list[T]:
186310
"""Return all matching items using get_multi with auto pagination."""
@@ -199,52 +323,45 @@ def get_all(self, *args: t.Any, **kwargs: t.Any) -> list[T]:
199323
items += _items
200324
return items
201325

202-
@abc.abstractmethod
203-
def _create(self, items: list[T]) -> None: ...
204-
205-
def _split_into_batches(
206-
self, items: list[T]
207-
) -> t.Generator[list[T], None, None]:
208-
for i in range(0, len(items), self._client.batch_size):
209-
yield items[i : i + self._client.batch_size]
210-
211-
def create(self, items: T | list[T]) -> None:
212-
"""Create one or multiple items."""
213-
if not isinstance(items, list):
214-
items = [items]
215-
216-
for batch in self._split_into_batches(items):
217-
self._create(batch)
218-
219-
@abc.abstractmethod
220-
def _delete(self, items: list[T]) -> None: ...
221-
222-
def delete(self, items: T | list[T]) -> None:
223-
"""Delete one or multiple items."""
224-
if not isinstance(items, list):
225-
items = [items]
226-
for batch in self._split_into_batches(items):
227-
self._delete(batch)
326+
async def a_get_all(self, *args: t.Any, **kwargs: t.Any) -> list[T]:
327+
"""Return all matching items using get_multi with auto pagination."""
328+
page = 1
329+
items, next_page = await self.a_get_multi(
330+
*args, page_size=self._client.page_size, page_number=page, **kwargs
331+
)
332+
while next_page:
333+
page += 1
334+
_items, next_page = await self.a_get_multi(
335+
*args,
336+
page_size=self._client.page_size,
337+
page_number=page,
338+
**kwargs,
339+
)
340+
items += _items
341+
return items
228342

229343

230-
class UpdatableItemsClient(ItemsClient, t.Generic[T], abc.ABC):
344+
class UpdateClient(BaseClient[T], abc.ABC):
231345
"""A client for items which can also be updated."""
232346

233-
_retry_methods: t.ClassVar[list[str]] = [
234-
"get_multi",
235-
"get",
236-
"_create",
237-
"_delete",
347+
_retry_methods: t.ClassVar[set[str]] = {
238348
"_update",
239-
]
349+
}
350+
_update_batch_size: int = 0
240351

241352
def _split_into_update_batches(
242353
self, items: list[T]
243-
) -> t.Generator[list[T], None, None] | t.Generator[T, None, None]:
244-
yield from self._split_into_batches(items)
354+
) -> t.Generator[list[T], None, None]:
355+
batch_size = self._update_batch_size or self._client.batch_size
356+
for it in self._pre_batching_grouping(items):
357+
for i in range(0, len(it), batch_size):
358+
yield it[i : i + batch_size]
245359

246360
@abc.abstractmethod
247-
def _update(self, to_update: T | list[T]) -> None: ...
361+
def _update(self, to_update: list[T]) -> None: ...
362+
363+
@abc.abstractmethod
364+
async def _a_update(self, to_update: list[T]) -> None: ...
248365

249366
def update(self, items: T | list[T]) -> None:
250367
"""Update the provided item or items."""
@@ -254,17 +371,16 @@ def update(self, items: T | list[T]) -> None:
254371
for batch in self._split_into_update_batches(items):
255372
self._update(batch)
256373

374+
async def a_update(self, items: T | list[T]) -> None:
375+
"""Update the provided item or items."""
376+
if not isinstance(items, list):
377+
items = [items]
257378

258-
class SingleUpdatableItemsMixin(t.Generic[T]):
259-
"""Mixin to split batches into single items."""
260-
261-
def _split_into_update_batches(
262-
self, items: list[T]
263-
) -> t.Generator[T, None, None]:
264-
yield from items
379+
for batch in self._split_into_update_batches(items):
380+
await self._a_update(batch)
265381

266382

267-
class StatusItemClient(UpdatableItemsClient, t.Generic[ST], abc.ABC):
383+
class StatusItemClient(UpdateClient, DeleteClient, t.Generic[ST], abc.ABC):
268384
"""A client for items, which have a status.
269385
270386
We support to set a specific status for these instead of deleting
@@ -287,12 +403,24 @@ def delete(self, items: ST | list[ST]) -> None:
287403
if self.delete_status is None:
288404
super().delete(items)
289405
else:
290-
if not isinstance(items, list):
291-
items = [items]
292-
delete_items: list[ST] = []
293-
for item in items:
294-
item.status = self.delete_status
295-
delete_items.append(
296-
self.item_cls(id=item.id, status=self.delete_status)
297-
)
406+
delete_items = self._prepare_update_items(items)
298407
self.update(delete_items)
408+
409+
async def a_delete(self, items: ST | list[ST]) -> None:
410+
"""Delete the item if no delete_status was set, else update status."""
411+
if self.delete_status is None:
412+
await super().a_delete(items)
413+
else:
414+
delete_items = self._prepare_update_items(items)
415+
await self.a_update(delete_items)
416+
417+
def _prepare_update_items(self, items: ST | list[ST]) -> list[ST]:
418+
if not isinstance(items, list):
419+
items = [items]
420+
delete_items: list[ST] = []
421+
for item in items:
422+
item.status = self.delete_status
423+
delete_items.append(
424+
self.item_cls(id=item.id, status=self.delete_status)
425+
)
426+
return delete_items

0 commit comments

Comments
 (0)