glpi-python-client 0.4.3__tar.gz → 0.5.0__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 (156) hide show
  1. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/.gitignore +5 -0
  2. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/CHANGELOG.md +380 -0
  3. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/PKG-INFO +6 -2
  4. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/README.md +5 -1
  5. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/api_reference.rst +56 -5
  6. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/development.md +12 -1
  7. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/user_guide.rst +81 -8
  8. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/__init__.py +3 -1
  9. {glpi_python_client-0.4.3/glpi_python_client/_sync → glpi_python_client-0.5.0/glpi_python_client/_async}/clients/commons/_payloads.py +8 -6
  10. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_errors.py +36 -4
  11. {glpi_python_client-0.4.3/glpi_python_client/_async → glpi_python_client-0.5.0/glpi_python_client/_sync}/clients/commons/_payloads.py +8 -6
  12. glpi_python_client-0.5.0/glpi_python_client/content/conversion.py +729 -0
  13. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/__init__.py +4 -3
  14. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/_base.py +135 -6
  15. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/__init__.py +7 -0
  16. glpi_python_client-0.5.0/glpi_python_client/models/api_schema/_content.py +292 -0
  17. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/assistance/_ticket.py +30 -9
  18. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/assistance/timeline/_followup.py +29 -8
  19. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/assistance/timeline/_solution.py +29 -8
  20. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/assistance/timeline/_task.py +29 -8
  21. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/knowledgebase/_article.py +49 -7
  22. glpi_python_client-0.5.0/glpi_python_client/models/api_schema/knowledgebase/_revision.py +58 -0
  23. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/custom_schema/_ticket_context.py +11 -0
  24. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/rsql.py +74 -14
  25. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/pyproject.toml +1 -1
  26. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/skills/glpi-client-setup/SKILL.md +13 -4
  27. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/skills/glpi-document-workflow/SKILL.md +1 -1
  28. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/skills/glpi-knowledge-base/SKILL.md +3 -1
  29. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/skills/glpi-plugin-fields/SKILL.md +2 -2
  30. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/skills/glpi-reporting-and-context/SKILL.md +2 -2
  31. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/skills/glpi-team-members/SKILL.md +1 -1
  32. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/skills/glpi-ticket-timeline/SKILL.md +3 -2
  33. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/skills/glpi-ticket-workflow/SKILL.md +4 -2
  34. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/skills/glpi-user-location-provisioning/SKILL.md +1 -1
  35. glpi_python_client-0.4.3/glpi_python_client/content/conversion.py +0 -130
  36. glpi_python_client-0.4.3/glpi_python_client/models/api_schema/_content.py +0 -83
  37. glpi_python_client-0.4.3/glpi_python_client/models/api_schema/knowledgebase/_revision.py +0 -35
  38. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/.pre-commit-config.yaml +0 -0
  39. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/.readthedocs.yaml +0 -0
  40. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/CONTRIBUTING.md +0 -0
  41. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/LICENSE +0 -0
  42. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/_static/.gitkeep +0 -0
  43. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/conf.py +0 -0
  44. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/development_rtd.rst +0 -0
  45. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/index.rst +0 -0
  46. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/installation.rst +0 -0
  47. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/publishing.md +0 -0
  48. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/publishing_rtd.rst +0 -0
  49. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/docs/sponsoring.rst +0 -0
  50. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/__init__.py +0 -0
  51. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/_concurrency.py +0 -0
  52. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/_testing.py +0 -0
  53. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/auth/__init__.py +0 -0
  54. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/auth/_v1_session.py +0 -0
  55. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/auth/auth.py +0 -0
  56. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/__init__.py +0 -0
  57. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/_base_client.py +0 -0
  58. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/__init__.py +0 -0
  59. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/administration/__init__.py +0 -0
  60. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/administration/_entity.py +0 -0
  61. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/administration/_user.py +0 -0
  62. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/assistance/__init__.py +0 -0
  63. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/assistance/_team.py +0 -0
  64. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/assistance/_ticket.py +0 -0
  65. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/assistance/timeline/__init__.py +0 -0
  66. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/assistance/timeline/_document.py +0 -0
  67. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/assistance/timeline/_followup.py +0 -0
  68. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/assistance/timeline/_solution.py +0 -0
  69. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/assistance/timeline/_task.py +0 -0
  70. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/dropdowns/__init__.py +0 -0
  71. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/dropdowns/_location.py +0 -0
  72. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/knowledgebase/__init__.py +0 -0
  73. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/knowledgebase/_article.py +0 -0
  74. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/knowledgebase/_category.py +0 -0
  75. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/knowledgebase/_comment.py +0 -0
  76. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/knowledgebase/_revision.py +0 -0
  77. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/management/__init__.py +0 -0
  78. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/management/_document.py +0 -0
  79. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/plugins/__init__.py +0 -0
  80. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/api/plugins/_fields.py +0 -0
  81. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/client.py +0 -0
  82. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/commons/__init__.py +0 -0
  83. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/commons/_config.py +0 -0
  84. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/commons/_constants.py +0 -0
  85. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/commons/_filters.py +0 -0
  86. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/commons/_http.py +0 -0
  87. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/commons/_transport.py +0 -0
  88. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/custom/__init__.py +0 -0
  89. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/custom/_statistics.py +0 -0
  90. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_async/clients/custom/_ticket_context.py +0 -0
  91. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/__init__.py +0 -0
  92. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/_concurrency.py +0 -0
  93. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/_testing.py +0 -0
  94. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/auth/__init__.py +0 -0
  95. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/auth/_v1_session.py +0 -0
  96. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/auth/auth.py +0 -0
  97. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/__init__.py +0 -0
  98. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/_base_client.py +0 -0
  99. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/__init__.py +0 -0
  100. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/administration/__init__.py +0 -0
  101. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/administration/_entity.py +0 -0
  102. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/administration/_user.py +0 -0
  103. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/assistance/__init__.py +0 -0
  104. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/assistance/_team.py +0 -0
  105. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/assistance/_ticket.py +0 -0
  106. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/assistance/timeline/__init__.py +0 -0
  107. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py +0 -0
  108. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py +0 -0
  109. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py +0 -0
  110. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/assistance/timeline/_task.py +0 -0
  111. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/dropdowns/__init__.py +0 -0
  112. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/dropdowns/_location.py +0 -0
  113. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/knowledgebase/__init__.py +0 -0
  114. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/knowledgebase/_article.py +0 -0
  115. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/knowledgebase/_category.py +0 -0
  116. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/knowledgebase/_comment.py +0 -0
  117. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/knowledgebase/_revision.py +0 -0
  118. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/management/__init__.py +0 -0
  119. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/management/_document.py +0 -0
  120. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/plugins/__init__.py +0 -0
  121. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/api/plugins/_fields.py +0 -0
  122. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/client.py +0 -0
  123. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/commons/__init__.py +0 -0
  124. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/commons/_config.py +0 -0
  125. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/commons/_constants.py +0 -0
  126. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/commons/_filters.py +0 -0
  127. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/commons/_http.py +0 -0
  128. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/commons/_transport.py +0 -0
  129. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/custom/__init__.py +0 -0
  130. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/custom/_statistics.py +0 -0
  131. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/_sync/clients/custom/_ticket_context.py +0 -0
  132. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/content/__init__.py +0 -0
  133. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/_common.py +0 -0
  134. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/administration/__init__.py +0 -0
  135. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/administration/_entity.py +0 -0
  136. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/administration/_user.py +0 -0
  137. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/assistance/__init__.py +0 -0
  138. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/assistance/_team.py +0 -0
  139. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/assistance/timeline/__init__.py +0 -0
  140. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/assistance/timeline/_document.py +0 -0
  141. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/dropdowns/__init__.py +0 -0
  142. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/dropdowns/_location.py +0 -0
  143. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/enums.py +0 -0
  144. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/knowledgebase/__init__.py +0 -0
  145. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/knowledgebase/_category.py +0 -0
  146. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/knowledgebase/_comment.py +0 -0
  147. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/management/__init__.py +0 -0
  148. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/management/_document.py +0 -0
  149. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/plugins/__init__.py +0 -0
  150. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/api_schema/plugins/_fields.py +0 -0
  151. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/models/custom_schema/__init__.py +0 -0
  152. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/py.typed +0 -0
  153. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/testing/__init__.py +0 -0
  154. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/testing/fixtures.py +0 -0
  155. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/glpi_python_client/testing/utils.py +0 -0
  156. {glpi_python_client-0.4.3 → glpi_python_client-0.5.0}/skills/README.md +0 -0
