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.
@@ -72,7 +72,7 @@ from glpi_python_client.models import (
72
72
  TicketMarkdownOptions,
73
73
  )
74
74
 
75
- __version__ = "0.3.1"
75
+ __version__ = "0.3.3"
76
76
 
77
77
  __all__ = [
78
78
  "AsyncGlpiClient",
@@ -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``. Real
11
- behaviour wins over the contract, so :func:`list_ticket_timeline_documents`
12
- unwraps the envelope through the shared
13
- :meth:`~glpi_python_client.clients.commons._transport.TransportMixin._resource_list`
14
- helper and tolerates both shapes.
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
- self, ticket_id: GlpiId
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[GetTimelineDocument]
49
- Document links returned by the GLPI server, with the timeline
50
- envelope unwrapped where present.
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
- GetTimelineDocument,
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
- ) -> GetTimelineDocument:
65
- """Fetch one timeline document link by identifier.
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 timeline document link to retrieve.
73
+ Numeric identifier of the linked document to retrieve.
73
74
 
74
75
  Returns
75
76
  -------
76
- GetTimelineDocument
77
- Validated document-link payload.
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
- GetTimelineDocument,
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. Defaults to
99
- ``end_date - default_days + 1`` when omitted.
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. Defaults to today.
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
- f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}",
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. Defaults to
230
- ``end_date - default_days + 1`` when omitted.
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. Defaults to today.
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
- f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}"
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. Defaults to today.
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
- f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}"
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: