glpi-python-client 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (132) hide show
  1. glpi_python_client-0.3.1/.pre-commit-config.yaml +32 -0
  2. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/PKG-INFO +1 -1
  3. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/user_guide.rst +180 -3
  4. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/__init__.py +1 -1
  5. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/administration/_entity.py +44 -0
  6. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/administration/_user.py +47 -0
  7. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/assistance/_ticket.py +52 -0
  8. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/_async_bridge.py +59 -3
  9. glpi_python_client-0.3.1/glpi_python_client/clients/custom/_statistics.py +674 -0
  10. glpi_python_client-0.3.1/glpi_python_client/clients/custom/_statistics_async.py +250 -0
  11. glpi_python_client-0.3.1/glpi_python_client/clients/custom/tests/test_statistics.py +562 -0
  12. glpi_python_client-0.3.1/glpi_python_client/clients/custom/tests/test_statistics_async.py +232 -0
  13. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/tests/test_api_coverage.py +175 -0
  14. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/tests/test_async_branches.py +58 -0
  15. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/tests/test_parity.py +10 -4
  16. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/pyproject.toml +1 -1
  17. glpi_python_client-0.3.1/skills/glpi-reporting-and-context/SKILL.md +110 -0
  18. glpi_python_client-0.3.0/.pre-commit-config.yaml +0 -7
  19. glpi_python_client-0.3.0/glpi_python_client/clients/custom/_statistics.py +0 -261
  20. glpi_python_client-0.3.0/glpi_python_client/clients/custom/_statistics_async.py +0 -69
  21. glpi_python_client-0.3.0/glpi_python_client/clients/custom/tests/test_statistics.py +0 -194
  22. glpi_python_client-0.3.0/skills/glpi-reporting-and-context/SKILL.md +0 -72
  23. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/.gitignore +0 -0
  24. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/.readthedocs.yaml +0 -0
  25. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/CONTRIBUTING.md +0 -0
  26. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/LICENSE +0 -0
  27. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/README.md +0 -0
  28. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/_static/.gitkeep +0 -0
  29. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/api_reference.rst +0 -0
  30. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/conf.py +0 -0
  31. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/development.md +0 -0
  32. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/development_rtd.rst +0 -0
  33. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/glpi_api_contract.json +0 -0
  34. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/index.rst +0 -0
  35. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/installation.rst +0 -0
  36. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/publishing.md +0 -0
  37. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/publishing_rtd.rst +0 -0
  38. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/docs/sponsoring.rst +0 -0
  39. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/auth/__init__.py +0 -0
  40. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/auth/_v1_session.py +0 -0
  41. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/auth/auth.py +0 -0
  42. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/auth/tests/test_auth.py +0 -0
  43. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/auth/tests/test_v1_session.py +0 -0
  44. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/__init__.py +0 -0
  45. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/__init__.py +0 -0
  46. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/administration/__init__.py +0 -0
  47. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/assistance/__init__.py +0 -0
  48. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/assistance/_team.py +0 -0
  49. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/assistance/timeline/__init__.py +0 -0
  50. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/assistance/timeline/_document.py +0 -0
  51. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/assistance/timeline/_followup.py +0 -0
  52. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/assistance/timeline/_solution.py +0 -0
  53. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/assistance/timeline/_task.py +0 -0
  54. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/dropdowns/__init__.py +0 -0
  55. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/dropdowns/_location.py +0 -0
  56. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/management/__init__.py +0 -0
  57. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/api/management/_document.py +0 -0
  58. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/async_client.py +0 -0
  59. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/__init__.py +0 -0
  60. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/_config.py +0 -0
  61. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/_constants.py +0 -0
  62. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/_errors.py +0 -0
  63. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/_filters.py +0 -0
  64. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/_http.py +0 -0
  65. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/_payloads.py +0 -0
  66. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/_transport.py +0 -0
  67. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/tests/__init__.py +0 -0
  68. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/tests/test_errors.py +0 -0
  69. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/tests/test_filters.py +0 -0
  70. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/tests/test_http.py +0 -0
  71. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/tests/test_payloads.py +0 -0
  72. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/commons/tests/test_transport.py +0 -0
  73. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/custom/__init__.py +0 -0
  74. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/custom/_ticket_context.py +0 -0
  75. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/custom/_ticket_context_async.py +0 -0
  76. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/custom/tests/test_ticket_context.py +0 -0
  77. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/sync_client.py +0 -0
  78. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/tests/__init__.py +0 -0
  79. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/tests/test_async_smoke.py +0 -0
  80. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/tests/test_glpi_client.py +0 -0
  81. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/clients/tests/test_smoke.py +0 -0
  82. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/content/__init__.py +0 -0
  83. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/content/conversion.py +0 -0
  84. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/content/tests/__init__.py +0 -0
  85. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/content/tests/test_conversion.py +0 -0
  86. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/__init__.py +0 -0
  87. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/_base.py +0 -0
  88. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/__init__.py +0 -0
  89. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/_common.py +0 -0
  90. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/_content.py +0 -0
  91. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/administration/__init__.py +0 -0
  92. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/administration/_entity.py +0 -0
  93. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/administration/_user.py +0 -0
  94. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/administration/tests/__init__.py +0 -0
  95. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/administration/tests/test_administration_schemas.py +0 -0
  96. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/__init__.py +0 -0
  97. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/_team.py +0 -0
  98. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/_ticket.py +0 -0
  99. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/tests/__init__.py +0 -0
  100. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/tests/test_assistance_schemas.py +0 -0
  101. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/tests/test_content_roundtrip.py +0 -0
  102. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/timeline/__init__.py +0 -0
  103. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/timeline/_document.py +0 -0
  104. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/timeline/_followup.py +0 -0
  105. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/timeline/_solution.py +0 -0
  106. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/timeline/_task.py +0 -0
  107. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/timeline/tests/__init__.py +0 -0
  108. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/assistance/timeline/tests/test_timeline_schemas.py +0 -0
  109. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/dropdowns/__init__.py +0 -0
  110. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/dropdowns/_location.py +0 -0
  111. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/dropdowns/tests/__init__.py +0 -0
  112. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/dropdowns/tests/test_dropdowns_schemas.py +0 -0
  113. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/enums.py +0 -0
  114. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/management/__init__.py +0 -0
  115. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/management/_document.py +0 -0
  116. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/management/tests/__init__.py +0 -0
  117. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py +0 -0
  118. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/custom_schema/__init__.py +0 -0
  119. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/custom_schema/_ticket_context.py +0 -0
  120. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/custom_schema/tests/__init__.py +0 -0
  121. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/models/custom_schema/tests/test_ticket_context.py +0 -0
  122. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/py.typed +0 -0
  123. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/testing/__init__.py +0 -0
  124. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/testing/fixtures.py +0 -0
  125. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/glpi_python_client/testing/utils.py +0 -0
  126. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/skills/README.md +0 -0
  127. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/skills/glpi-client-setup/SKILL.md +0 -0
  128. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/skills/glpi-document-workflow/SKILL.md +0 -0
  129. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/skills/glpi-team-members/SKILL.md +0 -0
  130. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/skills/glpi-ticket-timeline/SKILL.md +0 -0
  131. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/skills/glpi-ticket-workflow/SKILL.md +0 -0
  132. {glpi_python_client-0.3.0 → glpi_python_client-0.3.1}/skills/glpi-user-location-provisioning/SKILL.md +0 -0
