howler-api 4.1.0.dev1370__tar.gz → 4.1.0.dev1387__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 (227) hide show
  1. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/PKG-INFO +1 -1
  2. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/add_to_case.py +10 -13
  3. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v2/ingest.py +9 -7
  4. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/app.py +21 -4
  5. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/config.py +1 -1
  6. howler_api-4.1.0.dev1387/howler/datastore/bulk.py +272 -0
  7. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/collection.py +11 -18
  8. howler_api-4.1.0.dev1387/howler/datastore/utils.py +59 -0
  9. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/__init__.py +1 -1
  10. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/queues/named.py +5 -4
  11. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/case_service.py +71 -55
  12. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/correlation_service.py +165 -43
  13. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/event_service.py +28 -0
  14. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/hit_service.py +4 -1
  15. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/pyproject.toml +1 -1
  16. howler_api-4.1.0.dev1370/howler/datastore/bulk.py +0 -148
  17. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/README.md +0 -0
  18. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/__init__.py +0 -0
  19. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/__init__.py +0 -0
  20. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/add_label.py +0 -0
  21. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/add_to_bundle.py +0 -0
  22. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/change_field.py +0 -0
  23. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/demote.py +0 -0
  24. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/example_plugin.py +0 -0
  25. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/prioritization.py +0 -0
  26. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/promote.py +0 -0
  27. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/remove_from_bundle.py +0 -0
  28. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/remove_label.py +0 -0
  29. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/actions/transition.py +0 -0
  30. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/__init__.py +0 -0
  31. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/base.py +0 -0
  32. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/socket.py +0 -0
  33. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/__init__.py +0 -0
  34. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/action.py +0 -0
  35. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/analytic.py +0 -0
  36. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/auth.py +0 -0
  37. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/clue.py +0 -0
  38. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/configs.py +0 -0
  39. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/dossier.py +0 -0
  40. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/help.py +0 -0
  41. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/hit.py +0 -0
  42. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/notebook.py +0 -0
  43. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/overview.py +0 -0
  44. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/search.py +0 -0
  45. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/template.py +0 -0
  46. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/tool.py +0 -0
  47. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/user.py +0 -0
  48. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/utils/__init__.py +0 -0
  49. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/utils/etag.py +0 -0
  50. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/utils/params.py +0 -0
  51. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v1/view.py +0 -0
  52. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v2/__init__.py +0 -0
  53. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v2/case.py +0 -0
  54. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v2/fuzzy.py +0 -0
  55. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/api/v2/search.py +0 -0
  56. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/README.md +0 -0
  57. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/__init__.py +0 -0
  58. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/classification.py +0 -0
  59. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/classification.yml +0 -0
  60. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/exceptions.py +0 -0
  61. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/loader.py +0 -0
  62. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/logging/__init__.py +0 -0
  63. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/logging/audit.py +0 -0
  64. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/logging/format.py +0 -0
  65. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/net.py +0 -0
  66. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/net_static.py +0 -0
  67. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/random_user.py +0 -0
  68. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/common/swagger.py +0 -0
  69. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/cronjobs/__init__.py +0 -0
  70. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/cronjobs/action_queue_worker.py +0 -0
  71. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/cronjobs/correlation.py +0 -0
  72. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/cronjobs/retention.py +0 -0
  73. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/cronjobs/view_cleanup.py +0 -0
  74. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/README.md +0 -0
  75. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/__init__.py +0 -0
  76. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/constants.py +0 -0
  77. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/exceptions.py +0 -0
  78. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/howler_store.py +0 -0
  79. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/migrations/fix_process.py +0 -0
  80. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/operations.py +0 -0
  81. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/schemas.py +0 -0
  82. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/store.py +0 -0
  83. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/support/__init__.py +0 -0
  84. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/support/build.py +0 -0
  85. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/support/schemas.py +0 -0
  86. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/datastore/types.py +0 -0
  87. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/error.py +0 -0
  88. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/external/README.md +0 -0
  89. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/external/__init__.py +0 -0
  90. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/external/generate_mitre.py +0 -0
  91. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/external/generate_sigma_rules.py +0 -0
  92. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/external/generate_tlds.py +0 -0
  93. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/external/reindex_data.py +0 -0
  94. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/external/wipe_databases.py +0 -0
  95. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/gunicorn_config.py +0 -0
  96. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/healthz.py +0 -0
  97. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/helper/__init__.py +0 -0
  98. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/helper/azure.py +0 -0
  99. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/helper/discover.py +0 -0
  100. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/helper/hit.py +0 -0
  101. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/helper/oauth.py +0 -0
  102. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/helper/search.py +0 -0
  103. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/helper/workflow.py +0 -0
  104. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/helper/ws.py +0 -0
  105. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/README.md +0 -0
  106. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/__init__.py +0 -0
  107. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/base.py +0 -0
  108. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/charter.txt +0 -0
  109. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/constants.py +0 -0
  110. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/helper.py +0 -0
  111. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/howler_enum.py +0 -0
  112. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/mixins.py +0 -0
  113. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/__init__.py +0 -0
  114. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/action.py +0 -0
  115. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/analytic.py +0 -0
  116. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/assemblyline.py +0 -0
  117. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/aws.py +0 -0
  118. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/azure.py +0 -0
  119. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/case.py +0 -0
  120. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/cbs.py +0 -0
  121. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/clue.py +0 -0
  122. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/config.py +0 -0
  123. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/dossier.py +0 -0
  124. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/__init__.py +0 -0
  125. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/agent.py +0 -0
  126. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/autonomous_system.py +0 -0
  127. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/client.py +0 -0
  128. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/cloud.py +0 -0
  129. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/code_signature.py +0 -0
  130. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/container.py +0 -0
  131. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/dns.py +0 -0
  132. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/egress.py +0 -0
  133. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/elf.py +0 -0
  134. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/email.py +0 -0
  135. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/error.py +0 -0
  136. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/event.py +0 -0
  137. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/faas.py +0 -0
  138. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/file.py +0 -0
  139. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/geo.py +0 -0
  140. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/group.py +0 -0
  141. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/hash.py +0 -0
  142. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/host.py +0 -0
  143. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/http.py +0 -0
  144. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/ingress.py +0 -0
  145. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/interface.py +0 -0
  146. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/network.py +0 -0
  147. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/observer.py +0 -0
  148. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/organization.py +0 -0
  149. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/os.py +0 -0
  150. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/pe.py +0 -0
  151. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/process.py +0 -0
  152. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/registry.py +0 -0
  153. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/related.py +0 -0
  154. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/rule.py +0 -0
  155. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/server.py +0 -0
  156. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/threat.py +0 -0
  157. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/tls.py +0 -0
  158. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/url.py +0 -0
  159. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/user.py +0 -0
  160. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/user_agent.py +0 -0
  161. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/ecs/vulnerability.py +0 -0
  162. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/event.py +0 -0
  163. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/gcp.py +0 -0
  164. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/hit.py +0 -0
  165. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/howler_data.py +0 -0
  166. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/lead.py +0 -0
  167. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/localized_label.py +0 -0
  168. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/overview.py +0 -0
  169. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/pivot.py +0 -0
  170. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/record.py +0 -0
  171. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/template.py +0 -0
  172. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/user.py +0 -0
  173. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/models/view.py +0 -0
  174. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/random_data.py +0 -0
  175. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/odm/randomizer.py +0 -0
  176. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/patched.py +0 -0
  177. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/plugins/__init__.py +0 -0
  178. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/plugins/config.py +0 -0
  179. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/__init__.py +0 -0
  180. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/README.md +0 -0
  181. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/counters.py +0 -0
  182. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/events.py +0 -0
  183. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/hash.py +0 -0
  184. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/lock.py +0 -0
  185. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/queues/__init__.py +0 -0
  186. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/queues/comms.py +0 -0
  187. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/queues/multi.py +0 -0
  188. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/queues/priority.py +0 -0
  189. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/set.py +0 -0
  190. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/remote/datatypes/user_quota_tracker.py +0 -0
  191. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/security/__init__.py +0 -0
  192. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/security/socket.py +0 -0
  193. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/security/utils.py +0 -0
  194. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/__init__.py +0 -0
  195. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/action_service.py +0 -0
  196. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/analytic_service.py +0 -0
  197. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/auth_service.py +0 -0
  198. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/bundle_compat_service.py +0 -0
  199. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/comms_service.py +0 -0
  200. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/config_service.py +0 -0
  201. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/docs_service.py +0 -0
  202. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/dossier_service.py +0 -0
  203. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/fuzzy_service.py +0 -0
  204. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/jwt_service.py +0 -0
  205. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/lucene_service.py +0 -0
  206. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/notebook_service.py +0 -0
  207. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/overview_service.py +0 -0
  208. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/search_service.py +0 -0
  209. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/template_service.py +0 -0
  210. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/user_service.py +0 -0
  211. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/services/viewer_service.py +0 -0
  212. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/telemetry.py +0 -0
  213. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/__init__.py +0 -0
  214. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/annotations.py +0 -0
  215. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/chunk.py +0 -0
  216. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/compat.py +0 -0
  217. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/constants.py +0 -0
  218. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/dict_utils.py +0 -0
  219. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/indexes.py +0 -0
  220. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/isotime.py +0 -0
  221. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/list_utils.py +0 -0
  222. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/lucene.py +0 -0
  223. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/net_utils.py +0 -0
  224. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/path.py +0 -0
  225. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/socket_utils.py +0 -0
  226. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/str_utils.py +0 -0
  227. {howler_api-4.1.0.dev1370 → howler_api-4.1.0.dev1387}/howler/utils/uid.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: howler-api
