glpi-python-client 0.3.0__py3-none-any.whl → 0.3.1__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.
- glpi_python_client/__init__.py +1 -1
- glpi_python_client/clients/api/administration/_entity.py +44 -0
- glpi_python_client/clients/api/administration/_user.py +47 -0
- glpi_python_client/clients/api/assistance/_ticket.py +52 -0
- glpi_python_client/clients/commons/_async_bridge.py +59 -3
- glpi_python_client/clients/custom/_statistics.py +431 -18
- glpi_python_client/clients/custom/_statistics_async.py +198 -17
- glpi_python_client/clients/custom/tests/test_statistics.py +368 -0
- glpi_python_client/clients/custom/tests/test_statistics_async.py +232 -0
- glpi_python_client/clients/tests/test_api_coverage.py +175 -0
- glpi_python_client/clients/tests/test_async_branches.py +58 -0
- glpi_python_client/clients/tests/test_parity.py +10 -4
- {glpi_python_client-0.3.0.dist-info → glpi_python_client-0.3.1.dist-info}/METADATA +1 -1
- {glpi_python_client-0.3.0.dist-info → glpi_python_client-0.3.1.dist-info}/RECORD +16 -15
- {glpi_python_client-0.3.0.dist-info → glpi_python_client-0.3.1.dist-info}/WHEEL +0 -0
- {glpi_python_client-0.3.0.dist-info → glpi_python_client-0.3.1.dist-info}/licenses/LICENSE +0 -0
glpi_python_client/__init__.py
CHANGED
|
@@ -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
|
|
|
@@ -33,6 +33,19 @@ from collections.abc import Callable
|
|
|
33
33
|
from concurrent.futures import Executor
|
|
34
34
|
from typing import Any
|
|
35
35
|
|
|
36
|
+
# Sentinel used by the async-generator bridge to signal exhaustion without
|
|
37
|
+
# propagating StopIteration through a coroutine (which PEP 479 forbids).
|
|
38
|
+
_STOPPED: object = object()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _next_or_stopped(gen: Any) -> Any:
|
|
42
|
+
"""Return the next item from *gen* or ``_STOPPED`` when exhausted."""
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
return next(gen)
|
|
46
|
+
except StopIteration:
|
|
47
|
+
return _STOPPED
|
|
48
|
+
|
|
36
49
|
|
|
37
50
|
class AsyncBridge:
|
|
38
51
|
"""Base class that converts inherited sync methods into coroutines.
|
|
@@ -78,14 +91,22 @@ class AsyncBridge:
|
|
|
78
91
|
continue
|
|
79
92
|
if not callable(member) or inspect.iscoroutinefunction(member):
|
|
80
93
|
continue
|
|
94
|
+
if inspect.isasyncgenfunction(member):
|
|
95
|
+
continue
|
|
81
96
|
# Skip if the subclass already overrides the method with
|
|
82
|
-
# a coroutine function (for example async fan-outs).
|
|
97
|
+
# a coroutine function or async generator (for example async fan-outs).
|
|
83
98
|
existing = getattr(cls, name, None)
|
|
84
|
-
if existing is not None and
|
|
99
|
+
if existing is not None and (
|
|
100
|
+
inspect.iscoroutinefunction(existing)
|
|
101
|
+
or inspect.isasyncgenfunction(existing)
|
|
102
|
+
):
|
|
85
103
|
seen.add(name)
|
|
86
104
|
continue
|
|
87
105
|
seen.add(name)
|
|
88
|
-
|
|
106
|
+
if inspect.isgeneratorfunction(member):
|
|
107
|
+
setattr(cls, name, _make_async_generator_wrapper(member))
|
|
108
|
+
else:
|
|
109
|
+
setattr(cls, name, _make_async_wrapper(member))
|
|
89
110
|
|
|
90
111
|
|
|
91
112
|
def _make_async_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]:
|
|
@@ -115,4 +136,39 @@ def _make_async_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
115
136
|
return wrapper
|
|
116
137
|
|
|
117
138
|
|
|
139
|
+
def _make_async_generator_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]:
|
|
140
|
+
"""Return an async generator wrapper for a synchronous generator function.
|
|
141
|
+
|
|
142
|
+
Each call to ``next()`` on the underlying sync generator is dispatched
|
|
143
|
+
to a worker thread so that the blocking HTTP call inside the generator
|
|
144
|
+
body does not block the event loop.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
sync_func : Callable[..., Any]
|
|
149
|
+
Synchronous generator function inherited from a sync mixin.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
Callable[..., Any]
|
|
154
|
+
Async generator function that yields the same items as the
|
|
155
|
+
synchronous generator, one batch at a time, off the event loop.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
@functools.wraps(sync_func)
|
|
159
|
+
async def wrapper(self: AsyncBridge, *args: Any, **kwargs: Any) -> Any:
|
|
160
|
+
gen = sync_func(self, *args, **kwargs)
|
|
161
|
+
while True:
|
|
162
|
+
if self._executor is not None:
|
|
163
|
+
loop = asyncio.get_running_loop()
|
|
164
|
+
item = await loop.run_in_executor(self._executor, _next_or_stopped, gen)
|
|
165
|
+
else:
|
|
166
|
+
item = await asyncio.to_thread(_next_or_stopped, gen)
|
|
167
|
+
if item is _STOPPED:
|
|
168
|
+
return
|
|
169
|
+
yield item
|
|
170
|
+
|
|
171
|
+
return wrapper
|
|
172
|
+
|
|
173
|
+
|
|
118
174
|
__all__ = ["AsyncBridge"]
|