@@ -0,0 +1,32 @@
1
+ default_install_hook_types: [pre-commit, pre-push]
2
+
3
+ repos:
4
+ - repo: https://github.com/astral-sh/ruff-pre-commit
5
+ rev: v0.15.13
6
+ hooks:
7
+ - id: ruff-check
8
+ args: [--fix]
9
+ - id: ruff-format
10
+
11
+ - repo: local
12
+ hooks:
13
+ - id: mypy
14
+ name: mypy (strict)
15
+ entry: python -m mypy glpi_python_client
16
+ language: system
17
+ types: [python]
18
+ pass_filenames: false
19
+
20
+ - id: pytest-coverage
21
+ name: pytest with coverage (>=95%)
22
+ entry: python -m pytest -m "not integration" --cov=glpi_python_client --cov-fail-under=95 -q
23
+ language: system
24
+ pass_filenames: false
25
+ stages: [pre-push]
26
+
27
+ - id: sphinx-build
28
+ name: sphinx build (warnings as errors)
29
+ entry: python -m sphinx -b html -W --keep-going docs docs/_build/html_check
30
+ language: system
31
+ pass_filenames: false
32
+ stages: [pre-push]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glpi-python-client
3
- Version: 0.3.0
3
+ Version: 0.3.1
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/
@@ -186,6 +186,20 @@ need real concurrency:
186
186
  GLPI calls out concurrently with :func:`asyncio.gather`.
187
187
  * :meth:`AsyncGlpiClient.get_task_statistics` fans the per-ticket task
188
188
  list calls out concurrently with :func:`asyncio.gather`.