@@ -37,3 +37,8 @@ docs/glpi_api_contract.json
37
37
  # Coverage data: rewritten by every test run (and by the venv .pth hook).
38
38
  .coverage
39
39
  .coverage.*
40
+
41
+ # One-shot live-instance probe scripts. They are standalone investigations
42
+ # (a main() run by hand against preprod), not collected tests -- the findings
43
+ # get written up in CHANGELOG.md, the scripts stay local.
44
+ integration_tests/probe_*.py
@@ -4,6 +4,361 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## 0.5.0 — 2026-09-08
8
+
9
+ ### Fixed
10
+
11
+ - **Deeply nested HTML raised `RecursionError` while a model was being
12
+ validated.** `markdownify` walks the parsed document recursively and
13
+ spends about two CPython frames per nesting level, so roughly 494 levels
14
+ exhausted the default 1000-frame limit — measured, and the same 494 for
15
+ `<div>`, `<p>`, `<blockquote>` and `<table><tr><td>`, 495 for
16
+ `<ul><li>`, which is what identifies the cost as per-level. An
17
+ unclosed tag counts too: `html.parser` does not auto-close `<p>` or
18
+ `<li>`, so `"<p>" * 5000` really is 5000 levels.
19
+
20
+ Because the converter was wired as a Pydantic `BeforeValidator`, the
21
+ failure landed inside `model_validate` — that is, inside `get_ticket` —
22
+ as a bare builtin from a library whose whole error surface is supposed
23
+ to derive from `GlpiError`.
24
+
25
+ `from_transport` now *attempts* the conversion and answers the
26
+ `RecursionError` by stripping the document to its text instead.
27
+ **It degrades, it never truncates, and it does not raise for depth**:
28
+ every character of prose the converting path would have produced also
29
+ appears in the degraded rendering.
30
+
31
+ Attempting it rather than predicting it is the whole design, and it
32
+ replaced a fixed `MAX_HTML_DEPTH = 200` bound that was wrong in both
33
+ directions. Too low, because the budget is not 1000 frames but whatever
34
+ is left of the stack when the conversion starts, and that belongs to
35
+ the caller — so the bound had to assume the worst and flattened every
36
+ body between 200 and the real cliff of about 494. Measured, a
37
+ 300-level and a 400-level body now come back as **Markdown with their
38
+ links, emphasis and lists intact** where they used to come back as
39
+ plain text, with no error to notice and no way to ask for better. And
40
+ too fragile, because predicting the depth meant reproducing the
41
+ parser's idea of the tree: three rounds of adversarial review found
42
+ seven ways for that estimate to land *under* the real depth, each of
43
+ which sent a document to `markdownify` and into the very
44
+ `RecursionError` the bound existed to prevent.
45
+
46
+ Trying the conversion cannot be wrong about whether the conversion
47
+ fits. `MAX_HTML_DEPTH` and the scan behind it are gone; the constant
48
+ was introduced in this same unreleased cycle and never shipped.
49
+
50
+ Two consequences worth knowing. The outcome now depends on the caller's
51
+ remaining stack, so the same body can convert from one call site and
52
+ degrade from a deeper one — nothing is lost either way, but a caller
53
+ comparing two renderings of one body should know which knob moved it.
54
+ And a body too deep to convert now pays the failed attempt before it
55
+ degrades: measured, 2.0x to 2.6x the old cost at 600 and 5000 levels.
56
+ Ordinary bodies got *faster*, at 0.87x to 0.90x, because the scan they
57
+ used to pay for on every read is gone.
58
+
59
+ A document `html.parser` refuses outright — `<![FOO[`, an unknown
60
+ marked-section keyword, which `bs4` re-raises as
61
+ `ParserRejectedMarkup` — takes the same degraded path, where it used
62
+ to raise and give the caller none of their text.
63
+
64
+ Both halves were fuzzed against the real parser over 15000 documents,
65
+ with zero under-counts, zero over-counts and zero text losses. Getting
66
+ there took several rules that are not the obvious ones:
67
+
68
+ - A closing tag pops by name or is ignored — `bs4` pops nothing when no
69
+ element of that name is open, so `"<div></p>" * 600` really is 600
70
+ deep where a naive counter says 1.
71
+ - An attribute value may contain `<` and `>`, so
72
+ `'<div title="</div>">' * 600` also measured 0 against a real 600
73
+ until the scan learned to skip quoted values.
74
+ - A quote opens a value only as the first character after the `=`,
75
+ which is the parser's own rule, so `<p title=don't>` carries the
76
+ value `don't`. Reading that apostrophe as a quote printed the opening
77
+ tag verbatim at the reader — and an apostrophe needs no malice to
78
+ reach a French ticket body.
79
+ - `tagfind_tolerant` runs a tag *name* to whitespace, `/` or `>`, so
80
+ `<style=>` is an element named `style=` and never enters raw-text
81
+ mode; a self-closed `<script/>` does not either, because
82
+ `parse_starttag` enters it only on the branch that is not
83
+ self-closing. Reading either as raw text swallowed the rest of the
84
+ document: `"<style=>" + "<div>" * 600` measured 1 level against a
85
+ real 601 and raised.
86
+ - A declaration is text on neither path only when it is closed. A
87
+ `<!weird` left unterminated at end of input is flushed as character
88
+ data when the parser closes, so dropping it lost the tail of a body.
89
+ - An unclosed tag counts, a childless node still occupies a level, and
90
+ a bogus comment swallows the tags inside it.
91
+ - The degraded path had to be measured against the converting path
92
+ construct by construct rather than reasoned about. Three answers came
93
+ back the opposite way round: a `<script>`/`<style>` body is *kept*
94
+ (`markdownify`'s `strip=` removes an element's markup and still walks
95
+ its children), so is a `CDATA` body, and so is the inside of any
96
+ `<!`/`<?` construct the parser could not resolve.
97
+
98
+ What the degraded rendering does not reproduce, none of it prose: link
99
+ targets and image alt text, fenced-block and `<pre>` indentation,
100
+ `&nbsp;`-padded alignment, and a processing instruction's `<?`/`>`
101
+ delimiters, which survive as literal text.
102
+
103
+ Character references are resolved by the parser's rule rather than by
104
+ `html.unescape`, which implements HTML5's longest-known-*prefix* rule
105
+ and would rewrite a pasted URL: `?a=1&copyright=2` becomes
106
+ `?a=1©right=2` under `unescape` and is left alone by the parser. A
107
+ semicolon-less reference resolves only when its whole name is known.
108
+
109
+ The ceiling is 200 rather than 494 because the budget is not 1000
110
+ frames, it is whatever is left of the stack when conversion starts, and
111
+ that belongs to the caller. The package's own contribution is small,
112
+ and since conversion moved to the attribute it no longer depends on how
113
+ the record was fetched: measured, 5 frames below the caller when
114
+ `.content` is read — the same 5 whether the model came from
115
+ `model_validate` or from `client.get_ticket` — and 9 on the write path,
116
+ where the renderer runs inside `model_dump`. What is not small is an
117
+ application reading `.content` from inside a request handler or a
118
+ recursive walk. Converting a 200-level document peaks at a measured 412
119
+ frames, so it stays safe until the caller's own stack passes about
120
+ 588 — and no document a human wrote nests 200 elements deep.
121
+
122
+ **`sys.setrecursionlimit` was considered and rejected.** It is
123
+ process-global state belonging to the application, not to a library the
124
+ application imported; and past what the C stack can hold it converts a
125
+ catchable `RecursionError` into a hard interpreter crash — on Windows,
126
+ an access violation with no traceback. It moves the cliff and makes
127
+ falling off it worse. The prohibition is asserted by
128
+ `testing/tests/test_raise_site_audit.py` rather than left as a comment
129
+ for the next person to weigh up again.
130
+
131
+ - **Where a tag *ends* was read with start-tag rules, twice.** Both were
132
+ unbounded depth under-counts, which is the one direction the ceiling
133
+ exists to prevent, and both also deleted prose from the degraded path at
134
+ any depth.
135
+
136
+ `parse_endtag` falls back to `rawdata.find(">")`, so an end tag skips
137
+ nothing — CPython's own comment concedes the case: "this is not 100%
138
+ correct, since we might have things like `</tag attr=">">`". Reading one
139
+ with attribute rules made `'</x a="><div>">' * 600` measure **0**
140
+ against a real 600.
141
+
142
+ `locatestarttagend_tolerant` reaches a quoted value only through an
143
+ attribute *name*, and a name may itself begin with `=`. So in
144
+ `<div ="<p><p>">` the parser reads the name `="<p` and ends the tag at
145
+ the first `>`, where treating any `=` before a quote as a value
146
+ indicator swallowed the rest: `'<div ="' + "<p>" * 600` measured **1**
147
+ against a real 600.
148
+
149
+ The attribute pattern is now a sequence of attributes rather than a run
150
+ of permitted characters, and end tags have their own branch. The
151
+ attribute name carries the parser's own "starts after a quote,
152
+ whitespace or `/`" rule, which is load-bearing twice over: without it
153
+ `<div ="` reads as a value, *and* the pattern backtracks
154
+ catastrophically — a 400-byte `'<div a="' * 50` did not finish.
155
+
156
+ - **A body of nothing but `<div a="` cost O(n²).** 32 KB took 6.6 s. `re`
157
+ restarts at every `<` where `html.parser` buffers an incomplete tag and
158
+ never looks back. No `>` anywhere means no element anywhere, so that is
159
+ now answered in constant time. The pattern's remaining non-linear
160
+ shapes turned out to be exponential rather than quadratic, and are gone
161
+ with the pattern itself — see the entry below.
162
+
163
+ - **The markup scan imitated `html.parser` instead of using it, and was
164
+ wrong in five unbounded ways at once.** A third adversarial round found
165
+ that the pattern reproducing the parser's dispatch disagreed with the
166
+ parser on: a comment closing on `--\s*>` rather than only `-->`; `</
167
+ script>` ending raw text; `<![IGNORE[` opening a marked section; `</ div
168
+ foo>` being a bogus comment rather than an end tag; and `<a href=/>` —
169
+ an ordinary root-relative link — leaving the element *open*, because the
170
+ unquoted value swallows the `/`. Each made a document measure one level
171
+ deep where the real tree was hundreds, so `'<a href=/>' * 494` cleared
172
+ the ceiling and raised. Two further findings were cost: a run of
173
+ whitespace inside a failing tag made the attribute pattern backtrack as
174
+ `(a+)*`, and a 39-byte body took 20.8 s.
175
+
176
+ The pattern is gone. Depth, void-tag canonicalisation and the degraded
177
+ rendering now come from one pass of an `html.parser` subclass — the same
178
+ parser `bs4` uses, so this cannot be wrong about the parser and is not a
179
+ new dependency or a new risk. Every pathology `html.parser` has was
180
+ already in the pipeline: measured on the shapes that made the pattern
181
+ backtrack, the `markdownify` call costs what the scan costs, to within a
182
+ few per cent.
183
+
184
+ Three consequences beyond the five defects:
185
+
186
+ - **A derailed scan silently reinstated the `<br>` data loss fixed
187
+ below**, because the void-tag workaround read the same pattern.
188
+ Measured, `"<p>one<br>two</p><script>x</ script><p>three<br
189
+ />TAIL</p>"` lost `TAIL` outright, and `<img>` and `<hr>` lost their
190
+ tails the same way.
191
+ - **A document the parser rejects now degrades instead of raising.**
192
+ `<![FOO[` makes `_markupbase` raise `AssertionError` on the
193
+ interpreters where that keyword is unknown — 3.10 through 3.12.11 as
194
+ measured, no longer 3.12.14 — and `bs4` re-raises it as
195
+ `ParserRejectedMarkup`. Where it happens, the caller used to get a
196
+ `GlpiContentError` and none of their text, and now gets their words.
197
+
198
+ Note that `html.parser`'s reading of a *malformed* construct is not
199
+ stable across CPython patch releases: the same three builds disagree
200
+ about an unterminated `<script>`, a comment with no `-->` and an end
201
+ tag carrying a quoted `>`. The scan tracks the parser rather than a
202
+ snapshot of it, so the depth decision stays correct on every version,
203
+ but the exact text a broken construct contributes to a degraded body is
204
+ the interpreter's. Well-formed content is unaffected.
205
+ - **A processing instruction no longer leaves `<?` and `>` in the
206
+ degraded text.** The converting path prints the body alone, so this
207
+ does too.
208
+
209
+ Cost, end to end and on identical output: 0.77x to 1.52x of the previous
210
+ implementation on realistic bodies. The exponential shapes are flat: 39
211
+ bytes of the whitespace bomb went from 20.8 s to 0.12 ms, and 20 KB of
212
+ it costs 0.67 ms. The depth scan those defects were found in has since
213
+ been removed altogether — see the entry above — but the same parser now
214
+ backs the void-tag rewrite and the degraded renderer, which inherited
215
+ every one of the misreadings and the backtracking too.
216
+
217
+ The one shape where `html.parser` is worse than linear is a document
218
+ carrying no `>` at all, where `close()` advances a character at a time
219
+ and rescans the tail: 32 KB costs it 13 s. That is answered in constant
220
+ time by the guard already present for the pattern's own O(n²) on the
221
+ same input, and is unreachable from `from_transport`, which needs a `>`
222
+ to find an element at all.
223
+
224
+ Re-fuzzed against a ground-truth walk of the tree `bs4` really builds,
225
+ over an alphabet carrying every construct all three rounds raised —
226
+ including the four whose absence is why the previous 10.5M-document
227
+ corpus could not have found these: `-- >`, `</ script>`, `<![IGNORE[`
228
+ and runs of whitespace and quotes inside a tag. **470000 documents, 0
229
+ depth under-counts, 0 prose losses, 0 crashes**, plus 60000 hostile
230
+ documents through `from_transport` with nothing but `GlpiError`
231
+ escaping.
232
+
233
+ - **`GlpiContentError` did not survive the write path.** Outbound
234
+ conversion runs in a `PlainSerializer`, and pydantic-core catches
235
+ everything a serializer raises and re-raises `PydanticSerializationError`
236
+ — a `ValueError`, not a `GlpiError`, with `__cause__` and `__context__`
237
+ both `None`. So on every `create_*`/`update_*` carrying a body,
238
+ `except GlpiError` did not fire and the underlying fault was
239
+ unrecoverable. The fault is now stashed as it is raised and restored
240
+ around `model_dump`, with its own `__cause__` intact; a serialisation
241
+ failure that is *not* content becomes `GlpiValidationError` rather than
242
+ being mislabelled.
243
+
244
+ - **One field spelled two ways shadowed itself in `model_dump`.** Pydantic
245
+ consumes the first alias and `extra="allow"` files the rest as model
246
+ extras — and an extra named after a field is emitted *instead of* that
247
+ field, so the attribute reported one body and the object's own dump
248
+ reported the other. The redundant spelling is now dropped before
249
+ Pydantic resolves anything, and `content_html` is the first choice, so a
250
+ dump carrying both round-trips back to the raw body.
251
+
252
+ Worth knowing about the read models: `model_copy(update={"content": ...})`
253
+ — the 0.4.x spelling — updates **nothing**, because `content` is now a
254
+ `cached_property`. A caller redacting a body that way gets an object
255
+ whose `.content` still holds the original. Rewrite `content_html`, or
256
+ rebuild through `model_validate`.
257
+
258
+ - **A body that used both spellings of `<br>` lost everything after the
259
+ second one.** `<p>line1<br>line2</p><p>para2<br />line4</p>` converted
260
+ to `line1 \nline2\n\npara2` — `line4` silently gone, no error, on the
261
+ ordinary conversion path.
262
+
263
+ The cause is in `beautifulsoup4` (measured on 4.14.3), not in
264
+ `markdownify`. Its `html.parser` builder auto-closes a bare `<br>` and
265
+ records the name in `already_closed_empty_element` so a later `</br>`
266
+ can be ignored as redundant; when no `</br>` arrives the entry just
267
+ stays. The next `<br />` reaches the builder as `handle_startendtag`,
268
+ opens a real element and closes it itself — and that close finds the
269
+ stale entry, treats the element as already closed, and leaves it open,
270
+ so every following sibling becomes a child of the `<br>`.
271
+ `markdownify`'s `convert_br` ignores an element's children, and the
272
+ text is gone. `get_text` walks children, which is why the tree looks
273
+ intact.
274
+
275
+ Note the paragraph in the example: the two spellings need not be near
276
+ each other, since a name once recorded poisons the rest of the
277
+ document. `<img>` and `<hr>` are the other two converters that discard
278
+ children and lost text the same way.
279
+
280
+ `from_transport` now writes self-closing void tags bare before
281
+ converting, which removes the `handle_startendtag` path where the
282
+ asymmetry lives. Both spellings already built the same node, so nothing
283
+ else moves: measured over 4000 fuzzed documents of each spelling alone,
284
+ not one output changed, and over 4000 mixing them, 102 recovered text
285
+ and none lost any. Only names in the void set are touched, and only in
286
+ real tag position — a `<div/>`, a `<br />` inside an attribute value, a
287
+ comment or a `<script>` body are all left alone.
288
+
289
+ - **`GlpiModel` now recognises validation aliases when it captures unknown
290
+ keys.** `_capture_unknown_fields` runs before Pydantic resolves aliases
291
+ and compared incoming keys against field *names* only, so an aliased key
292
+ was diverted into `extra_payload` before its field could see it — HTTP
293
+ 200, no warning, and the value silently `None`. Latent until this
294
+ release, which introduces the package's first alias.
295
+
296
+ ### Added
297
+
298
+ - **`GlpiContentError`** — a new `GlpiError` leaf for a rich-text body
299
+ that could not be converted, in either direction, with the underlying
300
+ fault attached as `__cause__`. Exported from the package root and
301
+ documented in the API reference.
302
+
303
+ Content conversion previously sat outside the taxonomy altogether: a
304
+ parser fault escaped `except GlpiError` and reached the caller as a bare
305
+ builtin. The depth ceiling above means no ordinary input gets here, so
306
+ this is the backstop — including for the outbound direction, where
307
+ `markdown` has its own cliff at around 500 levels of list indentation.
308
+
309
+ Unlike `GlpiStatusError`, `GlpiValidationError` and `GlpiProtocolError`
310
+ it does **not** inherit `ValueError`. Those three carry it for
311
+ compatibility with releases that raised bare `ValueError` at the same
312
+ sites; there was never a `ValueError` at a conversion site, and a parser
313
+ exhausting the stack is not a value the caller got wrong. Same reasoning
314
+ as `GlpiTransportError`.
315
+
316
+ - **`content_html` on the read models**, holding the wire value verbatim:
317
+ `GetTicket`, `GetFollowup`, `GetTicketTask`, `GetSolution`,
318
+ `GetKBArticleRevision`, and `GetKBArticle` (which also gains
319
+ `description_html`).
320
+
321
+ ### Changed (breaking)
322
+
323
+ - **Read models convert to Markdown on first access instead of during
324
+ validation.** `content` is now a `functools.cached_property` over
325
+ `content_html`:
326
+
327
+ ```python
328
+ ticket = client.get_ticket(42)
329
+ ticket.content_html # '<p>Printer is <strong>offline</strong></p>'
330
+ ticket.content # 'Printer is **offline**' (converted here, once)
331
+ ```
332
+
333
+ **Callers that read `.content` need no change.** The field carries the
334
+ validation alias `content`, so a GLPI payload and a hand-written
335
+ `GetTicket(content=...)` both still populate it, and `.content` still
336
+ returns Markdown. What changes is *when*.
337
+
338
+ Two things follow. A caller who wants only `id` and `date_mod` no longer
339
+ pays HTML-to-Markdown on every record of every page. And a body that
340
+ cannot be converted no longer takes its page-mates with it:
341
+ `TransportMixin._resource_list` builds every item of a page in one
342
+ comprehension, so one unconvertible record used to make the whole page
343
+ unreadable — the failure is now scoped to the record whose body is
344
+ actually read.
345
+
346
+ Write models (`Post*`, `Patch*`) are deliberately unchanged: they keep
347
+ the plain `content` field and convert eagerly, so a caller's own
348
+ Markdown is still checked where it was supplied, and there is no list
349
+ path on a write model to make lazy.
350
+
351
+ What does break: `content` is no longer in `GetTicket.model_fields`, and
352
+ `GetTicket(...).model_dump()` emits `content_html` holding HTML where it
353
+ used to emit `content` holding Markdown (`by_alias=True` gives a dump
354
+ keyed the way GLPI keys it).
355
+
356
+ One sharp edge comes with the cache. Assigning to `content_html` after
357
+ `.content` has been read leaves the stale Markdown in place, and so does
358
+ `model_copy(update={"content_html": ...})` — and neither equality,
359
+ `repr` nor any `model_dump` reveals it. Treat a read model as immutable
360
+ once validated, or rebuild it through `model_validate`.
361
+
7
362
  ## 0.4.3 — 2026-08-13