3
- Version: 4.1.0.dev1370
3
+ Version: 4.1.0.dev1387
4
4
  Summary: Howler - API server
5
5
  License: MIT
6
6
  Keywords: howler,alerting,gc,canada,cse-cst,cse,cst,cyber,cccs
@@ -13,8 +13,7 @@ OPERATION_ID = "add_to_case"
13
13
  def execute( # noqa: C901
14
14
  query: str,
15
15
  case_id: Optional[str] = None,
16
- path: str = "related",
17
- title_template: str = "{{howler.analytic}} ({{howler.id}})",
16
+ destination: str = "related/{{howler.analytic}} ({{howler.id}})",
18
17
  **kwargs,
19
18
  ):
20
19
  """Add matching alerts to a given case.
@@ -22,10 +21,9 @@ def execute( # noqa: C901
22
21
  Args:
23
22
  query (str): The query on which to apply this automation.
24
23
  case_id (str): The ID of the case to add the alerts to.
25
- path (str): The path within the case at which to place the alerts. Defaults to "related".
26
- title_template (str): A Mustache-compatible template string used to generate each item's
27
- name. The hit's fields are available as template variables.
28
- Defaults to "{{howler.analytic}} ({{howler.id}})".
24
+ destination (str): A Mustache-compatible template string for the case path at which each
25
+ alert will be placed, in the form "path/to/parent/name". The hit's fields are
26
+ available as template variables. Defaults to "related/{{howler.analytic}} ({{howler.id}})".
29
27
  """