189
+ * :meth:`AsyncGlpiClient.get_task_durations` fans the per-ticket task
190
+ fetches out concurrently with :func:`asyncio.gather` when
191
+ ``return_task_details=True``.
192
+
193
+ Pagination helpers (``iter_search_tickets``, ``iter_search_users``,
194
+ ``iter_search_entities``) are exposed as **async generators** on the
195
+ async client. Iterate them with ``async for`` to walk every page
196
+ without blocking the event loop:
197
+
198
+ .. code-block:: python
199
+
200
+ async for batch in client.iter_search_tickets("status==1", batch_size=200):
201
+ for ticket in batch:
202
+ ...
189
203
 
190
204
  The synchronous versions of the same helpers issue the calls
191
205
  sequentially.
@@ -710,25 +724,79 @@ Example — description and timeline only, no metadata fields:
710
724
  Reporting helpers
711
725
  ~~~~~~~~~~~~~~~~~
712
726
 
713
- The custom statistics mixin exposes two helpers that aggregate the
727
+ The custom statistics mixin exposes several helpers that aggregate the
714
728
  ticket and ticket-task records returned by the contract-aligned mixins.
715
- Both return plain Python dictionaries so they can be serialised or
729
+ They all return plain Python dictionaries so they can be serialised or
716
730
  forwarded as-is.
717
731
 
732
+ Streaming pagination with ``iter_search_*``
733
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
734
+
735
+ The ``search_*`` helpers return one page at a time and require the
736
+ caller to manage the ``start`` cursor. The companion ``iter_search_*``
737
+ generators handle pagination automatically by yielding successive
738
+ batches until the API returns fewer rows than the requested
739
+ ``batch_size`` (the natural end-of-stream signal):
740
+
741
+ * :meth:`GlpiClient.iter_search_tickets`
742
+ * :meth:`GlpiClient.iter_search_users`
743
+ * :meth:`GlpiClient.iter_search_entities`
744
+
745
+ .. code-block:: python
746
+
747
+ # Walk every "open" ticket without loading the full result set in memory.
748
+ total = 0
749
+ for batch in client.iter_search_tickets("status==1", batch_size=200):
750
+ total += len(batch)
751
+ for ticket in batch:
752
+ print(ticket.id, ticket.name)
753
+ print(f"processed {total} tickets")
754
+
755
+ On the asynchronous client the same helpers are exposed as **async
756
+ generators** through the bridge, so each ``next()`` call runs off the
757
+ event loop and the consumer uses ``async for``:
758
+
759
+ .. code-block:: python
760
+
761
+ async for batch in async_client.iter_search_users("", batch_size=100):
762
+ for user in batch:
763
+ print(user.id, user.username)
764
+
718
765
  ``get_ticket_statistics``
719
766
  ^^^^^^^^^^^^^^^^^^^^^^^^^
720
767
 
721
768
  Counts tickets created within an ISO date window and groups them by
722
- entity, status, priority, and type.
769
+ entity, status, priority, and type. Optional filters restrict the
770
+ result set on the server side:
771
+
772
+ * ``entity_id`` — restrict to a single entity by numeric identifier.
773
+ * ``entity_name`` — substring match against the entity ``name`` column;
774
+ the helper resolves matching IDs via ``search_entities`` and ORs
775
+ them together. Ignored when ``entity_id`` is provided.
776
+ * ``extra_filter`` — raw RSQL fragment AND-joined with the date window.
723
777
 
724
778
  .. code-block:: python
725
779
 
780
+ # Tickets created in January 2026 on a specific entity, restricted to
781
+ # priority "HIGH" (5) via an extra raw RSQL fragment.
726
782
  stats = client.get_ticket_statistics(
727
783
  start_date="2026-01-01",
728
784
  end_date="2026-01-31",
785
+ entity_id=3,
786
+ extra_filter="priority==5",
729
787
  )
730
788
  print(stats)
731
789
 
790
+ # Resolve the entity by (partial) name instead of by ID:
791
+ stats = client.get_ticket_statistics(
792
+ start_date="2026-01-01",
793
+ end_date="2026-01-31",
794
+ entity_name="Helpdesk",
795
+ )
796
+
797
+ When ``entity_name`` matches no entity the helper short-circuits and
798
+ returns ``{"entities": {}}`` without issuing any ticket search.
799
+
732
800
  Returned shape (the outer key is always ``"entities"``; entity keys are
733
801
  the GLPI numeric identifier as a string, ``"unknown"`` when missing)::
734
802
 
@@ -790,6 +858,115 @@ needed (for example
790
858
  ``client.get_user(22)`` to turn user key ``"22"`` into a full
791
859
  :class:`GetUser` model).
792
860
 
