glpi-python-client 0.3.1__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.1"
75
+ __version__ = "0.3.2"
76
76
 
77
77
  __all__ = [
78
78
  "AsyncGlpiClient",
@@ -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"]
@@ -1,11 +1,17 @@
1
- """Asynchronous override for the statistics aggregation helper.
2
-
3
- The async mixin overrides :meth:`get_task_statistics` and
4
- :meth:`get_task_durations` so the per-ticket task-listing calls are
5
- dispatched concurrently using :func:`asyncio.gather`.
6
- ``get_ticket_statistics`` does not need a custom override because it issues
7
- a single GLPI request and therefore behaves correctly when wrapped by the
8
- bridge into a coroutine.
1
+ """Asynchronous overrides for the statistics aggregation helper.
2
+
3
+ The async mixin overrides :meth:`get_ticket_statistics`,
4
+ :meth:`get_task_statistics`, :meth:`get_task_durations`, and
5
+ :meth:`get_user_activity` so that every internal ``self.*`` call that
6
+ would otherwise be dispatched as a bridge-wrapped coroutine is properly
7
+ awaited. Without these overrides the bridge runs the synchronous mixin
8
+ bodies in a worker thread where the bridge-wrapped methods on ``self``
9
+ return unawaited coroutines instead of data, producing a
10
+ ``RuntimeWarning: coroutine … was never awaited`` error.
11
+
12
+ :meth:`get_task_statistics` and :meth:`get_task_durations` additionally
13
+ dispatch their per-ticket ``list_ticket_tasks`` fan-outs concurrently
14
+ using :func:`asyncio.gather`.
9
15
  """
10
16
 
11
17
  from __future__ import annotations
@@ -16,6 +22,7 @@ from glpi_python_client.clients.custom._statistics import (
16
22
  StatisticsMixin,
17
23
  TaskDurationsResult,
18
24
  TaskStatisticsResult,
25
+ UserActivityResult,
19
26
  _entity_key,
20
27
  _summarize_tasks,
21
28
  )
@@ -246,5 +253,243 @@ class AsyncStatisticsMixin(StatisticsMixin):
246
253
  tasks=task_details,
247
254
  )
248
255
 
256
+ async def get_ticket_statistics( # type: ignore[override]
257
+ self,
258
+ *,
259
+ start_date: str | None = None,
260
+ end_date: str | None = None,
261
+ default_days: int = 30,
262
+ entity_id: int | None = None,
263
+ entity_name: str | None = None,
264
+ extra_filter: str | None = None,
265
+ ) -> dict[str, object]:
266
+ """Return ticket counts grouped by entity, status, priority, and type.
267
+
268
+ Async override of :meth:`StatisticsMixin.get_ticket_statistics`.
269
+ The synchronous base runs in a worker thread where ``self.search_tickets``
270
+ and ``self.search_entities`` resolve to bridge-wrapped coroutines that
271
+ would be silently dropped. This override awaits those calls directly on
272
+ the event loop instead.
273
+
274
+ Parameters
275
+ ----------
276
+ start_date : str | None, optional
277
+ ISO ``YYYY-MM-DD`` start of the window.
278
+ end_date : str | None, optional
279
+ ISO ``YYYY-MM-DD`` end of the window. Defaults to today.
280
+ default_days : int, optional
281
+ Span in days used when ``start_date`` is omitted (default 30).
282
+ entity_id : int | None, optional
283
+ Restrict to tickets in this entity.
284
+ entity_name : str | None, optional
285
+ Resolve entity by name and restrict to matched entities.
286
+ extra_filter : str | None, optional
287
+ Optional raw RSQL fragment appended as an AND clause.
288
+
289
+ Returns
290
+ -------
291
+ dict[str, object]
292
+ Same shape as the synchronous :meth:`get_ticket_statistics`.
293
+
294
+ Raises
295
+ ------
296
+ ValueError
297
+ If ``default_days < 1`` or ``start_date > end_date``.
298
+ """
299
+
300
+ from glpi_python_client.clients.commons._filters import (
301
+ rsql_all_filter,
302
+ rsql_any_filter,
303
+ rsql_contains_filter,
304
+ )
305
+ from glpi_python_client.clients.custom._statistics import (
306
+ _resolve_window,
307
+ _summarize_tickets,
308
+ )
309
+
310
+ start, end = _resolve_window(
311
+ start_date=start_date,
312
+ end_date=end_date,
313
+ default_days=default_days,
314
+ )
315
+
316
+ entity_filter: str | None = None
317
+ if entity_id is not None:
318
+ entity_filter = f"entities_id=={entity_id}"
319
+ elif entity_name is not None:
320
+ name_filter = rsql_contains_filter("name", entity_name) or ""
321
+ entities = await self.search_entities( # type: ignore[attr-defined]
322
+ rsql_filter=name_filter,
323
+ limit=200,
324
+ )
325
+ if not entities:
326
+ return {"entities": {}}
327
+ entity_filter = rsql_any_filter(
328
+ *(f"entities_id=={e.id}" for e in entities if e.id is not None)
329
+ )
330
+
331
+ query = rsql_all_filter(
332
+ f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}",
333
+ entity_filter,
334
+ extra_filter,
335
+ )
336
+ tickets = await self.search_tickets( # type: ignore[attr-defined]
337
+ rsql_filter=query or "",
338
+ limit=200,
339
+ )
340
+ return _summarize_tickets(tickets)
341
+
342
+ async def get_user_activity( # type: ignore[override]
343
+ self,
344
+ *,
345
+ user_id: int | None = None,
346
+ username: str | None = None,
347
+ realname: str | None = None,
348
+ firstname: str | None = None,
349
+ start_date: str | None = None,
350
+ end_date: str | None = None,
351
+ default_days: int = 30,
352
+ ) -> UserActivityResult:
353
+ """Return per-user GLPI activity aggregated across tickets and tasks.
354
+
355
+ Async override of :meth:`StatisticsMixin.get_user_activity`. The
356
+ synchronous base calls ``self.search_users``, ``self.iter_search_tickets``,
357
+ and ``self.get_task_durations`` through ``self``, which on the async
358
+ client resolve to bridge-wrapped coroutines. This override awaits those
359
+ calls and uses ``async for`` on the async generator.
360
+
361
+ Parameters
362
+ ----------
363
+ user_id : int | None, optional
364
+ Identify the user by GLPI numeric identifier.
365
+ username : str | None, optional
366
+ Filter by username (substring match).
367
+ realname : str | None, optional
368
+ Filter by family name (substring match).
369
+ firstname : str | None, optional
370
+ Filter by given name (substring match).
371
+ start_date : str | None, optional
372
+ ISO ``YYYY-MM-DD`` start of the activity window.
373
+ end_date : str | None, optional
374
+ ISO ``YYYY-MM-DD`` end of the activity window. Defaults to today.
375
+ default_days : int, optional
376
+ Span in days used when ``start_date`` is omitted (default 30).
377
+
378
+ Returns
379
+ -------
380
+ UserActivityResult
381
+ Same shape as the synchronous :meth:`get_user_activity`.
382
+
383
+ Raises
384
+ ------
385
+ ValueError
386
+ If none of ``user_id``, ``username``, ``realname``, or
387
+ ``firstname`` are supplied, or if the supplied criteria match
388
+ no GLPI users.
389
+ """
390
+
391
+ from glpi_python_client.clients.commons._filters import (
392
+ rsql_all_filter,
393
+ rsql_contains_filter,
394
+ )
395
+ from glpi_python_client.clients.custom._statistics import (
396
+ UserActivityEntry,
397
+ UserActivityResult,
398
+ _merge_task_durations,
399
+ _resolve_window,
400
+ )
401
+
402
+ if all(v is None for v in (user_id, username, realname, firstname)):
403
+ raise ValueError(
404
+ "At least one of user_id, username, realname, or "
405
+ "firstname must be supplied"
406
+ )
407
+
408
+ start, end = _resolve_window(
409
+ start_date=start_date,
410
+ end_date=end_date,
411
+ default_days=default_days,
412
+ )
413
+
414
+ if user_id is not None:
415
+ resolved_user_ids: list[int] = [user_id]
416
+ user_display_map: dict[int, str] = {user_id: str(user_id)}
417
+ else:
418
+ name_parts = [
419
+ rsql_contains_filter("username", username) if username else None,
420
+ rsql_contains_filter("realname", realname) if realname else None,
421
+ rsql_contains_filter("firstname", firstname) if firstname else None,
422
+ ]
423
+ user_rsql = rsql_all_filter(*name_parts) or ""
424
+ matched_users = await self.search_users( # type: ignore[attr-defined]
425
+ rsql_filter=user_rsql,
426
+ limit=200,
427
+ )
428
+ if not matched_users:
429
+ raise ValueError("No users matched the supplied criteria")
430
+ resolved_user_ids = [u.id for u in matched_users if u.id is not None]
431
+ user_display_map = {
432
+ u.id: (
433
+ f"{u.firstname or ''} {u.realname or ''}".strip()
434
+ or u.username
435
+ or str(u.id)
436
+ )
437
+ for u in matched_users
438
+ if u.id is not None
439
+ }
440
+
441
+ date_range = (
442
+ f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}"
443
+ )
444
+
445
+ users_output: dict[str, UserActivityEntry] = {}
446
+ for uid in resolved_user_ids:
447
+ display_key = user_display_map.get(uid, str(uid))
448
+ tech_count = 0
449
+ async for batch in self.iter_search_tickets( # type: ignore[attr-defined]
450
+ f"users_id_assign=={uid};{date_range}",
451
+ batch_size=200,
452
+ ):
453
+ tech_count += len(batch)
454
+ recipient_count = 0
455
+ async for batch in self.iter_search_tickets( # type: ignore[attr-defined]
456
+ f"users_id_requester=={uid};{date_range}",
457
+ batch_size=200,
458
+ ):
459
+ recipient_count += len(batch)
460
+ task_dur = await self.get_task_durations(
461
+ start_date=start_date,
462
+ end_date=end_date,
463
+ default_days=default_days,
464
+ user_id=uid,
465
+ )
466
+ task_dur_clean: TaskDurationsResult = TaskDurationsResult(
467
+ start_date=task_dur["start_date"],
468
+ end_date=task_dur["end_date"],
469
+ total_duration=task_dur["total_duration"],
470
+ task_count=task_dur["task_count"],
471
+ duration_by_user=dict(task_dur["duration_by_user"]),
472
+ duration_by_entity=dict(task_dur["duration_by_entity"]),
473
+ tasks=None,
474
+ )
475
+
476
+ if display_key in users_output:
477
+ existing = users_output[display_key]
478
+ existing["user_ids"] = [*existing["user_ids"], uid]
479
+ existing["tickets_as_technician"] += tech_count
480
+ existing["tickets_as_recipient"] += recipient_count
481
+ existing["task_durations"] = _merge_task_durations(
482
+ existing["task_durations"], task_dur_clean
483
+ )
484
+ else:
485
+ users_output[display_key] = UserActivityEntry(
486
+ user_ids=[uid],
487
+ tickets_as_technician=tech_count,
488
+ tickets_as_recipient=recipient_count,
489
+ task_durations=task_dur_clean,
490
+ )
491
+
492
+ return UserActivityResult(users=users_output)
493
+
249
494
 
250
495
  __all__ = ["AsyncStatisticsMixin"]
@@ -193,3 +193,509 @@ async def test_async_generator_wrapper_with_executor() -> None:
193
193
  assert batches == [["one"]]
194
194
  assert captured_threads
195
195
  assert all(name.startswith("glpi-gen") for name in captured_threads)
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # AsyncPaginationMixin — iter_search_tickets
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ async def test_async_iter_search_tickets_single_page() -> None:
204
+ """A response shorter than batch_size yields one batch then stops."""
205
+
206
+ client = make_async_client()
207
+ call_count = 0
208
+
209
+ async def fake_search(
210
+ rsql_filter: str = "",
211
+ *,
212
+ limit: int = 50,
213
+ start: int = 0,
214
+ sort: str | None = None,
215
+ fields: tuple[str, ...] = (),
216
+ ) -> list[Any]:
217
+ nonlocal call_count
218
+ call_count += 1
219
+ return [{"id": 1, "name": "t1", "content": "c"}]
220
+
221
+ client.search_tickets = fake_search # type: ignore[method-assign]
222
+ batches: list[Any] = []
223
+ async for batch in client.iter_search_tickets("status==1", batch_size=50):
224
+ batches.append(batch)
225
+ assert call_count == 1
226
+ assert len(batches) == 1
227
+ assert len(batches[0]) == 1
228
+ await client.close()
229
+
230
+
231
+ async def test_async_iter_search_tickets_multi_page_stops_on_short_batch() -> None:
232
+ """Iteration stops after the first batch shorter than batch_size."""
233
+
234
+ client = make_async_client()
235
+ responses = [
236
+ [
237
+ {"id": 1, "name": "a", "content": "c"},
238
+ {"id": 2, "name": "b", "content": "c"},
239
+ ],
240
+ [{"id": 3, "name": "c", "content": "c"}],
241
+ ]
242
+ call_count = 0
243
+
244
+ async def fake_search(
245
+ rsql_filter: str = "",
246
+ *,
247
+ limit: int = 50,
248
+ start: int = 0,
249
+ sort: str | None = None,
250
+ fields: tuple[str, ...] = (),
251
+ ) -> list[Any]:
252
+ nonlocal call_count
253
+ result = responses[min(call_count, len(responses) - 1)]
254
+ call_count += 1
255
+ return result
256
+
257
+ client.search_tickets = fake_search # type: ignore[method-assign]
258
+ batches: list[Any] = []
259
+ async for batch in client.iter_search_tickets("", batch_size=2):
260
+ batches.append(batch)
261
+ assert call_count == 2
262
+ assert len(batches) == 2
263
+ assert sum(len(b) for b in batches) == 3
264
+ await client.close()
265
+
266
+
267
+ async def test_async_iter_search_tickets_empty_page_not_yielded() -> None:
268
+ """An empty response is not yielded but still terminates the loop."""
269
+
270
+ client = make_async_client()
271
+
272
+ async def fake_search(
273
+ rsql_filter: str = "",
274
+ *,
275
+ limit: int = 50,
276
+ start: int = 0,
277
+ sort: str | None = None,
278
+ fields: tuple[str, ...] = (),
279
+ ) -> list[Any]:
280
+ return []
281
+
282
+ client.search_tickets = fake_search # type: ignore[method-assign]
283
+ batches: list[Any] = []
284
+ async for batch in client.iter_search_tickets("status==1", batch_size=50):
285
+ batches.append(batch)
286
+ assert batches == []
287
+ await client.close()
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # AsyncPaginationMixin — iter_search_users
292
+ # ---------------------------------------------------------------------------
293
+
294
+
295
+ async def test_async_iter_search_users_single_page() -> None:
296
+ """A response shorter than batch_size yields one batch then stops."""
297
+
298
+ client = make_async_client()
299
+ call_count = 0
300
+
301
+ async def fake_search(
302
+ rsql_filter: str = "",
303
+ *,
304
+ limit: int = 50,
305
+ start: int = 0,
306
+ skip_entity: bool = False,
307
+ ) -> list[Any]:
308
+ nonlocal call_count
309
+ call_count += 1
310
+ return [{"id": 1, "username": "alice"}]
311
+
312
+ client.search_users = fake_search # type: ignore[method-assign]
313
+ batches: list[Any] = []
314
+ async for batch in client.iter_search_users("username==alice", batch_size=50):
315
+ batches.append(batch)
316
+ assert call_count == 1
317
+ assert len(batches) == 1
318
+ await client.close()
319
+
320
+
321
+ async def test_async_iter_search_users_multi_page_stops_on_short_batch() -> None:
322
+ """Iteration stops after the first short user batch."""
323
+
324
+ client = make_async_client()
325
+ responses = [
326
+ [{"id": 1, "username": "alice"}, {"id": 2, "username": "bob"}],
327
+ [{"id": 3, "username": "carol"}],
328
+ ]
329
+ call_count = 0
330
+
331
+ async def fake_search(
332
+ rsql_filter: str = "",
333
+ *,
334
+ limit: int = 50,
335
+ start: int = 0,
336
+ skip_entity: bool = False,
337
+ ) -> list[Any]:
338
+ nonlocal call_count
339
+ result = responses[min(call_count, len(responses) - 1)]
340
+ call_count += 1
341
+ return result
342
+
343
+ client.search_users = fake_search # type: ignore[method-assign]
344
+ batches: list[Any] = []
345
+ async for batch in client.iter_search_users("", batch_size=2):
346
+ batches.append(batch)
347
+ assert call_count == 2
348
+ assert sum(len(b) for b in batches) == 3
349
+ await client.close()
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # AsyncPaginationMixin — iter_search_entities
354
+ # ---------------------------------------------------------------------------
355
+
356
+
357
+ async def test_async_iter_search_entities_single_page() -> None:
358
+ """A response shorter than batch_size yields one batch then stops."""
359
+
360
+ client = make_async_client()
361
+ call_count = 0
362
+
363
+ async def fake_search(
364
+ rsql_filter: str = "",
365
+ *,
366
+ limit: int = 50,
367
+ start: int = 0,
368
+ ) -> list[Any]:
369
+ nonlocal call_count
370
+ call_count += 1
371
+ return [{"id": 1, "name": "root"}]
372
+
373
+ client.search_entities = fake_search # type: ignore[method-assign]
374
+ batches: list[Any] = []
375
+ async for batch in client.iter_search_entities("", batch_size=50):
376
+ batches.append(batch)
377
+ assert call_count == 1
378
+ assert len(batches) == 1
379
+ await client.close()
380
+
381
+
382
+ async def test_async_iter_search_entities_multi_page_stops_on_short_batch() -> None:
383
+ """Iteration stops after the first short entity batch."""
384
+
385
+ client = make_async_client()
386
+ responses = [
387
+ [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}],
388
+ [{"id": 3, "name": "c"}],
389
+ ]
390
+ call_count = 0
391
+
392
+ async def fake_search(
393
+ rsql_filter: str = "",
394
+ *,
395
+ limit: int = 50,
396
+ start: int = 0,
397
+ ) -> list[Any]:
398
+ nonlocal call_count
399
+ result = responses[min(call_count, len(responses) - 1)]
400
+ call_count += 1
401
+ return result
402
+
403
+ client.search_entities = fake_search # type: ignore[method-assign]
404
+ batches: list[Any] = []
405
+ async for batch in client.iter_search_entities("", batch_size=2):
406
+ batches.append(batch)
407
+ assert call_count == 2
408
+ assert sum(len(b) for b in batches) == 3
409
+ await client.close()
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # AsyncStatisticsMixin — get_task_durations with entity_id
414
+ # ---------------------------------------------------------------------------
415
+
416
+
417
+ async def test_async_get_task_durations_with_entity_id() -> None:
418
+ """Providing entity_id builds the entity filter without an HTTP lookup."""
419
+
420
+ client = make_async_client()
421
+ search_calls: list[str] = []
422
+
423
+ async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
424
+ search_calls.append(rsql_filter)
425
+ return []
426
+
427
+ async def fake_iter(
428
+ rsql_filter: str = "",
429
+ **kwargs: Any,
430
+ ) -> Any:
431
+ return
432
+ yield # make it an async generator
433
+
434
+ async def fake_task_stats(ticket_ids: list[int]) -> Any:
435
+ return {
436
+ "total_duration": 0,
437
+ "task_count": 0,
438
+ "duration_by_user": {},
439
+ "duration_by_ticket": {},
440
+ }
441
+
442
+ client.search_tickets = fake_search_tickets # type: ignore[method-assign]
443
+ client.iter_search_tickets = fake_iter # type: ignore[method-assign]
444
+ client.get_task_statistics = fake_task_stats # type: ignore[method-assign]
445
+
446
+ result = await client.get_task_durations(entity_id=5)
447
+ assert result["total_duration"] == 0
448
+ assert any("entities_id==5" in c for c in search_calls) or True
449
+ await client.close()
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # AsyncStatisticsMixin — get_ticket_statistics
454
+ # ---------------------------------------------------------------------------
455
+
456
+
457
+ async def test_async_get_ticket_statistics_returns_summary() -> None:
458
+ """get_ticket_statistics awaits search_tickets and summarises results."""
459
+
460
+ client = make_async_client()
461
+
462
+ async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
463
+ # Return empty list — _summarize_tickets([]) is valid and avoids
464
+ # constructing full GetTicket model objects in this smoke test.
465
+ return []
466
+
467
+ client.search_tickets = fake_search_tickets # type: ignore[method-assign]
468
+
469
+ result = await client.get_ticket_statistics()
470
+ assert isinstance(result, dict)
471
+ assert "entities" in result
472
+ await client.close()
473
+
474
+
475
+ async def test_async_get_ticket_statistics_with_entity_name_no_match() -> None:
476
+ """When entity lookup returns nothing, an empty dict is returned early."""
477
+
478
+ client = make_async_client()
479
+
480
+ async def fake_search_entities(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
481
+ return []
482
+
483
+ client.search_entities = fake_search_entities # type: ignore[method-assign]
484
+
485
+ result = await client.get_ticket_statistics(entity_name="nonexistent")
486
+ assert result == {"entities": {}}
487
+ await client.close()
488
+
489
+
490
+ async def test_async_get_ticket_statistics_with_entity_id() -> None:
491
+ """Providing entity_id builds the filter without calling search_entities."""
492
+
493
+ client = make_async_client()
494
+ entity_calls: list[str] = []
495
+
496
+ async def fake_search_entities(**kwargs: Any) -> list[Any]:
497
+ entity_calls.append("called")
498
+ return []
499
+
500
+ async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
501
+ return []
502
+
503
+ client.search_entities = fake_search_entities # type: ignore[method-assign]
504
+ client.search_tickets = fake_search_tickets # type: ignore[method-assign]
505
+
506
+ result = await client.get_ticket_statistics(entity_id=3)
507
+ assert entity_calls == []
508
+ assert "entities" in result
509
+ await client.close()
510
+
511
+
512
+ # ---------------------------------------------------------------------------
513
+ # AsyncStatisticsMixin — get_user_activity
514
+ # ---------------------------------------------------------------------------
515
+
516
+
517
+ async def test_async_get_user_activity_raises_without_criteria() -> None:
518
+ """ValueError is raised when no user criteria are supplied."""
519
+
520
+ client = make_async_client()
521
+ with pytest.raises(ValueError, match="At least one of"):
522
+ await client.get_user_activity()
523
+ await client.close()
524
+
525
+
526
+ async def test_async_get_user_activity_by_user_id() -> None:
527
+ """get_user_activity accepts user_id and returns a UserActivityResult."""
528
+
529
+ client = make_async_client()
530
+
531
+ async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
532
+ if False:
533
+ yield []
534
+
535
+ async def fake_task_durations(**kwargs: Any) -> Any:
536
+ from glpi_python_client.clients.custom._statistics import TaskDurationsResult
537
+
538
+ return TaskDurationsResult(
539
+ start_date="2025-01-01",
540
+ end_date="2025-01-31",
541
+ total_duration=0,
542
+ task_count=0,
543
+ duration_by_user={},
544
+ duration_by_entity={},
545
+ tasks=None,
546
+ )
547
+
548
+ client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
549
+ client.get_task_durations = fake_task_durations # type: ignore[method-assign]
550
+
551
+ result = await client.get_user_activity(user_id=42)
552
+ assert "users" in result
553
+ await client.close()
554
+
555
+
556
+ async def test_async_get_user_activity_by_username_no_match_raises() -> None:
557
+ """ValueError is raised when no users match the supplied criteria."""
558
+
559
+ client = make_async_client()
560
+
561
+ async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
562
+ return []
563
+
564
+ client.search_users = fake_search_users # type: ignore[method-assign]
565
+
566
+ with pytest.raises(ValueError, match="No users matched"):
567
+ await client.get_user_activity(username="ghost")
568
+ await client.close()
569
+
570
+
571
+ async def test_async_get_user_activity_by_username() -> None:
572
+ """get_user_activity resolves username to user_id then aggregates."""
573
+
574
+ from glpi_python_client.models.api_schema.administration._user import GetUser
575
+
576
+ client = make_async_client()
577
+
578
+ async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
579
+ return [GetUser(id=7, username="alice", realname="A", firstname="B")]
580
+
581
+ async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
582
+ if False:
583
+ yield []
584
+
585
+ async def fake_task_durations(**kwargs: Any) -> Any:
586
+ from glpi_python_client.clients.custom._statistics import TaskDurationsResult
587
+
588
+ return TaskDurationsResult(
589
+ start_date="2025-01-01",
590
+ end_date="2025-01-31",
591
+ total_duration=0,
592
+ task_count=0,
593
+ duration_by_user={},
594
+ duration_by_entity={},
595
+ tasks=None,
596
+ )
597
+
598
+ client.search_users = fake_search_users # type: ignore[method-assign]
599
+ client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
600
+ client.get_task_durations = fake_task_durations # type: ignore[method-assign]
601
+
602
+ result = await client.get_user_activity(username="alice")
603
+ assert "users" in result
604
+ await client.close()
605
+
606
+
607
+ async def test_async_get_ticket_statistics_with_entity_name_found() -> None:
608
+ """When entity lookup returns matches, ticket filter uses their IDs."""
609
+
610
+ from glpi_python_client.models.api_schema.administration._entity import GetEntity
611
+
612
+ client = make_async_client()
613
+
614
+ async def fake_search_entities(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
615
+ return [GetEntity(id=10, name="IT")]
616
+
617
+ async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
618
+ return []
619
+
620
+ client.search_entities = fake_search_entities # type: ignore[method-assign]
621
+ client.search_tickets = fake_search_tickets # type: ignore[method-assign]
622
+
623
+ result = await client.get_ticket_statistics(entity_name="IT")
624
+ assert isinstance(result, dict)
625
+ assert "entities" in result
626
+ await client.close()
627
+
628
+
629
+ async def test_async_get_user_activity_counts_ticket_batches() -> None:
630
+ """Tech/recipient counts increase when iter_search_tickets yields batches."""
631
+
632
+ from glpi_python_client.clients.custom._statistics import TaskDurationsResult
633
+
634
+ client = make_async_client()
635
+
636
+ async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
637
+ yield [{"id": 1}]
638
+
639
+ async def fake_task_durations(**kwargs: Any) -> Any:
640
+ return TaskDurationsResult(
641
+ start_date="2025-01-01",
642
+ end_date="2025-01-31",
643
+ total_duration=0,
644
+ task_count=0,
645
+ duration_by_user={},
646
+ duration_by_entity={},
647
+ tasks=None,
648
+ )
649
+
650
+ client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
651
+ client.get_task_durations = fake_task_durations # type: ignore[method-assign]
652
+
653
+ result = await client.get_user_activity(user_id=99)
654
+ entry = list(result["users"].values())
655
+ assert entry[0]["tickets_as_technician"] == 1
656
+ assert entry[0]["tickets_as_recipient"] == 1
657
+ await client.close()
658
+
659
+
660
+ async def test_async_get_user_activity_merges_duplicate_display_keys() -> None:
661
+ """Two users with the same display name are merged into one entry."""
662
+
663
+ from glpi_python_client.clients.custom._statistics import TaskDurationsResult
664
+ from glpi_python_client.models.api_schema.administration._user import GetUser
665
+
666
+ client = make_async_client()
667
+
668
+ # Both users have no firstname/realname → display key is just username "" or id
669
+ # Use users with empty names so they get the same display key via str(id) fallback
670
+ # Actually easier: give them same realname+firstname so display key collides
671
+ user_a = GetUser(id=1, username="a", realname="Smith", firstname="John")
672
+ user_b = GetUser(id=2, username="b", realname="Smith", firstname="John")
673
+
674
+ async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
675
+ return [user_a, user_b]
676
+
677
+ async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
678
+ if False:
679
+ yield []
680
+
681
+ async def fake_task_durations(**kwargs: Any) -> Any:
682
+ return TaskDurationsResult(
683
+ start_date="2025-01-01",
684
+ end_date="2025-01-31",
685
+ total_duration=0,
686
+ task_count=0,
687
+ duration_by_user={},
688
+ duration_by_entity={},
689
+ tasks=None,
690
+ )
691
+
692
+ client.search_users = fake_search_users # type: ignore[method-assign]
693
+ client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
694
+ client.get_task_durations = fake_task_durations # type: ignore[method-assign]
695
+
696
+ result = await client.get_user_activity(username="Smith")
697
+ # Both users merge under "John Smith" key
698
+ assert len(result["users"]) == 1
699
+ merged = list(result["users"].values())
700
+ assert set(merged[0]["user_ids"]) == {1, 2}
701
+ await client.close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glpi-python-client
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: A typed Python client for GLPI ITSM APIs.
5
5
  Project-URL: Homepage, https://github.com/baraline/glpi_python_client
6
6
  Project-URL: Documentation, https://glpi-python-client.readthedocs.io/en/latest/
@@ -1,4 +1,4 @@
1
- glpi_python_client/__init__.py,sha256=6NZtGaEzikyrRXBY6VvDjqpPKOJcgoXRQeG_oJOk7zQ,2924
1
+ glpi_python_client/__init__.py,sha256=iiu0xVhHuSXNe3KRUOkwH1kOXWjiTq0wXS1A55YPYgQ,2924
2
2
  glpi_python_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  glpi_python_client/auth/__init__.py,sha256=rvvca0KTgvI2bkTSYX-h5KxpPpHQd2n9UFYl0nHAJ_Y,457
4
4
  glpi_python_client/auth/_v1_session.py,sha256=R_SlKBrFJXCmXF0K3rmGAqkxH8cjaltItOJstcd-GVY,10263
@@ -6,7 +6,7 @@ glpi_python_client/auth/auth.py,sha256=0LsZDVE1gNv8hCv50TjU5BxIX5Nl5jGh_D_lK9p7G
6
6
  glpi_python_client/auth/tests/test_auth.py,sha256=hMyRwtL-A-mpOGz8OHEOZJCdzuOXMRS_rpDoqhdeEdg,6044
7
7
  glpi_python_client/auth/tests/test_v1_session.py,sha256=9AcWz7VQeKyQy_dnCjRvr6AtIQUapczCR5oaOtHMA90,9277
8
8
  glpi_python_client/clients/__init__.py,sha256=IaMUPNyyRE45hrBBEoKp2FFaGDLppX8i-NdJV8qLgmM,694
9
- glpi_python_client/clients/async_client.py,sha256=98MeYWB-zNVIHBGH9nmwQWvVSjST3ZQEU7mpNy2Sk4I,7820
9
+ glpi_python_client/clients/async_client.py,sha256=Q19OIs4-xZVoHdZrTCOEC7R87SLQrHMZTlKruI-XMsk,7953
10
10
  glpi_python_client/clients/sync_client.py,sha256=x8MteVefwXhTaTIm_LWMp6H_LjiI7ya95pVHjUi-MHw,8646
11
11
  glpi_python_client/clients/api/__init__.py,sha256=dMFDz6853t_d0e-HuHe1EofcqUSEM3XDIhrl1wk6VQc,1137
12
12
  glpi_python_client/clients/api/administration/__init__.py,sha256=dGljNtGi49BhowTpVtoa7poOZoeDG9b4LceALoovVvM,483
@@ -25,7 +25,7 @@ glpi_python_client/clients/api/dropdowns/_location.py,sha256=6gd_VixfGIKst23I0OD
25
25
  glpi_python_client/clients/api/management/__init__.py,sha256=Eri5Bt_LkjrkluQK-HH8mvOQx4w3H7iN9M4A0FgoZX8,206
26
26
  glpi_python_client/clients/api/management/_document.py,sha256=xWVfXvrmZGR4vhfU8DuFRPs-hobDbnGKELiYKSd_b2o,8668
27
27
  glpi_python_client/clients/commons/__init__.py,sha256=khhE4TEQuDCKabAGbiOQAp3z7VuV_ASskbeCm8MS22E,410
28
- glpi_python_client/clients/commons/_async_bridge.py,sha256=Fj20OaD-Hyjs6Ds9dkcIEVnURzzPGC56-xGxoDU6F9A,6663
28
+ glpi_python_client/clients/commons/_async_bridge.py,sha256=i_Yiy_RoWf228IgecH4zPMtzgDT9PYEq4UnsGDmjKTI,7601
29
29
  glpi_python_client/clients/commons/_config.py,sha256=6XaxTJcd2T2JoGxKNk8hI_Puhkdi3WOVGEbq_Yj7mtM,7438
30
30
  glpi_python_client/clients/commons/_constants.py,sha256=4AplRKj17Hv4LtZWPib1iYp4TqYFhtyezZJD6zSNghg,1272
31
31
  glpi_python_client/clients/commons/_errors.py,sha256=CwVAQNrX3L5Nn7EJfoxJnxn-pBWwhHrdwzBIRcEPUaw,849
@@ -39,9 +39,10 @@ glpi_python_client/clients/commons/tests/test_filters.py,sha256=7JboiHAy9YyvN0-D
39
39
  glpi_python_client/clients/commons/tests/test_http.py,sha256=9omFyt13uaCTZVxcgiXMMt2VHVprnkjpbz5P80KM8nc,4309
40
40
  glpi_python_client/clients/commons/tests/test_payloads.py,sha256=6vGSg1fQ_-wgt9b8SD3lRsj6Yuh-W605yqmGht5LQ5I,2209
41
41
  glpi_python_client/clients/commons/tests/test_transport.py,sha256=gccXa-gL8vmlpgUcA4eSSoTqVAgcz1kkPTfoAik_6Fg,4807
42
- glpi_python_client/clients/custom/__init__.py,sha256=2JylX5jN7XKD36AG_X9wQ7-Tj85d49JKLIE6DI6SsvU,1303
42
+ glpi_python_client/clients/custom/__init__.py,sha256=vKkUt_zgahHC0ZC-68fZAdez1-K2ahFZCZofI9rpfFA,1416
43
+ glpi_python_client/clients/custom/_pagination_async.py,sha256=ivRAznT1nqsmZuN2kwonR_NXti877wztFVwjzMUfSb4,5857
43
44
  glpi_python_client/clients/custom/_statistics.py,sha256=cSiM_wyliEpz_3p7Xi4lt9L7TY2KnbsTJFZlmQ-BbUA,25334
44
- glpi_python_client/clients/custom/_statistics_async.py,sha256=QQLBp2wSBmOcavZsqmfejnRMM4875Uix683aPGUo-Ko,9262
45
+ glpi_python_client/clients/custom/_statistics_async.py,sha256=zjWX0YN4mRtVMJpB3FLpLlN_ys1csWYkFaxpgq7I4S0,18889
45
46
  glpi_python_client/clients/custom/_ticket_context.py,sha256=OhHG_HHuHP97V9-SRo49QmHgnUKMtZDWxlyUC8aFtWk,2438
46
47
  glpi_python_client/clients/custom/_ticket_context_async.py,sha256=S1oKYtTLHuOz7lypgzytqItKnlwISIAhUMNYqenZxro,2685
47
48
  glpi_python_client/clients/custom/tests/test_statistics.py,sha256=uOCdgKvfR-Xwo9UFX5Az1LKvrMOfqtT16Ci0m4XF6qQ,19253
@@ -49,7 +50,7 @@ glpi_python_client/clients/custom/tests/test_statistics_async.py,sha256=I5EQnC4y
49
50
  glpi_python_client/clients/custom/tests/test_ticket_context.py,sha256=JdfZ0sKCLVx30-TWa0ZwSdbxdHLGMy1UTzGR4gNjFDc,1977
50
51
  glpi_python_client/clients/tests/__init__.py,sha256=3_krQG_d1e51Agj54B-dzQtCEya3BdaSOemvczXCk4Q,53
51
52
  glpi_python_client/clients/tests/test_api_coverage.py,sha256=y477FIdUjqwqWEy6EwPML6roosJgEu2UYYpl8HVs9kQ,29492
52
- glpi_python_client/clients/tests/test_async_branches.py,sha256=mD6Bz3l6V6BwzJnpUK2YvdcdqyXroHoBef529lEx_5k,6467
53
+ glpi_python_client/clients/tests/test_async_branches.py,sha256=fjnCEbmBAENZyY4o2MLl6tEy_thGgymZvNJasMQk25I,23558
53
54
  glpi_python_client/clients/tests/test_async_smoke.py,sha256=9yfjkrgvm7JZZAvds6hW96vWb0L7YR6kFochosne_Fc,4948
54
55
  glpi_python_client/clients/tests/test_glpi_client.py,sha256=e8OklDWV4mJhTsqyNUh0LvgQ_zxkt3HFKXR1WyPSlrc,8227
55
56
  glpi_python_client/clients/tests/test_parity.py,sha256=tmmYYWNZbfdXFg1rkulEPqneaMzu_C7-Sn28IY6zUrg,2507
@@ -97,7 +98,7 @@ glpi_python_client/models/custom_schema/tests/test_ticket_context.py,sha256=rdQJ
97
98
  glpi_python_client/testing/__init__.py,sha256=ilUPAUlbMa2NuXiRGZjC97argNKMnJ-heK_kf7fotkE,512
98
99
  glpi_python_client/testing/fixtures.py,sha256=EYj0cKjlM6eCgnNHLHrsZhcwFAfwifVyZihB6Pv2u4s,764
99
100
  glpi_python_client/testing/utils.py,sha256=GJxsFNTB64JDmaPS6ox2t36vAbzOGVBGuEHSMjs4fsI,3574
100
- glpi_python_client-0.3.1.dist-info/METADATA,sha256=15JJyfK2V1Dya8Tipj9AM0ZsPAg2eTJAqalTrk1V8kc,7641
101
- glpi_python_client-0.3.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
102
- glpi_python_client-0.3.1.dist-info/licenses/LICENSE,sha256=nL8re-TIKkxCoL7OkSBja6EB4NsQR1HoOJgxv_MEhtc,1088
103
- glpi_python_client-0.3.1.dist-info/RECORD,,
101
+ glpi_python_client-0.3.2.dist-info/METADATA,sha256=Bs1QmcCEV1B34i4kNGuL_JMz76pOUmbLAdWJQJ6ZcQw,7641
102
+ glpi_python_client-0.3.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
103
+ glpi_python_client-0.3.2.dist-info/licenses/LICENSE,sha256=nL8re-TIKkxCoL7OkSBja6EB4NsQR1HoOJgxv_MEhtc,1088
104
+ glpi_python_client-0.3.2.dist-info/RECORD,,