30
28
  if not case_id:
31
29
  return [
@@ -67,15 +65,15 @@ def execute( # noqa: C901
67
65
  added = []
68
66
 
69
67
  for hit in hits:
70
- title = chevron.render(title_template, hit.as_primitives())
71
- item_path = f"{path.rstrip('/')}/{title}" if path else title
68
+ rendered_destination = chevron.render(destination, hit.as_primitives())
72
69
  try:
73
- path, name = item_path.rsplit("/", maxsplit=1)
70
+ item_path, name = rendered_destination.rsplit("/", maxsplit=1)
74
71
  except ValueError:
75
- name = item_path
72
+ item_path = None
73
+ name = rendered_destination
76
74
 
77
75
  try:
78
- parent = case_service.get_parent_from_path(case, path, create_if_missing=True)
76
+ parent = case_service.get_parent_from_path(case, item_path, create_if_missing=True)
79
77
 
80
78
  case_service.append_case_item(
81
79
  case,
@@ -131,8 +129,7 @@ def specification():
131
129
  {
132
130
  "args": {
133
131
  "case_id": [],
134
- "path": [],
135
- "title_template": [],
132
+ "destination": [],
136
133
  },
137
134
  "options": {},
138
135
  }
@@ -39,7 +39,7 @@ hit_helper = OdmHelper(Hit)
39
39
  @ingest_api.route("/<index>", methods=["POST"])
40
40
  @api_login(required_priv=["W"])
41
41
  @parse_parameters(refresh=parse_refresh)
42
- def create(index: str, user: User, **kwargs):
42
+ def create(index: str, user: User, *, refresh: Literal["true", "false", "wait_for"] | None = None, **kwargs):
43
43
  """Create new records in a given index.
44
44
 
45
45
  Variables:
@@ -75,11 +75,9 @@ def create(index: str, user: User, **kwargs):
75
75
  if not isinstance(records, list):
76
76
  return bad_request(err="JSON Payload must be a list of records.")
77
77
  ignore_extra_values = request.args.get("ignore_extra_values", False, type=lambda v: v.lower() == "true")
78
- refresh = cast(Literal["true", "false", "wait_for"] | None, kwargs.get("refresh"))
79
78
 
80
- ids: list[str] = []
79
+ odms: list = []
81
80
  warnings = []
82
- refresh_kwargs: dict[str, Literal["true", "false", "wait_for"] | None] = {"refresh": refresh}
83
81
  for i, record in enumerate(records):
84
82
  try:
85
83
  odm: Hit | Event
@@ -87,18 +85,22 @@ def create(index: str, user: User, **kwargs):
87
85
  odm, _warnings = event_service.convert_event(
88
86
  record, unique=True, ignore_extra_values=ignore_extra_values
89
87
  )
90
- event_service.create_event(odm.howler.id, odm, user.uname, skip_exists=True, **refresh_kwargs)
91
88
  else:
92
89
  odm, _warnings = hit_service.convert_hit(record, unique=True, ignore_extra_values=ignore_extra_values)
93
- hit_service.create_hit(odm.howler.id, odm, user.uname, skip_exists=True, refresh=refresh)
94
90
 
95
- ids.append(odm.howler.id)
91
+ odms.append(odm)
96
92
  warnings.extend(_warnings)
97
93
  except HowlerException as e:
98
94
  logger.exception("Ingestion failed.")
99
95
  return bad_request(err=f"Ingestion failure on record at index {i}: {e}")
100
96
 
97
+ if index == "event":
98
+ event_service.create_events(odms, user.uname, overwrite=False, refresh=refresh)
99
+ else:
100
+ hit_service.create_hits(odms, user.uname, overwrite=False, refresh=refresh)
101
+
101
102
  # Enqueue newly created hit IDs for the correlation worker.
103
+ ids = [odm.howler.id for odm in odms]
102
104
  if ids:
103
105
  correlation_service.enqueue_for_correlation(ids)
104
106
 
@@ -71,6 +71,22 @@ from howler.telemetry import setup_telemetry
71
71
  logger = get_logger(__file__)
72
72
 
73
73
 
74
+ def _should_start_background_services() -> bool:
75
+ """Return whether this process should start long-lived background services.
76
+
77
+ Flask's development reloader imports this module in a parent process and
78
+ again in its serving child. The functional test wrapper sets
79
+ ``HWL_START_BACKGROUND_SERVICES`` only for its no-reload Flask server,
80
+ preventing pytest imports from starting competing workers.
81
+ """
82
+ return (
83
+ not DEBUG
84
+ or os.environ.get("WERKZEUG_RUN_MAIN") == "true"
85
+ or os.environ.get("HWL_START_BACKGROUND_SERVICES", "").lower() == "true"
86
+ or Path(sys.argv[0]).name.lower().startswith("gunicorn")
87
+ )
88
+
89
+
74
90
  app = Flask(
75
91
  "howler-api",
76
92
  static_url_path="/api/static",
@@ -177,14 +193,15 @@ if HWL_USE_WEBSOCKET_API or DEBUG:
177
193
  logger.debug("Enabled Websocket API")
178
194
  app.register_blueprint(socket_api)
179
195
 
180
- # Start the Redis pubsub watcher so this pod receives events from all pods
181
- import howler.services.comms_service as comms_service
196
+ if _should_start_background_services():
197
+ # Start the Redis pubsub watcher so this pod receives events from all pods
198
+ import howler.services.comms_service as comms_service
182
199
 
183
- comms_service.start_watcher()
200
+ comms_service.start_watcher()
184
201
  else:
185
202
  logger.info("Disabled Websocket API")
186
203
 
187
- if HWL_USE_JOB_SYSTEM or DEBUG:
204
+ if HWL_USE_JOB_SYSTEM and _should_start_background_services():
188
205
  setup_jobs()
189
206
 
190
207
 
@@ -21,7 +21,7 @@ MAX_CLASSIFICATION: str = CLASSIFICATION.UNRESTRICTED
21
21
  HWL_UNSECURED_UI = os.environ.get("HWL_UNSECURED_UI", "false").lower() == "true"
22
22
  HWL_USE_REST_API = os.environ.get("HWL_USE_REST_API", "true").lower() == "true"
23
23
  HWL_USE_WEBSOCKET_API = os.environ.get("HWL_USE_WEBSOCKET_API", "false").lower() == "true"
24
- HWL_USE_JOB_SYSTEM = os.environ.get("HWL_USE_JOB_SYSTEM", "false").lower() == "true"
24
+ HWL_USE_JOB_SYSTEM = os.environ.get("HWL_USE_JOB_SYSTEM", "false").lower() == "true" or DEBUG
25
25
  HWL_ENABLE_COVERAGE = os.environ.get("HWL_ENABLE_COVERAGE", "false").lower() == "true"
26
26
  HWL_PLUGIN_DIRECTORY = os.environ.get("HWL_PLUGIN_DIRECTORY", "/etc/howler/plugins")
27
27
  CORRELATION_QUEUE_NAME = os.environ.get("HWL_CORRELATION_QUEUE_NAME", "howler.ingestion_queue")
@@ -0,0 +1,272 @@
1
+ """Build newline-delimited JSON payloads for Elasticsearch bulk requests.
2
+
3
+ ``ElasticBulkPlan`` accumulates Elasticsearch bulk operations and renders them
4
+ as NDJSON suitable for the ``_bulk`` endpoint. Documents can be serialized
5
+ through an ODM model, and update operations can be limited to selected fields
6
+ to avoid overwriting unrelated stored data.
7
+ """
8
+
9
+ import json
10
+ import warnings
11
+ from copy import deepcopy
12
+ from typing import List, Optional
13
+
14
+ from howler import odm
15
+ from howler.config import config
16
+ from howler.datastore.utils import expand_field_patterns, prune_to_paths
17
+
18
+ _OPERATION_GROUP = tuple[str] | tuple[str, str]
19
+
20
+ ELASTIC_MAX_REQUEST_SIZE = config.datastore.max_request_size
21
+ DEFAULT_BATCH_SIZE = config.datastore.request_batch_size
22
+
23
+
24
+ class ElasticBulkPlan(object):
25
+ """Accumulate and render Elasticsearch bulk API operations.
26
+
27
+ Unless an operation supplies an explicit ``index``, insert, index, and
28
+ upsert operations target the first configured index. Delete and update
29
+ operations target every configured index, which supports collections that
30
+ span multiple physical indices.
31
+
32
+ Args:
33
+ indexes: Elasticsearch indices available to the plan. The first index
34
+ is the default target for single-index operations.
35
+ model: Optional ODM model used to serialize document values before
36
+ they are added to the request.
37
+ """
38
+
39
+ def __init__(self, indexes: List[str], model: Optional[type[odm.Model]] = None):
40
+ self.indexes = indexes
41
+ self.model = model
42
+ self.operations: list[_OPERATION_GROUP] = []
43
+
44
+ @property
45
+ def empty(self):
46
+ """Whether the plan has no queued operations.
47
+
48
+ Returns:
49
+ ``True`` when no bulk operation has been added; otherwise,
50
+ ``False``.
51
+ """
52
+ return len(self.operations) == 0
53
+
54
+ def add_delete_operation(self, doc_id, index=None):
55
+ """Queue a document delete operation.
56
+
57
+ Args:
58
+ doc_id: Identifier of the document to delete.
59
+ index: Explicit index to target. When omitted, queues one delete
60
+ operation for each configured index.
61
+ """
62
+ if index:
63
+ self.operations.append((json.dumps({"delete": {"_index": index, "_id": doc_id}}),))
64
+ else:
65
+ for cur_index in self.indexes:
66
+ self.operations.append((json.dumps({"delete": {"_index": cur_index, "_id": doc_id}}),))
67
+
68
+ def add_insert_operation(self, doc_id: str, doc, index=None):
69
+ """Queue a create-only document insert operation.
70
+
71
+ The Elasticsearch ``create`` action fails if a document with ``doc_id``
72
+ already exists. The document's ``id`` field is set to ``doc_id`` in the
73
+ serialized request body.
74
+
75
+ Args:
76
+ doc_id: Identifier to assign to the new document.
77
+ doc: Document data or an instance of the configured ODM model.
78
+ index: Explicit index to target. When omitted, uses the first
79
+ configured index.
80
+ """
81
+ if self.model and isinstance(doc, self.model):
82
+ saved_doc = doc.as_primitives(hidden_fields=True)
83
+ elif self.model:
84
+ saved_doc = self.model(doc).as_primitives(hidden_fields=True)
85
+ else:
86
+ if not isinstance(doc, dict):
87
+ saved_doc = {"__non_doc_raw__": doc}
88
+ else:
89
+ saved_doc = deepcopy(doc)
90
+ saved_doc["id"] = doc_id
91
+
92
+ self.operations.append(
93
+ (
94
+ json.dumps({"create": {"_index": index or self.indexes[0], "_id": doc_id}}),
95
+ json.dumps(saved_doc),
96
+ )
97
+ )
98
+
99
+ def add_index_operation(self, doc_id, doc, index=None):
100
+ """Queue a document index operation.
101
+
102
+ The Elasticsearch ``index`` action creates the document or replaces an
103
+ existing document with the same identifier. The document's id field is
104
+ set to doc_id in the serialized request body.
105
+
106
+ Args:
107
+ doc_id: Identifier of the document to create or replace.
108
+ doc: Document data or an instance of the configured ODM model.
109
+ index: Explicit index to target. When omitted, uses the first
110
+ configured index.
111
+ """
112
+ if self.model and isinstance(doc, self.model):
113
+ saved_doc = doc.as_primitives(hidden_fields=True)
114
+ elif self.model:
115
+ saved_doc = self.model(doc).as_primitives(hidden_fields=True)
116
+ else:
117
+ if not isinstance(doc, dict):
118
+ saved_doc = {"__non_doc_raw__": doc}
119
+ else:
120
+ saved_doc = deepcopy(doc)
121
+ saved_doc["id"] = doc_id
122
+
123
+ self.operations.append(
124
+ (
125
+ json.dumps({"index": {"_index": index or self.indexes[0], "_id": doc_id}}),
126
+ json.dumps(saved_doc),
127
+ )
128
+ )
129
+
130
+ def add_upsert_operation(self, doc_id, doc, index=None):
131
+ """Queue an update operation that creates the document when absent.
132
+
133
+ The operation uses Elasticsearch's ``doc_as_upsert`` option. Its
134
+ serialized document includes ``id`` set to ``doc_id``.
135
+
136
+ Args:
137
+ doc_id: Identifier of the document to update or create.
138
+ doc: Document data or an instance of the configured ODM model.
139
+ index: Explicit index to target. When omitted, uses the first
140
+ configured index.
141
+ """
142
+ if self.model and isinstance(doc, self.model):
143
+ saved_doc = doc.as_primitives(hidden_fields=True)
144
+ elif self.model:
145
+ saved_doc = self.model(doc).as_primitives(hidden_fields=True)
146
+ else:
147
+ if not isinstance(doc, dict):
148
+ saved_doc = {"__non_doc_raw__": doc}
149
+ else:
150
+ saved_doc = deepcopy(doc)
151
+ saved_doc["id"] = doc_id
152
+
153
+ self.operations.append(
154
+ (
155
+ json.dumps({"update": {"_index": index or self.indexes[0], "_id": doc_id}}),
156
+ json.dumps({"doc": saved_doc, "doc_as_upsert": True}),
157
+ )
158
+ )
159
+
160
+ def add_update_operation(self, doc_id, doc, index=None, fields: Optional[List[str]] = None):
161
+ """Queue a partial-merge Elasticsearch ``update`` operation.
162
+
163
+ Args:
164
+ doc_id: Identifier of the document to update.
165
+ doc: Document data or an instance of the configured ODM model.
166
+ index: Explicit index to target. When omitted, queues one update
167
+ operation for each configured index.
168
+
169
+ fields: When provided, restricts the update body to just these dotted field
170
+ paths (any valid key from :meth:`ESCollection.fields`/`Model.flat_fields`),
171
+ dropping everything else so unrelated concurrent edits to the stored
172
+ document aren't clobbered. Entries may contain ``*`` wildcards, expanded
173
+ against the model's known field paths (e.g. ``"items.*"`` selects every
174
+ subfield of the ``items`` list without touching ``title``, ``summary``,
175
+ etc.). A path with no wildcard is kept wholesale, including any nested
176
+ subfields, e.g. ``"items"`` alone also keeps every subfield of ``items``.
177
+ """
178
+ if self.model and isinstance(doc, self.model):
179
+ saved_doc = doc.as_primitives(hidden_fields=True)
180
+ elif self.model:
181
+ saved_doc = self.model(doc, mask=list(doc.keys())).as_primitives(hidden_fields=True)
182
+ else:
183
+ if not isinstance(doc, dict):
184
+ saved_doc = {"__non_doc_raw__": doc}
185
+ else:
186
+ saved_doc = deepcopy(doc)
187
+
188
+ if fields is not None:
189
+ saved_doc = prune_to_paths(saved_doc, allowed=expand_field_patterns(self.model, fields))
190
+
191
+ if index:
192
+ self.operations.append(
193
+ (
194
+ json.dumps({"update": {"_index": index, "_id": doc_id}}),
195
+ json.dumps({"doc": saved_doc}),
196
+ )
197
+ )
198
+ else:
199
+ for cur_index in self.indexes:
200
+ self.operations.append(
201
+ (
202
+ json.dumps({"update": {"_index": cur_index, "_id": doc_id}}),
203
+ json.dumps({"doc": saved_doc}),
204
+ )
205
+ )
206
+
207
+ def get_plan_data(self):
208
+ """Render all queued operations as an Elasticsearch bulk request.
209
+
210
+ Returns:
211
+ Newline-delimited JSON with a final trailing newline, ready to send
212
+ to Elasticsearch's ``_bulk`` endpoint.
213
+ """
214
+ return self._get_plan_for_operations()
215
+
216
+ def get_plan_batches(self, batch_size: int | None = DEFAULT_BATCH_SIZE):
217
+ """Yield queued operations as separate Elasticsearch bulk payloads.
218
+
219
+ Args:
220
+ batch_size: Maximum number of queued operations in each payload.
221
+ Pass ``None`` to produce one payload containing all operations.
222
+
223
+ Yields:
224
+ Newline-delimited JSON payloads, each with a trailing newline.
225
+ """
226
+ if batch_size is None:
227
+ batch_size = len(self.operations)
228
+ for ptr in range(0, len(self.operations), batch_size):
229
+ yield self._get_plan_for_operations(self.operations[ptr : ptr + batch_size])
230
+
231
+ def _flatten_operations(self, batch: List[_OPERATION_GROUP] | None = None) -> List[str]:
232
+ """Flatten queued action/body groups into their NDJSON lines.
233
+
234
+ Args:
235
+ batch: Operation groups to flatten. Uses every queued operation
236
+ when omitted or empty.
237
+
238
+ Returns:
239
+ JSON action and document lines in bulk API order.
240
+ """
241
+ if not batch:
242
+ batch = self.operations
243
+
244
+ flattened: list[str] = []
245
+ for op in batch:
246
+ flattened.extend(op)
247
+
248
+ return flattened
249
+
250
+ def _get_plan_for_operations(self, batch: List[_OPERATION_GROUP] | None = None) -> str:
251
+ """Render operation groups as a newline-delimited JSON payload.
252
+
253
+ Args:
254
+ batch: Operation groups to render. Uses every queued operation when
255
+ omitted or empty.
256
+
257
+ Returns:
258
+ The rendered payload with a final trailing newline.
259
+
260
+ Warns:
261
+ UserWarning: If the encoded payload exceeds the configured maximum
262
+ Elasticsearch request size.
263
+ """
264
+ plan = "\n".join(self._flatten_operations(batch)) + "\n"
265
+
266
+ if ELASTIC_MAX_REQUEST_SIZE and len(plan.encode("utf-8")) > ELASTIC_MAX_REQUEST_SIZE:
267
+ warnings.warn(
268
+ f"Bulk plan exceeds maximum request size of {ELASTIC_MAX_REQUEST_SIZE} bytes. "
269
+ f"Current size: {len(plan.encode('utf-8'))} bytes."
270
+ )
271
+
272
+ return plan
@@ -40,6 +40,7 @@ from howler.datastore.support.schemas import (
40
40
  default_mapping,
41
41
  )
42
42
  from howler.datastore.types import AggSearchResult, SearchResult
43
+ from howler.datastore.utils import expand_field_patterns
43
44
  from howler.odm.base import (
44
45
  BANNED_FIELDS,
45
46
  IP,
@@ -565,7 +566,14 @@ class ESCollection(Generic[ModelType]):
565
566
  for operation_batch in operations.get_plan_batches():
566
567
  response = self.with_retries(self.datastore.client.bulk, operations=operation_batch, refresh=refresh)
567
568
  responses.append(response)
568
- return not any(response["errors"] for response in responses)
569
+
570
+ has_errors = False
571
+ for response in responses:
572
+ if response["errors"]:
573
+ has_errors = True
574
+ logger.error("Errors on bulk plan: %s", response["errors"])
575
+
576
+ return not has_errors
569
577
 
570
578
  def get_bulk_plan(self):
571
579
  """
@@ -1628,23 +1636,8 @@ class ESCollection(Generic[ModelType]):
1628
1636
  if not self.model_class or "*" not in fl:
1629
1637
  return fl
1630
1638
 
1631
- all_fields = list(self.model_class.flat_fields().keys())
1632
- expanded: list[str] = []
1633
- for pattern in fl.split(","):
1634
- pattern = pattern.strip()
1635
- if not pattern:
1636
- # Skip empty entries (e.g. from trailing commas).
1637
- continue
1638
- if "*" not in pattern or pattern == "*":
1639
- # Exact names and the bare '*' (meaning "all fields") are kept as-is.
1640
- expanded.append(pattern)
1641
- else:
1642
- # Convert the glob-style wildcard to a full regex pattern.
1643
- # Replace '*' with '.*' and escape all other regex special characters.
1644
- regex = re.compile("^" + re.escape(pattern).replace(r"\*", ".*") + "$")
1645
- matched = [f for f in all_fields if regex.match(f)]
1646
- expanded.extend(matched if matched else [pattern])
1647
- return ",".join(expanded)
1639
+ patterns = [p.strip() for p in fl.split(",") if p.strip()]
1640
+ return ",".join(sorted(expand_field_patterns(self.model_class, patterns, preserve_all=True)))
1648
1641
 
1649
1642
  def _format_output(self, result, fields=None, as_obj=True):
1650
1643
  # Getting search document data
@@ -0,0 +1,59 @@
1
+ """Shared helpers for building field-scoped Elasticsearch operations from ODM models."""
2
+
3
+ import re
4
+ from typing import Iterable, Optional
5
+
6
+ from howler import odm
7
+
8
+
9
+ def expand_field_patterns(
10
+ model: Optional[type[odm.Model]], patterns: Iterable[str], preserve_all: bool = False
11
+ ) -> set[str]:
12
+ """Expand `*`-wildcard field patterns against a model's known dotted field paths.
13
+
14
+ Entries without a `*` are kept as-is (even if the model doesn't recognize them, so
15
+ synthetic keys like `__non_doc_raw__` still work). Without a model, patterns can't be
16
+ expanded and are kept literally.
17
+ """
18
+ if model is None:
19
+ return set(patterns)
20
+
21
+ known_paths = list(model.flat_fields().keys())
22
+ expanded: set[str] = set()
23
+ for pattern in patterns:
24
+ if "*" not in pattern:
25
+ expanded.add(pattern)
26
+ continue
27
+
28
+ if pattern == "*" and preserve_all:
29
+ expanded.add(pattern)
30
+ continue
31
+
32
+ regex = re.compile("^" + re.escape(pattern).replace(r"\*", ".*") + "$")
33
+ matches = [path for path in known_paths if regex.match(path)]
34
+ expanded.update(matches)
35
+ return expanded
36
+
37
+
38
+ def prune_to_paths(value, allowed: set[str], prefix: str = ""):
39
+ """Recursively keep only the parts of `value` selected by dotted paths in `allowed`.
40
+
41
+ A key is kept wholesale (subtree included as-is) if its own path is in `allowed`;
42
+ otherwise it's kept and recursed into if some allowed path starts with it, so
43
+ e.g. `allowed={"items.type"}` prunes each entry of a list field named `items` down
44
+ to just its `type` subfield.
45
+ """
46
+ if isinstance(value, dict):
47
+ result = {}
48
+ for key, sub_value in value.items():
49
+ cur_path = f"{prefix}.{key}" if prefix else key
50
+ if cur_path in allowed:
51
+ result[key] = sub_value
52
+ elif any(path.startswith(f"{cur_path}.") for path in allowed):
53
+ result[key] = prune_to_paths(sub_value, allowed, cur_path)
54
+ return result
55
+
56
+ if isinstance(value, list):
57
+ return [prune_to_paths(entry, allowed, prefix) for entry in value]
58
+
59
+ return value
@@ -139,7 +139,7 @@ def get_client(
139
139
  host: str | redis.Redis | redis.StrictRedis | redis.RedisCluster | None,
140
140
  port: int | None,
141
141
  private: bool = False,
142
- ):
142
+ ) -> redis.Redis | redis.StrictRedis | redis.RedisCluster:
143
143
  """
144
144
  Get Redis instance.
145
145
 
@@ -1,6 +1,8 @@
1
1
  import json
2
2
  import time
3
- from typing import Any, Generic, Optional, TypeVar
3
+ from typing import Generic, Optional, TypeVar
4
+
5
+ import redis
4
6
 
5
7
  from howler.common.exceptions import HowlerTypeError
6
8
  from howler.remote.datatypes import get_client, retry_call
@@ -10,7 +12,7 @@ T = TypeVar("T")
10
12
 
11
13
  class NamedQueue(Generic[T]):
12
14
  def __init__(self, name: str, host=None, port=None, private: bool = False, ttl: int = 0):
13
- self.c: Any = get_client(host, port, private)
15
+ self.c: redis.Redis | redis.StrictRedis | redis.RedisCluster = get_client(host, port, private)
14
16
  self.name: str = name
15
17
  self.ttl: int = ttl
16
18
  self.last_expire_time: float = 0
@@ -66,8 +68,7 @@ class NamedQueue(Generic[T]):
66
68
  return json.loads(response)
67
69
 
68
70
  def push(self, *messages: T):
69
- for message in messages:
70
- retry_call(self.c.rpush, self.name, json.dumps(message))
71
+ retry_call(self.c.rpush, self.name, *[json.dumps(message) for message in messages])
71
72
  self._conditional_expire()
72
73
 
73
74
  def unpop(self, *messages: T):