861
+ ``get_task_durations``
862
+ ^^^^^^^^^^^^^^^^^^^^^^
863
+
864
+ Aggregates task durations over a date window with rich server-side
865
+ filters and an optional per-task detail list. Internally the helper
866
+ iterates :meth:`iter_search_tickets` to collect every matching ticket,
867
+ then computes per-user and per-entity totals.
868
+
869
+ Available filters:
870
+
871
+ * ``start_date`` / ``end_date`` / ``default_days`` — ISO ``YYYY-MM-DD``
872
+ date window; ``default_days`` is used when ``start_date`` is omitted.
873
+ * ``entity_id`` — restrict to a single entity by identifier.
874
+ * ``entity_name`` — substring match resolved through ``search_entities``;
875
+ ignored when ``entity_id`` is given.
876
+ * ``user_id`` — tickets where the user is **either** assignee or
877
+ requester (OR semantics).
878
+ * ``user_editor_id`` — tickets last updated by this user.
879
+ * ``user_recipient_id`` — tickets where this user is the requester.
880
+ * ``extra_filter`` — raw RSQL fragment AND-joined with everything else.
881
+ * ``return_task_details`` — when ``True``, fetch every non-zero ticket's
882
+ task list and include them as ``tasks`` in the result.
883
+
884
+ .. code-block:: python
885
+
886
+ # Sum durations for a tech on a specific entity over the last 30 days.
887
+ summary = client.get_task_durations(
888
+ entity_id=3,
889
+ user_id=42,
890
+ )
891
+ print(summary["total_duration"], summary["task_count"])
892
+ print(summary["duration_by_entity"]) # {"3": 7200}
893
+
894
+ # Same query but ask for the per-task breakdown.
895
+ detailed = client.get_task_durations(
896
+ entity_id=3,
897
+ user_id=42,
898
+ return_task_details=True,
899
+ )
900
+ for task in detailed["tasks"] or []:
901
+ print(task["task_id"], task["ticket_id"], task["duration"])
902
+
903
+ Returned shape::
904
+
905
+ {
906
+ "start_date": "2026-01-01",
907
+ "end_date": "2026-01-31",
908
+ "total_duration": 7200,
909
+ "task_count": 4,
910
+ "duration_by_user": {"42": 7200},
911
+ "duration_by_entity": {"3": 7200},
912
+ "tasks": None, # or a list[dict] when return_task_details=True
913
+ }
914
+
915
+ On the async client the same method is overridden to run the per-ticket
916
+ task fetches concurrently with :func:`asyncio.gather` when
917
+ ``return_task_details=True``.
918
+
919
+ ``get_user_activity``
920
+ ^^^^^^^^^^^^^^^^^^^^^
921
+
922
+ Aggregates per-user activity over a date window: tickets where the
923
+ user appears as technician (``users_id_assign``), tickets where the
924
+ user appears as requester (``users_id_requester``), and the user's
925
+ task duration totals. Multiple users that resolve to the same display
926
+ key (``"<firstname> <realname>"``) are merged into a single bucket.
927
+
928
+ The helper raises ``ValueError`` when no identifier is supplied or
929
+ when the search criteria match no users in the directory.
930
+
931
+ .. code-block:: python
932
+
933
+ # Activity for a single user identified by username (substring match).
934
+ report = client.get_user_activity(
935
+ username="alice",
936
+ start_date="2026-01-01",
937
+ end_date="2026-01-31",
938
+ )
939
+ for display_name, data in report["users"].items():
940
+ print(
941
+ display_name,
942
+ data["tickets_as_technician"],
943
+ data["tickets_as_recipient"],
944
+ data["task_durations"]["total_duration"],
945
+ )
946
+
947
+ # Activity for every user whose last name contains "Smith".
948
+ report = client.get_user_activity(realname="Smith", default_days=90)
949
+
950
+ Returned shape::
951
+
952
+ {
953
+ "users": {
954
+ "Alice Smith": {
955
+ "user_ids": [42],
956
+ "tickets_as_technician": 7,
957
+ "tickets_as_recipient": 2,
958
+ "task_durations": {
959
+ "start_date": "2026-01-01",
960
+ "end_date": "2026-01-31",
961
+ "total_duration": 7200,
962
+ "task_count": 4,
963
+ "duration_by_user": {"42": 7200},
964
+ "duration_by_entity": {"3": 7200},
965
+ },
966
+ }
967
+ }
968
+ }
969
+
793
970
  .. _end-to-end-examples:
794
971
 
795
972
  6. End-to-end examples
@@ -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.1"
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
 
@@ -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 inspect.iscoroutinefunction(existing):
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
- setattr(cls, name, _make_async_wrapper(member))
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"]