8
363
 
9
364
  ### Changed (breaking)
@@ -34,6 +389,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
34
389
  database; without it `zoneinfo` resolves on Linux CI and raises on a
35
390
  developer machine.
36
391
 
392
+ - **`changed_since` no longer assumes UTC.** An aware `datetime` now needs the
393
+ server's timezone and raises `GlpiValidationError` without it:
394
+
395
+ ```python
396
+ window = changed_since(last_run, tz=client.server_timezone)
397
+ ```
398
+
399
+ The bound this builds is compared against a naive server-local column, so
400
+ converting the caller's moment to UTC asked the server for a different one.
401
+ A 09:33 Paris timestamp became a `07:33` filter. East of UTC that only
402
+ re-reads a few hours on every sweep; west of it the bound moves *forward*
403
+ and modifications are skipped outright — four hours in New York, seven in
404
+ Los Angeles — and the size of the drift changes at each DST transition, so
405
+ a sync that looks correct in January starts losing rows in March.
406
+
407
+ The offset is now spent converting the value onto the server's clock and
408
+ then dropped, which is what the model serialiser already does on the way
409
+ out; the two halves had diverged by exactly the offset. Missing `tz` is
410
+ refused rather than defaulted for the same reason `server_timezone` has no
411
+ default: every guess is wrong somewhere, and being wrong here returns a
412
+ short result set rather than an error.
413
+
414
+ A `date`, an ISO string, or a naive `datetime` is unaffected and needs no
415
+ `tz` — a naive value already means the server's clock.
416
+
37
417
  - **Search endpoints now raise on a 4xx instead of returning `[]`.** The seven
