glpi-python-client 0.3.0__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,7 +72,7 @@ from glpi_python_client.models import (
72
72
  TicketMarkdownOptions,
73
73
  )
74
74
 
75
- __version__ = "0.3.0"
75
+ __version__ = "0.3.2"
76
76
 
77
77
  __all__ = [
78
78
  "AsyncGlpiClient",
@@ -7,6 +7,8 @@ client's ``GLPI-Entity`` header so cross-entity lookups remain possible.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from collections.abc import Iterator
11
+
10
12
  from glpi_python_client.clients.commons._constants import ENTITY_ENDPOINT, GlpiId
11
13
  from glpi_python_client.clients.commons._transport import TransportMixin
12
14
  from glpi_python_client.models.api_schema.administration._entity import (
@@ -54,6 +56,48 @@ class EntityMixin(TransportMixin):
54
56
  ENTITY_ENDPOINT, GetEntity, params=params, skip_entity=True
55
57
  )
56
58
 
59
+ def iter_search_entities(
60
+ self,
61
+ rsql_filter: str = "",
62
+ *,
63
+ batch_size: int = 50,
64
+ ) -> Iterator[list[GetEntity]]:
65
+ """Yield successive pages of GLPI entities until exhausted.
66
+
67
+ The generator drives pagination automatically by advancing the
68
+ ``start`` offset after each batch. Iteration stops when the server
69
+ returns fewer items than ``batch_size``, which signals the last page.
70
+ Entity calls bypass the ``GLPI-Entity`` header so cross-entity
71
+ lookups remain possible.
72
+
73
+ Parameters
74
+ ----------
75
+ rsql_filter : str, optional
76
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
77
+ Empty by default, which lists every accessible entity.
78
+ batch_size : int, optional
79
+ Number of records requested per page (default 50).
80
+
81
+ Yields
82
+ ------
83
+ list[GetEntity]
84
+ One page of entities per iteration. The last yielded batch may
85
+ be shorter than ``batch_size``.
86
+ """
87
+
88
+ start = 0
89
+ while True:
90
+ batch = self.search_entities(
91
+ rsql_filter,
92
+ limit=batch_size,
93
+ start=start,
94
+ )
95
+ if batch:
96
+ yield batch
97
+ if len(batch) < batch_size:
98
+ break
99
+ start += batch_size
100
+
57
101
  def get_entity(self, entity_id: GlpiId) -> GetEntity:
58
102
  """Fetch one GLPI entity by identifier.
59
103
 
@@ -8,6 +8,8 @@ the Synchronous transport mixin for HTTP dispatch.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ from collections.abc import Iterator
12
+
11
13
  from glpi_python_client.clients.commons._constants import USER_ENDPOINT, GlpiId
12
14
  from glpi_python_client.clients.commons._transport import TransportMixin
13
15
  from glpi_python_client.models.api_schema.administration._user import (
@@ -63,6 +65,51 @@ class UserMixin(TransportMixin):
63
65
  USER_ENDPOINT, GetUser, params=params, skip_entity=skip_entity
64
66
  )
65
67
 
68
+ def iter_search_users(
69
+ self,
70
+ rsql_filter: str = "",
71
+ *,
72
+ batch_size: int = 50,
73
+ skip_entity: bool = False,
74
+ ) -> Iterator[list[GetUser]]:
75
+ """Yield successive pages of GLPI users until exhausted.
76
+
77
+ The generator drives pagination automatically by advancing the
78
+ ``start`` offset after each batch. Iteration stops when the server
79
+ returns fewer items than ``batch_size``, which signals the last page.
80
+
81
+ Parameters
82
+ ----------
83
+ rsql_filter : str, optional
84
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
85
+ Empty by default, which lists every visible user.
86
+ batch_size : int, optional
87
+ Number of records requested per page (default 50).
88
+ skip_entity : bool, optional
89
+ When ``True`` the ``GLPI-Entity`` header is omitted so the
90
+ search spans every entity the caller has access to.
91
+
92
+ Yields
93
+ ------
94
+ list[GetUser]
95
+ One page of users per iteration. The last yielded batch may
96
+ be shorter than ``batch_size``.
97
+ """
98
+
99
+ start = 0
100
+ while True:
101
+ batch = self.search_users(
102
+ rsql_filter,
103
+ limit=batch_size,
104
+ start=start,
105
+ skip_entity=skip_entity,
106
+ )
107
+ if batch:
108
+ yield batch
109
+ if len(batch) < batch_size:
110
+ break
111
+ start += batch_size
112
+
66
113
  def get_user(self, user_id: GlpiId) -> GetUser:
67
114
  """Fetch one GLPI user by identifier.
68
115
 
@@ -6,6 +6,8 @@ GLPI ticket resource using the ``api_schema`` Pydantic models.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from collections.abc import Iterator
10
+
9
11
  from glpi_python_client.clients.commons._constants import TICKET_ENDPOINT, GlpiId
10
12
  from glpi_python_client.clients.commons._transport import TransportMixin
11
13
  from glpi_python_client.models.api_schema.assistance._ticket import (
@@ -68,6 +70,56 @@ class TicketMixin(TransportMixin):
68
70
  params["fields"] = ",".join(fields)
69
71
  return self._resource_list(TICKET_ENDPOINT, GetTicket, params=params)
70
72
 
73
+ def iter_search_tickets(
74
+ self,
75
+ rsql_filter: str = "",
76
+ *,
77
+ batch_size: int = 50,
78
+ sort: str | None = None,
79
+ fields: tuple[str, ...] = (),
80
+ ) -> Iterator[list[GetTicket]]:
81
+ """Yield successive pages of GLPI tickets until exhausted.
82
+
83
+ The generator drives pagination automatically by advancing the
84
+ ``start`` offset after each batch. Iteration stops when the server
85
+ returns fewer items than ``batch_size``, which signals the last page.
86
+
87
+ Parameters
88
+ ----------
89
+ rsql_filter : str, optional
90
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
91
+ Empty by default, which lists every visible ticket.
92
+ batch_size : int, optional
93
+ Number of records requested per page (default 50). Acts as
94
+ the ``limit`` parameter on each underlying
95
+ :meth:`search_tickets` call.
96
+ sort : str | None, optional
97
+ ``sort`` query parameter forwarded as-is to each page request.
98
+ fields : tuple[str, ...], optional
99
+ Restricted set of contract field names to request.
100
+
101
+ Yields
102
+ ------
103
+ list[GetTicket]
104
+ One page of tickets per iteration. The last yielded batch may
105
+ be shorter than ``batch_size``.
106
+ """
107
+
108
+ start = 0
109
+ while True:
110
+ batch = self.search_tickets(
111
+ rsql_filter,
112
+ limit=batch_size,
113
+ start=start,
114
+ sort=sort,
115
+ fields=fields,
116
+ )
117
+ if batch:
118
+ yield batch
119
+ if len(batch) < batch_size:
120
+ break
121
+ start += batch_size
122
+
71
123
  def get_ticket(self, ticket_id: GlpiId) -> GetTicket:
72
124
  """Fetch one GLPI ticket by identifier.
73
125
 
@@ -50,6 +50,7 @@ from glpi_python_client.clients.commons._config import (
50
50
  build_client_resources,
51
51
  )
52
52
  from glpi_python_client.clients.commons._transport import TransportMixin
53
+ from glpi_python_client.clients.custom._pagination_async import AsyncPaginationMixin
53
54
  from glpi_python_client.clients.custom._statistics_async import AsyncStatisticsMixin
54
55
  from glpi_python_client.clients.custom._ticket_context_async import (
55
56
  AsyncTicketContextMixin,
@@ -61,8 +62,9 @@ if TYPE_CHECKING:
61
62
  logger = logging.getLogger(__name__)
62
63
 
63
64
 
64
- class AsyncGlpiClient(
65
+ class AsyncGlpiClient( # type: ignore[misc]
65
66
  AsyncBridge,
67
+ AsyncPaginationMixin,
66
68
  TicketMixin,
67
69
  TicketTaskMixin,
68
70
  FollowupMixin,
@@ -22,6 +22,23 @@ Concurrency notes
22
22
  releases the awaiter immediately, but the in-flight HTTP request keeps
23
23
  running on the worker thread until ``requests`` returns. This matches
24
24
  the behaviour of the original async client.
25
+
26
+ Known limitation — internal ``self``-calls
27
+ ------------------------------------------
28
+ The bridge wraps *every* public method on the async client class. As a
29
+ result, when a synchronous body that is running inside a worker thread
30
+ calls another public method through ``self`` (e.g.
31
+ ``self.search_tickets(...)``), it resolves to the *bridge-wrapped*
32
+ coroutine, not the synchronous function. Calling a coroutine without
33
+ ``await`` produces a dangling coroutine object, not data.
34
+
35
+ Any sync method (or generator) that internally calls other public
36
+ methods through ``self`` must therefore be given a hand-written async
37
+ override that ``await``s (or ``async for``s) those calls on the event
38
+ loop. The convention used in this codebase is to place such overrides
39
+ in a ``_*_async.py`` companion module (e.g.
40
+ ``clients/custom/_statistics_async.py``) and wire them into the async
41
+ client's MRO *before* the sync mixin that defines the original method.
25
42
  """
26
43
 
27
44
  from __future__ import annotations
@@ -33,6 +50,19 @@ from collections.abc import Callable
33
50
  from concurrent.futures import Executor
34
51
  from typing import Any
35
52
 
53
+ # Sentinel used by the async-generator bridge to signal exhaustion without
54
+ # propagating StopIteration through a coroutine (which PEP 479 forbids).
55
+ _STOPPED: object = object()
56
+
57
+
58
+ def _next_or_stopped(gen: Any) -> Any:
59
+ """Return the next item from *gen* or ``_STOPPED`` when exhausted."""
60
+
61
+ try:
62
+ return next(gen)
63
+ except StopIteration:
64
+ return _STOPPED
65
+
36
66
 
37
67
  class AsyncBridge:
38
68
  """Base class that converts inherited sync methods into coroutines.
@@ -78,14 +108,22 @@ class AsyncBridge:
78
108
  continue
79
109
  if not callable(member) or inspect.iscoroutinefunction(member):
80
110
  continue
111
+ if inspect.isasyncgenfunction(member):
112
+ continue
81
113
  # Skip if the subclass already overrides the method with
82
- # a coroutine function (for example async fan-outs).
114
+ # a coroutine function or async generator (for example async fan-outs).
83
115
  existing = getattr(cls, name, None)
84
- if existing is not None and inspect.iscoroutinefunction(existing):
116
+ if existing is not None and (
117
+ inspect.iscoroutinefunction(existing)
118
+ or inspect.isasyncgenfunction(existing)
119
+ ):
85
120
  seen.add(name)
86
121
  continue
87
122
  seen.add(name)
88
- setattr(cls, name, _make_async_wrapper(member))
123
+ if inspect.isgeneratorfunction(member):
124
+ setattr(cls, name, _make_async_generator_wrapper(member))
125
+ else:
126
+ setattr(cls, name, _make_async_wrapper(member))
89
127
 
90
128
 
91
129
  def _make_async_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]:
@@ -115,4 +153,39 @@ def _make_async_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]:
115
153
  return wrapper
116
154
 
117
155
 
156
+ def _make_async_generator_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]:
157
+ """Return an async generator wrapper for a synchronous generator function.
158
+
159
+ Each call to ``next()`` on the underlying sync generator is dispatched
160
+ to a worker thread so that the blocking HTTP call inside the generator
161
+ body does not block the event loop.
162
+
163
+ Parameters
164
+ ----------
165
+ sync_func : Callable[..., Any]
166
+ Synchronous generator function inherited from a sync mixin.
167
+
168
+ Returns
169
+ -------
170
+ Callable[..., Any]
171
+ Async generator function that yields the same items as the
172
+ synchronous generator, one batch at a time, off the event loop.
173
+ """
174
+
175
+ @functools.wraps(sync_func)
176
+ async def wrapper(self: AsyncBridge, *args: Any, **kwargs: Any) -> Any:
177
+ gen = sync_func(self, *args, **kwargs)
178
+ while True:
179
+ if self._executor is not None:
180
+ loop = asyncio.get_running_loop()
181
+ item = await loop.run_in_executor(self._executor, _next_or_stopped, gen)
182
+ else:
183
+ item = await asyncio.to_thread(_next_or_stopped, gen)
184
+ if item is _STOPPED:
185
+ return
186
+ yield item
187
+
188
+ return wrapper
189
+
190
+
118
191
  __all__ = ["AsyncBridge"]
@@ -17,6 +17,7 @@ composed into :class:`~glpi_python_client.clients.AsyncGlpiClient`.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ from glpi_python_client.clients.custom._pagination_async import AsyncPaginationMixin
20
21
  from glpi_python_client.clients.custom._statistics import StatisticsMixin
21
22
  from glpi_python_client.clients.custom._statistics_async import AsyncStatisticsMixin
22
23
  from glpi_python_client.clients.custom._ticket_context import TicketContextMixin
@@ -25,6 +26,7 @@ from glpi_python_client.clients.custom._ticket_context_async import (
25
26
  )
26
27
 
27
28
  __all__ = [
29
+ "AsyncPaginationMixin",
28
30
  "AsyncStatisticsMixin",
29
31
  "AsyncTicketContextMixin",
30
32
  "StatisticsMixin",
@@ -0,0 +1,164 @@
1
+ """Asynchronous overrides for the three paginated search generators.
2
+
3
+ Each of the three ``iter_search_*`` generators in the API mixins
4
+ delegates its pagination loop to a ``self.search_*()`` call. When the
5
+ :class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`
6
+ wraps a sync generator it drives ``next()`` on the generator object inside
7
+ a worker thread. Inside that thread ``self`` is still the
8
+ :class:`~glpi_python_client.clients.AsyncGlpiClient` instance, so
9
+ ``self.search_tickets(...)`` resolves to the bridge-wrapped coroutine
10
+ function and calling it without ``await`` returns a coroutine object
11
+ instead of the expected list — triggering a
12
+ ``RuntimeWarning: coroutine … was never awaited`` and a 500 response.
13
+
14
+ This mixin replaces all three generators with native async generators that
15
+ ``await self.search_*(...)`` directly on the event loop.
16
+
17
+ The mixin must be positioned **before** ``TicketMixin``, ``UserMixin``, and
18
+ ``EntityMixin`` in the :class:`~glpi_python_client.clients.AsyncGlpiClient`
19
+ base list so that the bridge's ``__init_subclass__`` hook finds the async
20
+ generator via ``getattr`` before it would otherwise wrap the sync version.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import AsyncIterator
26
+
27
+ from glpi_python_client.models.api_schema.administration._entity import GetEntity
28
+ from glpi_python_client.models.api_schema.administration._user import GetUser
29
+ from glpi_python_client.models.api_schema.assistance._ticket import GetTicket
30
+
31
+
32
+ class AsyncPaginationMixin:
33
+ """Async generator overrides for the three paginated search helpers.
34
+
35
+ Each override re-implements the simple ``start``-advancing loop of
36
+ the synchronous counterpart but ``await``\\ s the underlying
37
+ ``search_*`` call so it runs on the event loop rather than inside a
38
+ worker-thread-dispatched ``next()`` call where the coroutine would
39
+ be dropped on the floor.
40
+ """
41
+
42
+ async def iter_search_tickets(
43
+ self,
44
+ rsql_filter: str = "",
45
+ *,
46
+ batch_size: int = 50,
47
+ sort: str | None = None,
48
+ fields: tuple[str, ...] = (),
49
+ ) -> AsyncIterator[list[GetTicket]]:
50
+ """Yield successive pages of GLPI tickets until exhausted.
51
+
52
+ Parameters
53
+ ----------
54
+ rsql_filter : str, optional
55
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
56
+ Empty by default, which lists every visible ticket.
57
+ batch_size : int, optional
58
+ Number of records requested per page (default 50).
59
+ sort : str | None, optional
60
+ ``sort`` query parameter forwarded as-is to each page request.
61
+ fields : tuple[str, ...], optional
62
+ Restricted set of contract field names to request.
63
+
64
+ Yields
65
+ ------
66
+ list[GetTicket]
67
+ One page of tickets per iteration. The last yielded batch may
68
+ be shorter than ``batch_size``.
69
+ """
70
+
71
+ start = 0
72
+ while True:
73
+ batch: list[GetTicket] = await self.search_tickets( # type: ignore[attr-defined]
74
+ rsql_filter,
75
+ limit=batch_size,
76
+ start=start,
77
+ sort=sort,
78
+ fields=fields,
79
+ )
80
+ if batch:
81
+ yield batch
82
+ if len(batch) < batch_size:
83
+ break
84
+ start += batch_size
85
+
86
+ async def iter_search_users(
87
+ self,
88
+ rsql_filter: str = "",
89
+ *,
90
+ batch_size: int = 50,
91
+ skip_entity: bool = False,
92
+ ) -> AsyncIterator[list[GetUser]]:
93
+ """Yield successive pages of GLPI users until exhausted.
94
+
95
+ Parameters
96
+ ----------
97
+ rsql_filter : str, optional
98
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
99
+ Empty by default, which lists every visible user.
100
+ batch_size : int, optional
101
+ Number of records requested per page (default 50).
102
+ skip_entity : bool, optional
103
+ When ``True`` the ``GLPI-Entity`` header is omitted so the
104
+ search spans every entity the caller has access to.
105
+
106
+ Yields
107
+ ------
108
+ list[GetUser]
109
+ One page of users per iteration. The last yielded batch may
110
+ be shorter than ``batch_size``.
111
+ """
112
+
113
+ start = 0
114
+ while True:
115
+ batch: list[GetUser] = await self.search_users( # type: ignore[attr-defined]
116
+ rsql_filter,
117
+ limit=batch_size,
118
+ start=start,
119
+ skip_entity=skip_entity,
120
+ )
121
+ if batch:
122
+ yield batch
123
+ if len(batch) < batch_size:
124
+ break
125
+ start += batch_size
126
+
127
+ async def iter_search_entities(
128
+ self,
129
+ rsql_filter: str = "",
130
+ *,
131
+ batch_size: int = 50,
132
+ ) -> AsyncIterator[list[GetEntity]]:
133
+ """Yield successive pages of GLPI entities until exhausted.
134
+
135
+ Parameters
136
+ ----------
137
+ rsql_filter : str, optional
138
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
139
+ Empty by default, which lists every accessible entity.
140
+ batch_size : int, optional
141
+ Number of records requested per page (default 50).
142
+
143
+ Yields
144
+ ------
145
+ list[GetEntity]
146
+ One page of entities per iteration. The last yielded batch
147
+ may be shorter than ``batch_size``.
148
+ """
149
+
150
+ start = 0
151
+ while True:
152
+ batch: list[GetEntity] = await self.search_entities( # type: ignore[attr-defined]
153
+ rsql_filter,
154
+ limit=batch_size,
155
+ start=start,
156
+ )
157
+ if batch:
158
+ yield batch
159
+ if len(batch) < batch_size:
160
+ break
161
+ start += batch_size
162
+
163
+
164
+ __all__ = ["AsyncPaginationMixin"]