glpi-python-client 0.3.1__py3-none-any.whl → 0.3.3__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/assistance/timeline/_document.py +21 -20
- glpi_python_client/clients/async_client.py +3 -1
- glpi_python_client/clients/commons/_async_bridge.py +17 -0
- glpi_python_client/clients/custom/__init__.py +2 -0
- glpi_python_client/clients/custom/_pagination_async.py +164 -0
- glpi_python_client/clients/custom/_statistics.py +21 -16
- glpi_python_client/clients/custom/_statistics_async.py +264 -14
- glpi_python_client/clients/custom/tests/test_statistics.py +2 -2
- glpi_python_client/clients/tests/test_api_coverage.py +2 -2
- glpi_python_client/clients/tests/test_async_branches.py +506 -0
- glpi_python_client/models/custom_schema/_ticket_context.py +12 -9
- glpi_python_client/models/custom_schema/tests/test_ticket_context.py +7 -7
- {glpi_python_client-0.3.1.dist-info → glpi_python_client-0.3.3.dist-info}/METADATA +1 -1
- {glpi_python_client-0.3.1.dist-info → glpi_python_client-0.3.3.dist-info}/RECORD +17 -16
- {glpi_python_client-0.3.1.dist-info → glpi_python_client-0.3.3.dist-info}/WHEEL +0 -0
- {glpi_python_client-0.3.1.dist-info → glpi_python_client-0.3.3.dist-info}/licenses/LICENSE +0 -0
glpi_python_client/__init__.py
CHANGED
|
@@ -7,11 +7,12 @@ Notes
|
|
|
7
7
|
-----
|
|
8
8
|
The live GLPI v2 server returns each entry of the list endpoint wrapped
|
|
9
9
|
in a ``{"type": "Document_Item", "item": {...}}`` envelope, even though
|
|
10
|
-
the OpenAPI contract documents a flat array of ``Document_Item``.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
:
|
|
14
|
-
helper and
|
|
10
|
+
the OpenAPI contract documents a flat array of ``Document_Item``. The
|
|
11
|
+
``item`` value is a full ``Document`` record (matching :class:`GetDocument`),
|
|
12
|
+
not a ``Document_Item`` link record — real behaviour wins over the contract.
|
|
13
|
+
:func:`list_ticket_timeline_documents` unwraps the envelope through the shared
|
|
14
|
+
``TransportMixin._resource_list`` helper and deserialises each inner object
|
|
15
|
+
as :class:`GetDocument`.
|
|
15
16
|
"""
|
|
16
17
|
|
|
17
18
|
from __future__ import annotations
|
|
@@ -24,19 +25,17 @@ from glpi_python_client.clients.commons._constants import (
|
|
|
24
25
|
from glpi_python_client.clients.commons._transport import TransportMixin
|
|
25
26
|
from glpi_python_client.models.api_schema.assistance.timeline._document import (
|
|
26
27
|
DeleteTimelineDocument,
|
|
27
|
-
GetTimelineDocument,
|
|
28
28
|
PatchTimelineDocument,
|
|
29
29
|
PostTimelineDocument,
|
|
30
30
|
)
|
|
31
|
+
from glpi_python_client.models.api_schema.management._document import GetDocument
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
class TimelineDocumentMixin(TransportMixin):
|
|
34
35
|
"""Synchronous CRUD helpers for the ticket document timeline endpoint."""
|
|
35
36
|
|
|
36
|
-
def list_ticket_timeline_documents(
|
|
37
|
-
|
|
38
|
-
) -> list[GetTimelineDocument]:
|
|
39
|
-
"""List all timeline documents linked to one ticket.
|
|
37
|
+
def list_ticket_timeline_documents(self, ticket_id: GlpiId) -> list[GetDocument]:
|
|
38
|
+
"""List all documents linked to one ticket timeline.
|
|
40
39
|
|
|
41
40
|
Parameters
|
|
42
41
|
----------
|
|
@@ -45,14 +44,16 @@ class TimelineDocumentMixin(TransportMixin):
|
|
|
45
44
|
|
|
46
45
|
Returns
|
|
47
46
|
-------
|
|
48
|
-
list[
|
|
49
|
-
Document
|
|
50
|
-
|
|
47
|
+
list[GetDocument]
|
|
48
|
+
Document records returned by the GLPI server. The live API
|
|
49
|
+
wraps each entry in a ``{"type": "Document_Item", "item": {...}}``
|
|
50
|
+
envelope whose ``item`` value is a full ``Document`` record; the
|
|
51
|
+
envelope is unwrapped automatically.
|
|
51
52
|
"""
|
|
52
53
|
|
|
53
54
|
return self._resource_list(
|
|
54
55
|
f"{TICKET_ENDPOINT}/{ticket_id}/{TIMELINE_DOCUMENT_SUFFIX}",
|
|
55
|
-
|
|
56
|
+
GetDocument,
|
|
56
57
|
failure_message=(
|
|
57
58
|
f"Failed to list timeline documents for ticket {ticket_id}"
|
|
58
59
|
),
|
|
@@ -61,20 +62,20 @@ class TimelineDocumentMixin(TransportMixin):
|
|
|
61
62
|
|
|
62
63
|
def get_ticket_timeline_document(
|
|
63
64
|
self, ticket_id: GlpiId, document_link_id: GlpiId
|
|
64
|
-
) ->
|
|
65
|
-
"""Fetch one
|
|
65
|
+
) -> GetDocument:
|
|
66
|
+
"""Fetch one document linked to the ticket timeline by its document ID.
|
|
66
67
|
|
|
67
68
|
Parameters
|
|
68
69
|
----------
|
|
69
70
|
ticket_id : GlpiId
|
|
70
71
|
Numeric identifier of the parent ticket.
|
|
71
72
|
document_link_id : GlpiId
|
|
72
|
-
Numeric identifier of the
|
|
73
|
+
Numeric identifier of the linked document to retrieve.
|
|
73
74
|
|
|
74
75
|
Returns
|
|
75
76
|
-------
|
|
76
|
-
|
|
77
|
-
Validated document
|
|
77
|
+
GetDocument
|
|
78
|
+
Validated document payload.
|
|
78
79
|
|
|
79
80
|
Raises
|
|
80
81
|
------
|
|
@@ -85,7 +86,7 @@ class TimelineDocumentMixin(TransportMixin):
|
|
|
85
86
|
return self._resource_get(
|
|
86
87
|
f"{TICKET_ENDPOINT}/{ticket_id}/"
|
|
87
88
|
f"{TIMELINE_DOCUMENT_SUFFIX}/{document_link_id}",
|
|
88
|
-
|
|
89
|
+
GetDocument,
|
|
89
90
|
failure_message=(
|
|
90
91
|
f"Failed to get timeline document {document_link_id} on "
|
|
91
92
|
f"ticket {ticket_id}"
|
|
@@ -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
|
|
@@ -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"]
|
|
@@ -95,10 +95,12 @@ class StatisticsMixin(TransportMixin):
|
|
|
95
95
|
Parameters
|
|
96
96
|
----------
|
|
97
97
|
start_date : str | None, optional
|
|
98
|
-
ISO ``YYYY-MM-DD`` start of the window
|
|
99
|
-
``end_date - default_days + 1``
|
|
98
|
+
ISO ``YYYY-MM-DD`` start of the window (inclusive from
|
|
99
|
+
00:00:00). Defaults to ``end_date - default_days + 1``
|
|
100
|
+
when omitted.
|
|
100
101
|
end_date : str | None, optional
|
|
101
|
-
ISO ``YYYY-MM-DD`` end of the window
|
|
102
|
+
ISO ``YYYY-MM-DD`` end of the window (inclusive through
|
|
103
|
+
23:59:59). Defaults to today.
|
|
102
104
|
default_days : int, optional
|
|
103
105
|
Span in days used when ``start_date`` is omitted (defaults
|
|
104
106
|
to 30 and must be a positive integer).
|
|
@@ -147,9 +149,10 @@ class StatisticsMixin(TransportMixin):
|
|
|
147
149
|
entity_filter = rsql_any_filter(
|
|
148
150
|
*(f"entities_id=={e.id}" for e in entities if e.id is not None)
|
|
149
151
|
)
|
|
150
|
-
|
|
152
|
+
date_filter = f"date_creation=ge={start.isoformat()};"
|
|
153
|
+
date_filter += f"date_creation=le={end.isoformat()} 23:59:59"
|
|
151
154
|
query = rsql_all_filter(
|
|
152
|
-
|
|
155
|
+
date_filter,
|
|
153
156
|
entity_filter,
|
|
154
157
|
extra_filter,
|
|
155
158
|
)
|
|
@@ -226,10 +229,12 @@ class StatisticsMixin(TransportMixin):
|
|
|
226
229
|
Parameters
|
|
227
230
|
----------
|
|
228
231
|
start_date : str | None, optional
|
|
229
|
-
ISO ``YYYY-MM-DD`` start of the window
|
|
230
|
-
``end_date - default_days + 1``
|
|
232
|
+
ISO ``YYYY-MM-DD`` start of the window (inclusive from
|
|
233
|
+
00:00:00). Defaults to ``end_date - default_days + 1``
|
|
234
|
+
when omitted.
|
|
231
235
|
end_date : str | None, optional
|
|
232
|
-
ISO ``YYYY-MM-DD`` end of the window
|
|
236
|
+
ISO ``YYYY-MM-DD`` end of the window (inclusive through
|
|
237
|
+
23:59:59). Defaults to today.
|
|
233
238
|
default_days : int, optional
|
|
234
239
|
Span in days used when ``start_date`` is omitted (defaults
|
|
235
240
|
to 30 and must be a positive integer).
|
|
@@ -269,9 +274,8 @@ class StatisticsMixin(TransportMixin):
|
|
|
269
274
|
end_date=end_date,
|
|
270
275
|
default_days=default_days,
|
|
271
276
|
)
|
|
272
|
-
date_filter = (
|
|
273
|
-
|
|
274
|
-
)
|
|
277
|
+
date_filter = f"date_creation=ge={start.isoformat()};"
|
|
278
|
+
date_filter += f"date_creation=le={end.isoformat()} 23:59:59"
|
|
275
279
|
|
|
276
280
|
entity_filter: str | None = None
|
|
277
281
|
if entity_id is not None:
|
|
@@ -399,9 +403,11 @@ class StatisticsMixin(TransportMixin):
|
|
|
399
403
|
firstname : str | None, optional
|
|
400
404
|
Filter by given name (substring match).
|
|
401
405
|
start_date : str | None, optional
|
|
402
|
-
ISO ``YYYY-MM-DD`` start of the activity window
|
|
406
|
+
ISO ``YYYY-MM-DD`` start of the activity window (inclusive
|
|
407
|
+
from 00:00:00).
|
|
403
408
|
end_date : str | None, optional
|
|
404
|
-
ISO ``YYYY-MM-DD`` end of the activity window
|
|
409
|
+
ISO ``YYYY-MM-DD`` end of the activity window (inclusive
|
|
410
|
+
through 23:59:59). Defaults to today.
|
|
405
411
|
default_days : int, optional
|
|
406
412
|
Span in days used when ``start_date`` is omitted (default 30).
|
|
407
413
|
|
|
@@ -460,9 +466,8 @@ class StatisticsMixin(TransportMixin):
|
|
|
460
466
|
if u.id is not None
|
|
461
467
|
}
|
|
462
468
|
|
|
463
|
-
date_range = (
|
|
464
|
-
|
|
465
|
-
)
|
|
469
|
+
date_range = f"date_creation=ge={start.isoformat()};"
|
|
470
|
+
date_range += f"date_creation=le={end.isoformat()} 23:59:59"
|
|
466
471
|
|
|
467
472
|
users_output: dict[str, UserActivityEntry] = {}
|
|
468
473
|
for uid in resolved_user_ids:
|