38
418
  `search_*` helpers passed no `failure_message` to `_resource_list`, which
39
419
  skipped the status check entirely, so a 400, 401, 403 or 404 came back as an
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: glpi-python-client
3
- Version: 0.4.3
3
+ Version: 0.5.0
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/
@@ -70,7 +70,11 @@ The goal is to let GLPI integrations work with domain objects instead of raw
70
70
  JSON payloads. The package exposes Pydantic models for tickets, users,
71
71
  followups, documents, locations, and related records, while converting GLPI
72
72
  HTML content into Markdown for Python-side workflows and rendering Markdown
73
- back to HTML for outgoing payloads.
73
+ back to HTML for outgoing payloads. On a response model that conversion is
74
+ lazy — `.content` converts on first read and caches, so listing records
75
+ costs nothing per body — and it degrades to plain text rather than failing
76
+ on pathologically nested HTML. See
77
+ [Rich-text content](https://glpi-python-client.readthedocs.io/en/latest/user_guide.html#content-conversion).
74
78
 
75
79
  It currently focuses on ticket-centric workflows and exposes two high-level
76
80
  clients built on top of the GLPI v2 REST API:
@@ -12,7 +12,11 @@ The goal is to let GLPI integrations work with domain objects instead of raw
12
12
  JSON payloads. The package exposes Pydantic models for tickets, users,
13
13
  followups, documents, locations, and related records, while converting GLPI
14
14
  HTML content into Markdown for Python-side workflows and rendering Markdown
15
- back to HTML for outgoing payloads.
15
+ back to HTML for outgoing payloads. On a response model that conversion is
16
+ lazy — `.content` converts on first read and caches, so listing records
17
+ costs nothing per body — and it degrades to plain text rather than failing
18
+ on pathologically nested HTML. See
19
+ [Rich-text content](https://glpi-python-client.readthedocs.io/en/latest/user_guide.html#content-conversion).
16
20
 
17
21
  It currently focuses on ticket-centric workflows and exposes two high-level
18
22
  clients built on top of the GLPI v2 REST API:
@@ -27,11 +27,13 @@ from it, so neither wraps the other and the two cannot drift apart.
27
27
  Exceptions
28
28
  ----------
29
29
 
30
- Exceptions raised for a bad argument, an unexpected HTTP status, or an
31
- unusable response body derive from :class:`GlpiError`.
32
- :class:`GlpiStatusError`, :class:`GlpiValidationError` and
33
- :class:`GlpiProtocolError` also inherit :class:`ValueError` for backwards
34
- compatibility with releases that raised bare ``ValueError``.
30
+ Exceptions raised for a bad argument, an unexpected HTTP status, an
31
+ unusable response body, or content that cannot be converted derive from
32
+ :class:`GlpiError`. :class:`GlpiStatusError`, :class:`GlpiValidationError`
33
+ and :class:`GlpiProtocolError` also inherit :class:`ValueError` for
34
+ backwards compatibility with releases that raised bare ``ValueError``;
35
+ :class:`GlpiContentError` and :class:`GlpiTransportError` do not, because
36
+ nothing was passed in wrongly in either case.
35
37
 
36
38
  Network-level faults (connection failures, DNS errors, timeouts) are
37
39
  raised as :class:`GlpiTransportError`, or its :class:`GlpiTimeoutError`
@@ -80,6 +82,55 @@ guide for the full picture, including which methods raise which type.
80
82
  :members:
81
83
  :show-inheritance:
82
84
 
85
+ .. autoexception:: GlpiContentError
86
+ :members:
87
+ :show-inheritance:
88
+
89
+ Rich-text content
90
+ -----------------
91
+
92
+ GLPI exchanges ticket, followup, task, solution and knowledge-base bodies
93
+ as HTML. The package's surface is Markdown in both directions, but the two
94
+ directions work differently, and the difference is visible.
95
+
96
+ A **write** model (``Post*``, ``Patch*``) takes Markdown in ``content`` and
97
+ renders it to HTML when the request is built. Nothing to think about.
98
+
99
+ A **read** model (``Get*``) keeps two views of the same body:
100
+
101
+ ``content_html``
102
+ what GLPI sent, verbatim. Also accepts the wire spelling ``content`` on
103
+ construction.
104
+
105
+ ``content``
106
+ the same body as Markdown, converted on the first read and cached.
107
+ :class:`GetKBArticle` has ``description`` / ``description_html`` as
108
+ well.
109
+
110
+ Reading ``.content`` is what a caller wants and what earlier releases
111
+ returned, so no read-side code needs changing. What changed is *when* the
112
+ conversion happens, which buys two things: listing records costs nothing
113
+ per body, and a body that cannot be converted no longer stops the rest of
114
+ its page being read.
115
+
116
+ Very deeply nested HTML is the case worth knowing about.
117
+ ``markdownify`` walks the document recursively and runs out of stack at
118
+ around 494 levels of nesting. The converter does not try to predict
119
+ that: it attempts the conversion and, if the walk does not fit, strips
120
+ tags instead. It degrades, it never truncates, and it does not raise:
121
+ every character the normal rendering would have produced still appears.
122
+ What is lost is structure rather than words — link targets and image alt
123
+ text, code fencing and ``<pre>`` indentation, ``&nbsp;`` alignment.
124
+ Because the budget is the stack left when the conversion starts, the
125
+ same body can convert from one call site and degrade from a deeper one.
126
+ Anything else that goes wrong in either direction raises
127
+ :class:`GlpiContentError`.
128
+
129
+ Because the conversion is cached on first read, a read model should be
130
+ treated as immutable afterwards: assigning to ``content_html``, or
131
+ ``model_copy(update={"content_html": ...})``, leaves the cached Markdown
132
+ in place. Rebuild through ``model_validate`` if you need to change it.
133
+
83
134
  Aggregated Models
84
135
  -----------------
85
136
 
@@ -82,7 +82,18 @@ python -m pytest
82
82
  - `glpi_python_client.models` contains typed request and response
83
83
  models.
84
84
  - `glpi_python_client.content` handles HTML/Markdown conversion for
85
- ticket descriptions, followups, tasks, and solutions.
85
+ ticket descriptions, followups, tasks, solutions, knowledge-base
86
+ articles (`content` and `description`) and article revisions. It is
87
+ wired into the models by `models/api_schema/_content.py`, eagerly on
88
+ the write models and through a cached property on the read ones.
89
+ Inbound HTML too deep for `markdownify` to walk is tag-stripped
90
+ rather than parsed. The depth is not predicted: the conversion is
91
+ attempted and the `RecursionError` answered, because the budget is the
92
+ caller's remaining stack and no bound computed in advance can know it.
93
+ The module docstring carries the derivation and the rejected
94
+ alternatives (`sys.setrecursionlimit`, and a thread with a larger
95
+ stack, which needs the same global). The prohibition is enforced by
96
+ `testing/tests/test_raise_site_audit.py`, not just written down.
86
97
  - `glpi_python_client.testing` exposes `make_client` and
87
98
  `make_async_client` factories that produce in-memory clients with no
88
99
  real HTTP plumbing for downstream test suites, plus the shared
@@ -586,7 +586,11 @@ Knowledge base
586
586
  The knowledge base mixins map to ``/Knowledgebase``. Articles and
587
587
  categories expose the ``search_ / get_ / create_ / update_ / delete_``
588
588
  shape; comments are nested under an article; revisions are read-only.
589
- Article ``content`` and ``description`` accept and return Markdown. An
589
+ Article ``content`` and ``description`` accept and return Markdown; on
590
+ :class:`~glpi_python_client.GetKBArticle` they are properties over
591
+ ``content_html`` and ``description_html``, converted on first read -- see
592
+ :ref:`content-conversion`, which matters here because searching the
593
+ knowledge base returns whole article bodies. An
590
594
  article's ``categories`` association is read-only in the v2 GLPI contract,
591
595
  so the client sets it through a legacy fallback — see
592
596
  `Assigning categories`_.
@@ -1429,9 +1433,9 @@ Example output::
1429
1433
  -----------------
1430
1434
 
1431
1435
  Exceptions the client raises for a bad argument, an unexpected HTTP
1432
- status, or an unusable response body derive from
1433
- :class:`~glpi_python_client.GlpiError`, so one handler covers that part
1434
- of the library surface:
1436
+ status, an unusable response body, or content it cannot convert derive
1437
+ from :class:`~glpi_python_client.GlpiError`, so one handler covers that
1438
+ part of the library surface:
1435
1439
 
1436
1440
  .. code-block:: python
1437
1441
 
@@ -1474,15 +1478,17 @@ The hierarchy lets you narrow as far as you need:
1474
1478
  .. code-block:: text
1475
1479
 
1476
1480
  GlpiError
1477
- ├── GlpiTransportError reserved for the httpx transport swap;
1478
- │ └── GlpiTimeoutError not raised yet -- see the note above
1481
+ ├── GlpiTransportError the request never produced a response
1482
+ │ └── GlpiTimeoutError GLPI was too slow
1479
1483
  ├── GlpiStatusError GLPI answered with an unexpected status
1480
1484
  │ ├── GlpiAuthError 401 / 403
1481
1485
  │ ├── GlpiNotFoundError 404
1482
1486
  │ └── GlpiServerError 5xx (retried up to 3 attempts before it
1483
1487
  │ reaches you)
1484
1488
  ├── GlpiValidationError the client rejected your argument
1485
- └── GlpiProtocolError GLPI answered 2xx with an unusable body
1489
+ ├── GlpiProtocolError GLPI answered 2xx with an unusable body
1490
+ └── GlpiContentError a rich-text body could not be converted
1491
+ between HTML and Markdown
1486
1492
 
1487
1493
  :class:`~glpi_python_client.GlpiStatusError` carries the diagnostics you
1488
1494
  usually want:
@@ -1505,6 +1511,73 @@ usually want:
1505
1511
  :class:`~glpi_python_client.GlpiProtocolError` also inherit
1506
1512
  :class:`ValueError`. Code written against earlier releases, which
1507
1513
  raised bare ``ValueError``, keeps working unchanged.
1514
+ :class:`~glpi_python_client.GlpiContentError` and
1515
+ :class:`~glpi_python_client.GlpiTransportError` do not inherit it:
1516
+ there was never a bare ``ValueError`` at either kind of site, and
1517
+ neither is a value the caller got wrong.
1518
+
1519
+ .. _content-conversion:
1520
+
1521
+ Rich-text content: Markdown in, Markdown out
1522
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1523
+
1524
+ Ticket, followup, task, solution and knowledge-base bodies travel to GLPI
1525
+ as HTML. You work in Markdown in both directions and the package handles
1526
+ the translation, but the two directions are not symmetric and the
1527
+ difference shows up in the field names.
1528
+
1529
+ Writing is the simple half: give ``content`` Markdown and it is rendered
1530
+ to HTML when the request is built.
1531
+
1532
+ Reading gives you two views of the same body:
1533
+
1534
+ .. code-block:: python
1535
+
1536
+ ticket = client.get_ticket(42)
1537
+
1538
+ ticket.content_html # '<p>The printer is <strong>offline</strong>.</p>'
1539
+ ticket.content # 'The printer is **offline**.'
1540
+
1541
+ ``.content`` is what you want and what earlier releases gave you, so
1542
+ read-side code needs no change. What changed is *when* the conversion
1543
+ runs: on the first read of ``.content``, cached afterwards, rather than
1544
+ while the model is being built. Two things follow.
1545
+
1546
+ Listing records is cheap. ``client.search_tickets()`` used to convert
1547
+ every body on the page whether or not you looked at one; now a search
1548
+ that only reads ``id`` and ``date_mod`` converts nothing at all.
1549
+
1550
+ A body that cannot be converted no longer takes its page down with it.
1551
+ The whole page is built in one pass, so a single unconvertible record used
1552
+ to make its page-mates unreadable too. The failure is now scoped to the
1553
+ record whose body you actually read.
1554
+
1555
+ .. note::
1556
+
1557
+ Deeply nested HTML is the case worth knowing about. The HTML-to-Markdown
1558
+ converter walks the document recursively and exhausts the interpreter's
1559
+ stack at around 494 levels of nesting. ``.content`` does not try to
1560
+ predict that -- it attempts the conversion and, when the walk does not
1561
+ fit, strips the tags instead. **It degrades, it never truncates, and it
1562
+ does not raise**: every character the normal rendering would have
1563
+ produced still appears, so a body never says less because of how deeply
1564
+ it happened to nest. What you lose is structure,
1565
+ not words — link targets and image alt text, code-block fencing and
1566
+ ``<pre>`` indentation, and ``&nbsp;``-padded alignment. Anything else
1567
+ that goes wrong raises
1568
+ :class:`~glpi_python_client.GlpiContentError`.
1569
+
1570
+ Because the result is cached on first read, treat a read model as
1571
+ immutable afterwards. Assigning to ``content_html`` -- or
1572
+ ``model_copy(update={"content_html": ...})`` -- leaves the cached
1573
+ Markdown in place, and nothing in ``repr``, ``==`` or ``model_dump``
1574
+ will tell you. Rebuild through ``model_validate`` instead.
1575
+
1576
+ Two smaller consequences, if you are upgrading from 0.4.x: ``content`` is
1577
+ no longer in ``GetTicket.model_fields``, and ``GetTicket(...).model_dump()``
1578
+ emits ``content_html`` holding HTML where it used to emit ``content``
1579
+ holding Markdown. Pass ``by_alias=True`` for a dump keyed the way GLPI
1580
+ keys it.
1508
1581
 
1509
1582
  Retry behaviour
1510
1583
  ~~~~~~~~~~~~~~~
@@ -1533,4 +1606,4 @@ most 2 POST requests.
1533
1606
 
1534
1607
  Search methods are deliberately tolerant: ``search_tickets`` and its
1535
1608
  siblings return an empty list rather than raising when GLPI rejects the
1536
- query. Methods that fetch or mutate one specific record always raise.
1609
+ query. Methods that fetch or mutate one specific record always raise.