howler-api 2.13.0.dev329__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of howler-api might be problematic. Click here for more details.

Files changed (200) hide show
  1. howler/__init__.py +0 -0
  2. howler/actions/__init__.py +167 -0
  3. howler/actions/add_label.py +111 -0
  4. howler/actions/add_to_bundle.py +159 -0
  5. howler/actions/change_field.py +76 -0
  6. howler/actions/demote.py +160 -0
  7. howler/actions/example_plugin.py +104 -0
  8. howler/actions/prioritization.py +93 -0
  9. howler/actions/promote.py +147 -0
  10. howler/actions/remove_from_bundle.py +133 -0
  11. howler/actions/remove_label.py +111 -0
  12. howler/actions/transition.py +200 -0
  13. howler/api/__init__.py +249 -0
  14. howler/api/base.py +88 -0
  15. howler/api/socket.py +114 -0
  16. howler/api/v1/__init__.py +97 -0
  17. howler/api/v1/action.py +372 -0
  18. howler/api/v1/analytic.py +748 -0
  19. howler/api/v1/auth.py +382 -0
  20. howler/api/v1/borealis.py +101 -0
  21. howler/api/v1/configs.py +55 -0
  22. howler/api/v1/dossier.py +222 -0
  23. howler/api/v1/help.py +28 -0
  24. howler/api/v1/hit.py +1181 -0
  25. howler/api/v1/notebook.py +82 -0
  26. howler/api/v1/overview.py +191 -0
  27. howler/api/v1/search.py +715 -0
  28. howler/api/v1/template.py +206 -0
  29. howler/api/v1/tool.py +183 -0
  30. howler/api/v1/user.py +414 -0
  31. howler/api/v1/utils/__init__.py +0 -0
  32. howler/api/v1/utils/etag.py +84 -0
  33. howler/api/v1/view.py +288 -0
  34. howler/app.py +235 -0
  35. howler/common/README.md +144 -0
  36. howler/common/__init__.py +0 -0
  37. howler/common/classification.py +979 -0
  38. howler/common/classification.yml +107 -0
  39. howler/common/exceptions.py +167 -0
  40. howler/common/hexdump.py +48 -0
  41. howler/common/iprange.py +171 -0
  42. howler/common/loader.py +154 -0
  43. howler/common/logging/__init__.py +241 -0
  44. howler/common/logging/audit.py +138 -0
  45. howler/common/logging/format.py +38 -0
  46. howler/common/net.py +79 -0
  47. howler/common/net_static.py +1494 -0
  48. howler/common/random_user.py +316 -0
  49. howler/common/swagger.py +117 -0
  50. howler/config.py +64 -0
  51. howler/cronjobs/__init__.py +29 -0
  52. howler/cronjobs/retention.py +61 -0
  53. howler/cronjobs/rules.py +274 -0
  54. howler/cronjobs/view_cleanup.py +88 -0
  55. howler/datastore/README.md +112 -0
  56. howler/datastore/__init__.py +0 -0
  57. howler/datastore/bulk.py +72 -0
  58. howler/datastore/collection.py +2327 -0
  59. howler/datastore/constants.py +117 -0
  60. howler/datastore/exceptions.py +41 -0
  61. howler/datastore/howler_store.py +105 -0
  62. howler/datastore/migrations/fix_process.py +41 -0
  63. howler/datastore/operations.py +130 -0
  64. howler/datastore/schemas.py +90 -0
  65. howler/datastore/store.py +231 -0
  66. howler/datastore/support/__init__.py +0 -0
  67. howler/datastore/support/build.py +214 -0
  68. howler/datastore/support/schemas.py +90 -0
  69. howler/datastore/types.py +22 -0
  70. howler/error.py +91 -0
  71. howler/external/__init__.py +0 -0
  72. howler/external/generate_mitre.py +96 -0
  73. howler/external/generate_sigma_rules.py +31 -0
  74. howler/external/generate_tlds.py +47 -0
  75. howler/external/reindex_data.py +46 -0
  76. howler/external/wipe_databases.py +58 -0
  77. howler/gunicorn_config.py +25 -0
  78. howler/healthz.py +47 -0
  79. howler/helper/__init__.py +0 -0
  80. howler/helper/azure.py +50 -0
  81. howler/helper/discover.py +59 -0
  82. howler/helper/hit.py +236 -0
  83. howler/helper/oauth.py +247 -0
  84. howler/helper/search.py +92 -0
  85. howler/helper/workflow.py +110 -0
  86. howler/helper/ws.py +378 -0
  87. howler/odm/README.md +102 -0
  88. howler/odm/__init__.py +1 -0
  89. howler/odm/base.py +1504 -0
  90. howler/odm/charter.txt +146 -0
  91. howler/odm/helper.py +416 -0
  92. howler/odm/howler_enum.py +25 -0
  93. howler/odm/models/__init__.py +0 -0
  94. howler/odm/models/action.py +33 -0
  95. howler/odm/models/analytic.py +90 -0
  96. howler/odm/models/assemblyline.py +48 -0
  97. howler/odm/models/aws.py +23 -0
  98. howler/odm/models/azure.py +16 -0
  99. howler/odm/models/cbs.py +44 -0
  100. howler/odm/models/config.py +558 -0
  101. howler/odm/models/dossier.py +33 -0
  102. howler/odm/models/ecs/__init__.py +0 -0
  103. howler/odm/models/ecs/agent.py +17 -0
  104. howler/odm/models/ecs/autonomous_system.py +16 -0
  105. howler/odm/models/ecs/client.py +149 -0
  106. howler/odm/models/ecs/cloud.py +141 -0
  107. howler/odm/models/ecs/code_signature.py +27 -0
  108. howler/odm/models/ecs/container.py +32 -0
  109. howler/odm/models/ecs/dns.py +62 -0
  110. howler/odm/models/ecs/egress.py +10 -0
  111. howler/odm/models/ecs/elf.py +74 -0
  112. howler/odm/models/ecs/email.py +122 -0
  113. howler/odm/models/ecs/error.py +14 -0
  114. howler/odm/models/ecs/event.py +140 -0
  115. howler/odm/models/ecs/faas.py +24 -0
  116. howler/odm/models/ecs/file.py +84 -0
  117. howler/odm/models/ecs/geo.py +30 -0
  118. howler/odm/models/ecs/group.py +18 -0
  119. howler/odm/models/ecs/hash.py +16 -0
  120. howler/odm/models/ecs/host.py +17 -0
  121. howler/odm/models/ecs/http.py +37 -0
  122. howler/odm/models/ecs/ingress.py +12 -0
  123. howler/odm/models/ecs/interface.py +21 -0
  124. howler/odm/models/ecs/network.py +30 -0
  125. howler/odm/models/ecs/observer.py +45 -0
  126. howler/odm/models/ecs/organization.py +12 -0
  127. howler/odm/models/ecs/os.py +21 -0
  128. howler/odm/models/ecs/pe.py +17 -0
  129. howler/odm/models/ecs/process.py +216 -0
  130. howler/odm/models/ecs/registry.py +26 -0
  131. howler/odm/models/ecs/related.py +45 -0
  132. howler/odm/models/ecs/rule.py +51 -0
  133. howler/odm/models/ecs/server.py +24 -0
  134. howler/odm/models/ecs/threat.py +247 -0
  135. howler/odm/models/ecs/tls.py +58 -0
  136. howler/odm/models/ecs/url.py +51 -0
  137. howler/odm/models/ecs/user.py +57 -0
  138. howler/odm/models/ecs/user_agent.py +20 -0
  139. howler/odm/models/ecs/vulnerability.py +41 -0
  140. howler/odm/models/gcp.py +16 -0
  141. howler/odm/models/hit.py +356 -0
  142. howler/odm/models/howler_data.py +328 -0
  143. howler/odm/models/lead.py +33 -0
  144. howler/odm/models/localized_label.py +13 -0
  145. howler/odm/models/overview.py +16 -0
  146. howler/odm/models/pivot.py +40 -0
  147. howler/odm/models/template.py +24 -0
  148. howler/odm/models/user.py +83 -0
  149. howler/odm/models/view.py +34 -0
  150. howler/odm/random_data.py +888 -0
  151. howler/odm/randomizer.py +606 -0
  152. howler/patched.py +5 -0
  153. howler/plugins/__init__.py +25 -0
  154. howler/plugins/config.py +123 -0
  155. howler/remote/__init__.py +0 -0
  156. howler/remote/datatypes/README.md +355 -0
  157. howler/remote/datatypes/__init__.py +98 -0
  158. howler/remote/datatypes/counters.py +63 -0
  159. howler/remote/datatypes/events.py +66 -0
  160. howler/remote/datatypes/hash.py +206 -0
  161. howler/remote/datatypes/lock.py +42 -0
  162. howler/remote/datatypes/queues/__init__.py +0 -0
  163. howler/remote/datatypes/queues/comms.py +59 -0
  164. howler/remote/datatypes/queues/multi.py +32 -0
  165. howler/remote/datatypes/queues/named.py +93 -0
  166. howler/remote/datatypes/queues/priority.py +215 -0
  167. howler/remote/datatypes/set.py +118 -0
  168. howler/remote/datatypes/user_quota_tracker.py +54 -0
  169. howler/security/__init__.py +253 -0
  170. howler/security/socket.py +108 -0
  171. howler/security/utils.py +185 -0
  172. howler/services/__init__.py +0 -0
  173. howler/services/action_service.py +111 -0
  174. howler/services/analytic_service.py +128 -0
  175. howler/services/auth_service.py +323 -0
  176. howler/services/config_service.py +128 -0
  177. howler/services/dossier_service.py +252 -0
  178. howler/services/event_service.py +93 -0
  179. howler/services/hit_service.py +893 -0
  180. howler/services/jwt_service.py +158 -0
  181. howler/services/lucene_service.py +286 -0
  182. howler/services/notebook_service.py +119 -0
  183. howler/services/overview_service.py +44 -0
  184. howler/services/template_service.py +45 -0
  185. howler/services/user_service.py +330 -0
  186. howler/utils/__init__.py +0 -0
  187. howler/utils/annotations.py +28 -0
  188. howler/utils/chunk.py +38 -0
  189. howler/utils/dict_utils.py +200 -0
  190. howler/utils/isotime.py +17 -0
  191. howler/utils/list_utils.py +11 -0
  192. howler/utils/lucene.py +77 -0
  193. howler/utils/path.py +27 -0
  194. howler/utils/socket_utils.py +61 -0
  195. howler/utils/str_utils.py +256 -0
  196. howler/utils/uid.py +47 -0
  197. howler_api-2.13.0.dev329.dist-info/METADATA +71 -0
  198. howler_api-2.13.0.dev329.dist-info/RECORD +200 -0
  199. howler_api-2.13.0.dev329.dist-info/WHEEL +4 -0
  200. howler_api-2.13.0.dev329.dist-info/entry_points.txt +8 -0
howler/odm/base.py ADDED
@@ -0,0 +1,1504 @@
1
+ """HOWLER's built in Object Document Model tool.
2
+
3
+ The classes in this module can be composed to build database
4
+ independent data models in python. This gives us:
5
+ - single source of truth for our data schemas
6
+ - database independent serialization
7
+ - type checking
8
+
9
+
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ import json
16
+ import re
17
+ import typing
18
+ from datetime import datetime
19
+ from enum import Enum as PyEnum
20
+ from enum import EnumMeta
21
+ from typing import Any as _Any
22
+ from typing import Dict, Tuple, Union
23
+ from venv import logger
24
+
25
+ import arrow
26
+ import validators
27
+ from dateutil.tz import tzutc
28
+
29
+ from howler.common import loader
30
+ from howler.common.exceptions import HowlerKeyError, HowlerNotImplementedError, HowlerTypeError, HowlerValueError
31
+ from howler.common.net import is_valid_domain, is_valid_ip
32
+ from howler.utils.dict_utils import flatten, recursive_update
33
+ from howler.utils.isotime import now_as_iso
34
+ from howler.utils.uid import get_random_id
35
+
36
+ BANNED_FIELDS = {
37
+ "_id",
38
+ "__access_grp1__",
39
+ "__access_lvl__",
40
+ "__access_req__",
41
+ "__access_grp2__",
42
+ }
43
+ DATEFORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
44
+ FIELD_SANITIZER = re.compile("^[a-z][a-z0-9_-]*$")
45
+ FLATTENED_OBJECT_SANITIZER = re.compile("^[a-z][a-z0-9_.]*$")
46
+ NOT_INDEXED_SANITIZER = re.compile("^[A-Za-z0-9_ -]*$")
47
+ UTC_TZ = tzutc()
48
+
49
+ DOMAIN_REGEX = (
50
+ r"(?:(?:[A-Za-z0-9\u00a1-\uffff][A-Za-z0-9\u00a1-\uffff_-]{0,62})?[A-Za-z0-9\u00a1-\uffff]\.)+"
51
+ r"(?:xn--)?(?:[A-Za-z0-9\u00a1-\uffff]{2,}\.?)"
52
+ )
53
+ DOMAIN_ONLY_REGEX = f"^{DOMAIN_REGEX}$"
54
+ EMAIL_REGEX = f"^[a-zA-Z0-9!#$%&'*+/=?^_‘{{|}}~-]+(?:\\.[a-zA-Z0-9!#$%&'*+/=?^_‘{{|}}~-]+)*@({DOMAIN_REGEX})$"
55
+ IPV4_REGEX = r"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"
56
+ IPV6_REGEX = (
57
+ r"(?:(?:[0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|"
58
+ r"(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|"
59
+ r"(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|"
60
+ r"(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|"
61
+ r":(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|"
62
+ r"::(?:ffff(?::0{1,4}){0,1}:){0,1}(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|"
63
+ r"(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|"
64
+ r"(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9]))"
65
+ )
66
+ IP_REGEX = f"(?:{IPV4_REGEX}|{IPV6_REGEX})"
67
+ IP_ONLY_REGEX = f"^{IP_REGEX}$"
68
+ PRIVATE_IP = (
69
+ r"(?:(?:127|10)(?:\.(?:[2](?:[0-5][0-5]|[01234][6-9])|[1][0-9][0-9]|[1-9][0-9]|[0-9])){3})|"
70
+ r"(?:172\.(?:1[6-9]|2[0-9]|3[0-1])(?:\.(?:2[0-4][0-9]|25[0-5]|[1][0-9][0-9]|[1-9][0-9]|[0-9])){2}|"
71
+ r"(?:192\.168(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])){2}))"
72
+ )
73
+ PHONE_REGEX = r"^(\+?\d{1,2})?[ .-]?(\(\d{3}\)|\d{3})[ .-](\d{3})[ .-](\d{4})$"
74
+ SSDEEP_REGEX = r"^[0-9]{1,18}:[a-zA-Z0-9/+]{0,64}:[a-zA-Z0-9/+]{0,64}$"
75
+ MD5_REGEX = r"^[a-f0-9]{32}$"
76
+ SHA1_REGEX = r"^[a-f0-9]{40}$"
77
+ SHA256_REGEX = r"^[a-f0-9]{64}$"
78
+ HOWLER_HASH_REGEX = r"^[a-f0-9]{1,64}$"
79
+ MAC_REGEX = r"^(?:(?:[0-9a-f]{2}-){5}[0-9a-f]{2}|(?:[0-9a-f]{2}:){5}[0-9a-f]{2})$"
80
+ URI_PATH = r"(?:[/?#]\S*)"
81
+ FULL_URI = f"^((?:(?:[A-Za-z]*:)?//)?(?:\\S+(?::\\S*)?@)?({IP_REGEX}|{DOMAIN_REGEX})(?::\\d{{2,5}})?){URI_PATH}?$"
82
+ PLATFORM_REGEX = r"^(Windows|Linux|MacOS|Android|iOS)$"
83
+ PROCESSOR_REGEX = r"^x(64|86)$"
84
+
85
+
86
+ def flat_to_nested(data: dict[str, _Any]) -> dict[str, _Any]:
87
+ sub_data: dict[str, _Any] = {}
88
+ nested_keys = []
89
+ for key, value in data.items():
90
+ if "." in key:
91
+ child, sub_key = key.split(".", 1)
92
+ nested_keys.append(child)
93
+ try:
94
+ sub_data[child][sub_key] = value
95
+ except (KeyError, TypeError):
96
+ sub_data[child] = {sub_key: value}
97
+ else:
98
+ sub_data[key] = value
99
+
100
+ for key in nested_keys:
101
+ sub_data[key] = flat_to_nested(sub_data[key])
102
+
103
+ return sub_data
104
+
105
+
106
+ class KeyMaskException(HowlerKeyError):
107
+ pass
108
+
109
+
110
+ class _Field:
111
+ def __init__(
112
+ self,
113
+ name=None,
114
+ index=None,
115
+ store=None,
116
+ copyto=None,
117
+ default=None,
118
+ description=None,
119
+ deprecated_description=None,
120
+ reference=None,
121
+ optional=False,
122
+ deprecated=False,
123
+ ):
124
+ self.index = index
125
+ self.store = store
126
+ self.multivalued = False
127
+ self.copyto = []
128
+ if isinstance(copyto, str):
129
+ self.copyto.append(copyto)
130
+ elif copyto:
131
+ self.copyto.extend(copyto)
132
+
133
+ self.name = name
134
+ self.parent_name = None
135
+ self.getter_function = None
136
+ self.setter_function = None
137
+ self.description = description
138
+ self.reference = reference
139
+ self.optional = optional
140
+ self.deprecated = deprecated
141
+ self.deprecated_description = deprecated_description
142
+
143
+ self.default = default
144
+ self.default_set = default is not None
145
+
146
+ # noinspection PyProtectedMember
147
+ def __get__(self, obj, objtype=None):
148
+ """Read the value of this field from the model instance (obj)."""
149
+ if obj is None:
150
+ return obj
151
+ if self.name in obj._odm_removed:
152
+ raise KeyMaskException(self.name)
153
+ if self.getter_function is not None:
154
+ return self.getter_function(obj, obj._odm_py_obj[self.name.rstrip("_")])
155
+
156
+ return obj._odm_py_obj[self.name.rstrip("_")]
157
+
158
+ # noinspection PyProtectedMember
159
+ def __set__(self, obj, value):
160
+ """Set the value of this field, calling a setter method if available."""
161
+ if self.name in obj._odm_removed:
162
+ raise KeyMaskException(self.name)
163
+ value = self.check(value)
164
+ if self.setter_function is not None:
165
+ value = self.setter_function(obj, value)
166
+ obj._odm_py_obj[self.name.rstrip("_")] = value
167
+
168
+ def getter(self, method):
169
+ """Decorator to create getter method for a field."""
170
+ out = copy.deepcopy(self)
171
+ out.getter_function = method
172
+ return out
173
+
174
+ def setter(self, method):
175
+ """Let fields be used as a decorator to define a setter method.
176
+
177
+ >>> expiry = Date()
178
+ >>>
179
+ >>> # noinspection PyUnusedLocal,PyUnresolvedReferences
180
+ >>> @expiry.setter
181
+ >>> def expiry(self, assign, value):
182
+ >>> assert value
183
+ >>> assign(value)
184
+ """
185
+ out = copy.deepcopy(self)
186
+ out.setter_function = method
187
+ return out
188
+
189
+ def apply_defaults(self, index, store):
190
+ """Used by the model decorator to pass through default parameters."""
191
+ if self.index is None:
192
+ self.index = index
193
+ if self.store is None:
194
+ self.store = store
195
+
196
+ def fields(self):
197
+ """Return the subfields/modified field data.
198
+
199
+ For simple fields this is an identity function.
200
+ """
201
+ return {"": self}
202
+
203
+ def check(self, value, **kwargs):
204
+ raise HowlerNotImplementedError(
205
+ "This function is not defined in the default field. " "Each fields has to have their own definition"
206
+ )
207
+
208
+ def __repr__(self) -> str:
209
+ keys = [
210
+ key
211
+ for key in self.__dir__()
212
+ if not key.startswith("_") and not callable(getattr(self, key)) and getattr(self, key) is not None
213
+ ]
214
+ return f"{type(self).__name__}({', '.join([f'{key}={str(getattr(self, key))}' for key in keys])})"
215
+
216
+
217
+ class _DeletedField:
218
+ pass
219
+
220
+
221
+ class Date(_Field):
222
+ """A field storing a datetime value."""
223
+
224
+ def check(self, value, context=[], **kwargs):
225
+ if value is None:
226
+ return None
227
+
228
+ if value == "NOW":
229
+ value = now_as_iso()
230
+
231
+ try:
232
+ try:
233
+ return datetime.strptime(value, DATEFORMAT).replace(tzinfo=UTC_TZ)
234
+ except (TypeError, ValueError):
235
+ return arrow.get(value).datetime
236
+ except Exception as e:
237
+ raise HowlerValueError(f"[{'.'.join(context) or self.name}]: {str(e)}")
238
+
239
+
240
+ class Boolean(_Field):
241
+ """A field storing a boolean value."""
242
+
243
+ def check(self, value, context=[], **kwargs):
244
+ if self.optional and value is None:
245
+ return None
246
+
247
+ try:
248
+ return bool(value)
249
+ except ValueError as e:
250
+ raise HowlerValueError(f"[{'.'.join(context) or self.name}]: {str(e)}")
251
+
252
+
253
+ class Json(_Field):
254
+ """A field storing serializeable structure with their JSON encoded representations.
255
+
256
+ Examples: metadata
257
+ """
258
+
259
+ def check(self, value, context=[], **kwargs):
260
+ if self.optional and value is None:
261
+ return None
262
+
263
+ if not isinstance(value, str):
264
+ try:
265
+ return json.dumps(value)
266
+ except (ValueError, OverflowError, TypeError) as e:
267
+ raise HowlerValueError(f"[{'.'.join(context) or self.name}]: {str(e)}")
268
+
269
+ return value
270
+
271
+
272
+ class Keyword(_Field):
273
+ """A field storing a short string with a technical interpretation.
274
+
275
+ Examples: file hashes, service names, document ids
276
+ """
277
+
278
+ def check(self, value, context=[], **kwargs):
279
+ # We have a special case for bytes here due to how often strings and bytes
280
+ # get mixed up in python apis
281
+ if self.optional and value is None:
282
+ return None
283
+
284
+ if isinstance(value, bytes):
285
+ raise HowlerValueError(f"[{'.'.join(context) or self.name}] Keyword doesn't accept bytes values")
286
+
287
+ if value == "" or value is None:
288
+ if self.default_set:
289
+ value = self.default
290
+ else:
291
+ raise HowlerValueError(
292
+ f"[{'.'.join(context) or self.name}] Empty strings are not allowed without defaults"
293
+ )
294
+
295
+ if value is None:
296
+ return None
297
+
298
+ return str(value)
299
+
300
+
301
+ class EmptyableKeyword(_Field):
302
+ """A keyword which allow to differentiate between empty and None values."""
303
+
304
+ def check(self, value, context=[], **kwargs):
305
+ if self.optional and value is None:
306
+ return None
307
+
308
+ # We have a special case for bytes here due to how often strings and bytes
309
+ # get mixed up in python apis
310
+ if isinstance(value, bytes):
311
+ raise HowlerValueError(f"[{'.'.join(context) or self.name}] EmptyableKeyword doesn't accept bytes values")
312
+
313
+ if value is None and self.default_set:
314
+ value = self.default
315
+
316
+ if value is None:
317
+ return None
318
+
319
+ return str(value)
320
+
321
+
322
+ class UpperKeyword(Keyword):
323
+ """A field storing a short uppercase string with a technical interpretation."""
324
+
325
+ def check(self, value, context=[], **kwargs):
326
+ kw_val = super().check(value, context=context, **kwargs)
327
+
328
+ if kw_val is None:
329
+ return None
330
+
331
+ return kw_val.upper()
332
+
333
+
334
+ class LowerKeyword(Keyword):
335
+ """
336
+ A field storing a short lowercase string with a technical interpretation.
337
+ """
338
+
339
+ def check(self, value, context=[], **kwargs):
340
+ kw_val = super().check(value, context=context, **kwargs)
341
+
342
+ if kw_val is None:
343
+ return None
344
+
345
+ return kw_val.lower()
346
+
347
+
348
+ class CaseInsensitiveKeyword(Keyword):
349
+ """
350
+ A field storing a string with a technical interpretation, but is case-insensitive when searching.
351
+ """
352
+
353
+
354
+ class Any(Keyword):
355
+ """A field that can hold any value whatsoever but which is stored as a
356
+ Keyword in the datastore index
357
+ """
358
+
359
+ def __init__(self, *args, **kwargs):
360
+ kwargs["index"] = False
361
+ kwargs["store"] = False
362
+ super().__init__(*args, **kwargs)
363
+
364
+ def check(self, value, **_):
365
+ return value
366
+
367
+
368
+ class ValidatedKeyword(Keyword):
369
+ """Keyword field which the values are validated by a regular expression"""
370
+
371
+ def __init__(self, validation_regex, *args, **kwargs):
372
+ super().__init__(*args, **kwargs)
373
+ self.validation_regex = re.compile(validation_regex)
374
+
375
+ def __deepcopy__(self, memo=None):
376
+ # NOTE: This deepcopy code does not work with a sub-class that add args of kwargs that should be copied.
377
+ # If that is the case, the sub-class should implement its own deepcopy function.
378
+ valid_fields = ["name", "index", "store", "copyto", "default", "description"]
379
+ if "validation_regex" in self.__class__.__init__.__code__.co_varnames:
380
+ return self.__class__(
381
+ self.validation_regex.pattern,
382
+ **{k: v for k, v in self.__dict__.items() if k in valid_fields},
383
+ )
384
+ else:
385
+ return self.__class__(**{k: v for k, v in self.__dict__.items() if k in valid_fields})
386
+
387
+ def check(self, value, context=[], **kwargs):
388
+ if self.optional and value is None:
389
+ return None
390
+
391
+ if not value:
392
+ if self.default_set:
393
+ value = self.default
394
+ else:
395
+ raise HowlerValueError(
396
+ f"[{'.'.join(context) or self.name}]: Empty strings are not allowed without defaults"
397
+ )
398
+
399
+ if value is None:
400
+ return value
401
+
402
+ if not self.validation_regex.match(value):
403
+ raise HowlerValueError(
404
+ f"[{'.'.join(context) or self.name}]: '{value}' not match the "
405
+ f"validator: {self.validation_regex.pattern}"
406
+ )
407
+
408
+ return str(value)
409
+
410
+
411
+ class IP(Keyword):
412
+ def __init__(self, *args, **kwargs):
413
+ super().__init__(*args, **kwargs)
414
+ self.validation_regex = re.compile(IP_ONLY_REGEX)
415
+
416
+ def check(self, value, context=[], **kwargs):
417
+ if not value:
418
+ return None
419
+
420
+ if not self.validation_regex.match(value):
421
+ raise HowlerValueError(
422
+ f"[{'.'.join(context) or self.name}]: '{value}' not match the "
423
+ f"validator: {self.validation_regex.pattern}"
424
+ )
425
+
426
+ return value
427
+
428
+
429
+ class Domain(Keyword):
430
+ def __init__(self, *args, strict=True, **kwargs):
431
+ super().__init__(*args, **kwargs)
432
+ self.strict = strict
433
+
434
+ def check(self, value, context=[], **kwargs):
435
+ if not value:
436
+ return None
437
+
438
+ domain_result = validators.domain(value)
439
+ # We'll only raise the exception if strict mode is enabled - otherwise, we'll check hostname validation as well
440
+ if isinstance(domain_result, Exception) and self.strict:
441
+ raise HowlerValueError(
442
+ f"[{'.'.join(context) or self.name}] '{value}' did not pass validation."
443
+ ) from domain_result
444
+
445
+ hostname_result = validators.hostname(value)
446
+ if isinstance(hostname_result, Exception):
447
+ raise HowlerValueError(
448
+ f"[{'.'.join(context) or self.name}] '{value}' did not pass validation."
449
+ ) from hostname_result
450
+
451
+ return value.lower()
452
+
453
+
454
+ class Email(Keyword):
455
+ def __init__(self, *args, **kwargs):
456
+ super().__init__(*args, **kwargs)
457
+ self.validation_regex = re.compile(EMAIL_REGEX)
458
+
459
+ def check(self, value, context=[], **kwargs):
460
+ if not value:
461
+ return None
462
+
463
+ validation_result = validators.email(value)
464
+ if isinstance(validation_result, Exception):
465
+ raise HowlerValueError(
466
+ f"[{'.'.join(context) or self.name}] '{value}' did not pass validation."
467
+ ) from validation_result
468
+
469
+ match = self.validation_regex.match(value)
470
+ if not is_valid_domain(match.group(1)):
471
+ raise HowlerValueError(
472
+ f"[{'.'.join(context) or self.name}] '{match.group(1)}' in email '{value}'" " is not a valid Domain."
473
+ )
474
+
475
+ return value.lower()
476
+
477
+
478
+ class URI(Keyword):
479
+ def __init__(self, *args, **kwargs):
480
+ super().__init__(*args, **kwargs)
481
+ self.validation_regex = re.compile(FULL_URI)
482
+
483
+ def check(self, value, context=[], **kwargs):
484
+ if not value:
485
+ return None
486
+
487
+ match = self.validation_regex.match(value)
488
+ if not match:
489
+ raise HowlerValueError(
490
+ f"[{'.'.join(context) or self.name}] '{value}' not match the "
491
+ f"validator: {self.validation_regex.pattern}"
492
+ )
493
+
494
+ if not is_valid_domain(match.group(2)) and not is_valid_ip(match.group(2)):
495
+ raise HowlerValueError(
496
+ f"[{'.'.join(context) or self.name}] '{match.group(2)}' in URI '{value}'"
497
+ " is not a valid Domain or IP."
498
+ )
499
+
500
+ return match.group(0).replace(match.group(1), match.group(1).lower())
501
+
502
+
503
+ class URIPath(ValidatedKeyword):
504
+ def __init__(self, *args, **kwargs):
505
+ super().__init__(URI_PATH, *args, **kwargs)
506
+
507
+
508
+ class MAC(ValidatedKeyword):
509
+ def __init__(self, *args, **kwargs):
510
+ super().__init__(MAC_REGEX, *args, **kwargs)
511
+
512
+
513
+ class PhoneNumber(ValidatedKeyword):
514
+ def __init__(self, *args, **kwargs):
515
+ super().__init__(PHONE_REGEX, *args, **kwargs)
516
+
517
+
518
+ class SSDeepHash(ValidatedKeyword):
519
+ def __init__(self, *args, **kwargs):
520
+ super().__init__(SSDEEP_REGEX, *args, **kwargs)
521
+
522
+
523
+ class SHA1(ValidatedKeyword):
524
+ def __init__(self, *args, **kwargs):
525
+ super().__init__(SHA1_REGEX, *args, **kwargs)
526
+
527
+
528
+ class SHA256(ValidatedKeyword):
529
+ def __init__(self, *args, **kwargs):
530
+ super().__init__(SHA256_REGEX, *args, **kwargs)
531
+
532
+
533
+ class HowlerHash(ValidatedKeyword):
534
+ def __init__(self, *args, **kwargs):
535
+ super().__init__(HOWLER_HASH_REGEX, *args, **kwargs)
536
+
537
+
538
+ class MD5(ValidatedKeyword):
539
+ def __init__(self, *args, **kwargs):
540
+ super().__init__(MD5_REGEX, *args, **kwargs)
541
+
542
+
543
+ class Platform(ValidatedKeyword):
544
+ def __init__(self, *args, **kwargs):
545
+ super().__init__(PLATFORM_REGEX, *args, **kwargs)
546
+
547
+
548
+ class Processor(ValidatedKeyword):
549
+ def __init__(self, *args, **kwargs):
550
+ super().__init__(PROCESSOR_REGEX, *args, **kwargs)
551
+
552
+
553
+ class Enum(Keyword):
554
+ """A field storing a short string that has predefined list of possible values"""
555
+
556
+ def __init__(self, values: PyEnum | list[typing.Any] | set[typing.Any], *args, **kwargs):
557
+ super().__init__(*args, **kwargs)
558
+ if isinstance(values, set):
559
+ self.values = values
560
+ elif isinstance(values, (list, tuple)):
561
+ self.values = set(values)
562
+ elif isinstance(values, (PyEnum, EnumMeta)):
563
+ self.values = set([e.value for e in values])
564
+ else:
565
+ raise HowlerTypeError(f"Type unsupported for Enum odm: {type(values)}")
566
+
567
+ def check(self, value, context=[], **kwargs):
568
+ if self.optional and value is None:
569
+ return None
570
+
571
+ if not value:
572
+ if self.default_set:
573
+ value = self.default
574
+ else:
575
+ raise HowlerValueError(f"[{'.'.join(context)}] Empty enums are not allow without defaults")
576
+
577
+ if value not in self.values:
578
+ raise HowlerValueError(f"[{'.'.join(context)}] {value} not in the possible values: {self.values}")
579
+
580
+ if value is None:
581
+ return value
582
+
583
+ return str(value)
584
+
585
+
586
+ class UUID(Keyword):
587
+ """A field storing an auto-generated unique ID if None is provided"""
588
+
589
+ def __init__(self, *args, **kwargs):
590
+ super().__init__(*args, **kwargs)
591
+ self.default_set = True
592
+
593
+ def check(self, value, **kwargs):
594
+ if value is None:
595
+ value = get_random_id()
596
+
597
+ return str(value)
598
+
599
+
600
+ class Text(_Field):
601
+ """A field storing human readable text data."""
602
+
603
+ def check(self, value, context=[], **kwargs):
604
+ if self.optional and value is None:
605
+ return None
606
+
607
+ if not value:
608
+ if self.default_set:
609
+ value = self.default
610
+ else:
611
+ raise HowlerValueError(f"[{'.'.join(context)}] Empty strings are not allowed without defaults")
612
+
613
+ if value is None:
614
+ return None
615
+
616
+ return str(value)
617
+
618
+
619
+ class IndexText(_Field):
620
+ """A special field with special processing rules to simplify searching."""
621
+
622
+ def check(self, value, **kwargs):
623
+ if self.optional and value is None:
624
+ return None
625
+
626
+ return str(value)
627
+
628
+
629
+ class Integer(_Field):
630
+ """A field storing an integer value."""
631
+
632
+ def check(self, value, context=[], **kwargs):
633
+ if self.optional and value is None:
634
+ return None
635
+
636
+ if value is None or value == "":
637
+ if self.default_set:
638
+ return self.default
639
+
640
+ try:
641
+ return int(value)
642
+ except ValueError as e:
643
+ raise HowlerValueError(f"[{'.'.join(context)}]: {str(e)}")
644
+
645
+
646
+ class Float(_Field):
647
+ """A field storing a floating point value."""
648
+
649
+ def check(self, value, context=[], **kwargs):
650
+ if self.optional and value is None:
651
+ return None
652
+
653
+ if not value:
654
+ if self.default_set:
655
+ return self.default
656
+ try:
657
+ return float(value)
658
+ except ValueError as e:
659
+ raise HowlerValueError(f"[{'.'.join(context)}]: {str(e)}")
660
+
661
+
662
+ class ClassificationObject(object):
663
+ def __init__(self, engine, value, is_uc=False):
664
+ self.engine = engine
665
+ self.is_uc = is_uc
666
+ self.value = engine.normalize_classification(value, skip_auto_select=is_uc)
667
+
668
+ def get_access_control_parts(self):
669
+ return self.engine.get_access_control_parts(self.value, user_classification=self.is_uc)
670
+
671
+ def min(self, other):
672
+ return ClassificationObject(
673
+ self.engine,
674
+ self.engine.min_classification(self.value, other.value),
675
+ is_uc=self.is_uc,
676
+ )
677
+
678
+ def max(self, other):
679
+ return ClassificationObject(
680
+ self.engine,
681
+ self.engine.max_classification(self.value, other.value),
682
+ is_uc=self.is_uc,
683
+ )
684
+
685
+ def intersect(self, other):
686
+ return ClassificationObject(
687
+ self.engine,
688
+ self.engine.intersect_user_classification(self.value, other.value),
689
+ is_uc=self.is_uc,
690
+ )
691
+
692
+ def long(self):
693
+ return self.engine.normalize_classification(self.value, skip_auto_select=self.is_uc)
694
+
695
+ def small(self):
696
+ return self.engine.normalize_classification(self.value, long_format=False, skip_auto_select=self.is_uc)
697
+
698
+ def __str__(self):
699
+ return self.value
700
+
701
+ def __eq__(self, other):
702
+ return self.value == other.value
703
+
704
+ def __ne__(self, other):
705
+ return self.value != other.value
706
+
707
+ def __le__(self, other):
708
+ return self.engine.is_accessible(other.value, self.value)
709
+
710
+ def __lt__(self, other):
711
+ return self.engine.is_accessible(other.value, self.value)
712
+
713
+ def __ge__(self, other):
714
+ return self.engine.is_accessible(self.value, other.value)
715
+
716
+ def __gt__(self, other):
717
+ return not self.engine.is_accessible(other.value, self.value)
718
+
719
+
720
+ class Classification(Keyword):
721
+ """A field storing access control classification."""
722
+
723
+ def __init__(self, *args, is_user_classification=False, yml_config=None, **kwargs):
724
+ """An expanded classification is one that controls the access to the document
725
+ which holds it.
726
+ """
727
+ super().__init__(*args, **kwargs)
728
+ self.engine = loader.get_classification(yml_config=yml_config)
729
+ self.is_uc = is_user_classification
730
+
731
+ def check(self, value, **kwargs):
732
+ if self.optional and value is None:
733
+ return None
734
+
735
+ if isinstance(value, ClassificationObject):
736
+ return ClassificationObject(self.engine, value.value, is_uc=self.is_uc)
737
+
738
+ return ClassificationObject(self.engine, value, is_uc=self.is_uc)
739
+
740
+
741
+ class ClassificationString(Keyword):
742
+ """A field storing the classification as a string only."""
743
+
744
+ def __init__(self, *args, yml_config=None, **kwargs):
745
+ super().__init__(*args, **kwargs)
746
+ self.engine = loader.get_classification(yml_config=yml_config)
747
+
748
+ def check(self, value, context=[], **kwargs):
749
+ if self.optional and value is None:
750
+ return None
751
+
752
+ if not value:
753
+ if self.default_set:
754
+ value = self.default
755
+ else:
756
+ raise HowlerValueError(
757
+ f"[{'.'.join(context) or self.name}]: Empty classification is not allowed without defaults"
758
+ )
759
+
760
+ if not self.engine.is_valid(value):
761
+ raise HowlerValueError(f"[{'.'.join(context) or self.name}]: Invalid classification: {value}")
762
+
763
+ return str(value)
764
+
765
+
766
+ class TypedList(list):
767
+ def __init__(self, type_p, *items, context=[], **kwargs):
768
+ self.context = context
769
+ self.type = type_p
770
+
771
+ super().__init__([type_p.check(el, context=self.context, **kwargs) for el in items])
772
+
773
+ def append(self, item):
774
+ super().append(self.type.check(item, context=self.context))
775
+
776
+ def extend(self, sequence):
777
+ super().extend(self.type.check(item, context=self.context) for item in sequence)
778
+
779
+ def insert(self, index, item):
780
+ super().insert(index, self.type.check(item, context=self.context))
781
+
782
+ def __setitem__(self, index, item):
783
+ if isinstance(index, slice):
784
+ item = [self.type.check(val, context=self.context) for val in item]
785
+ super().__setitem__(index, item)
786
+ else:
787
+ super().__setitem__(index, self.type.check(item, context=self.context))
788
+
789
+
790
+ class List(_Field):
791
+ """A field storing a sequence of typed elements."""
792
+
793
+ def __init__(self, child_type, **kwargs):
794
+ super().__init__(**kwargs)
795
+ self.child_type = child_type
796
+
797
+ def check(self, value, **kwargs):
798
+ if self.optional and value is None:
799
+ return None
800
+
801
+ if isinstance(self.child_type, Compound) and isinstance(value, dict):
802
+ # Search queries of list of compound fields will return dotted paths of list of
803
+ # values. When processed through the flat_fields function, since this function
804
+ # has no idea about the data layout, it will transform the dotted paths into
805
+ # a dictionary of items then contains a list of object instead of a list
806
+ # of dictionaries with single items.
807
+
808
+ # The following piece of code transforms the dictionary of list into a list of
809
+ # dictionaries so the rest of the model validation can go through.
810
+
811
+ fixed_values = []
812
+ check_key = None
813
+ length = None
814
+ for key, val in flatten(value).items():
815
+ if not isinstance(val, list):
816
+ val = [val]
817
+
818
+ if length is None:
819
+ check_key = key
820
+ length = len(val)
821
+
822
+ for entry in val:
823
+ fixed_values.append({key: entry})
824
+ elif len(val) != length:
825
+ raise HowlerValueError(
826
+ "Flattened fields creating list of ODMs must have equal length. Key "
827
+ f"{key} has length {len(val)} compared to key {check_key} with length {length}."
828
+ )
829
+ else:
830
+ for i in range(len(val)):
831
+ fixed_values[i][key] = val[i]
832
+
833
+ return TypedList(
834
+ self.child_type,
835
+ *fixed_values,
836
+ **kwargs,
837
+ )
838
+
839
+ if value is None:
840
+ logger.warning("Value is None, but optional is not set to True. Using an empty list to avoid errors.")
841
+ value = []
842
+
843
+ return TypedList(self.child_type, *value, **kwargs)
844
+
845
+ def apply_defaults(self, index, store):
846
+ """Initialize the default settings for the child field."""
847
+ # First apply the default to the list itself
848
+ super().apply_defaults(index, store)
849
+ # Then pass through the initialized values on the list to the child type
850
+ self.child_type.apply_defaults(self.index, self.store)
851
+
852
+ def fields(self):
853
+ out = dict()
854
+ for name, field_data in self.child_type.fields().items():
855
+ field_data = copy.deepcopy(field_data)
856
+ field_data.apply_defaults(self.index, self.store)
857
+ out[name] = field_data
858
+ return out
859
+
860
+
861
+ class TypedMapping(dict):
862
+ def __init__(self, type_p, index, store, sanitizer, context=[], **items):
863
+ self.index = index
864
+ self.store = store
865
+ self.sanitizer = sanitizer
866
+ self.context = context
867
+
868
+ for key in items.keys():
869
+ if not self.sanitizer.match(key):
870
+ raise HowlerKeyError(f"[{'.'.join(self.context)}]: Illegal key {key}")
871
+
872
+ super().__init__({key: type_p.check(el, context=self.context) for key, el in items.items()})
873
+ self.type = type_p
874
+
875
+ def __setitem__(self, key, item):
876
+ if not self.sanitizer.match(key):
877
+ raise HowlerKeyError(f"[{'.'.join(self.context)}]: Illegal key: {key}")
878
+
879
+ return super().__setitem__(key, self.type.check(item, context=self.context))
880
+
881
+ def update(self, *args, **kwargs):
882
+ # Update supports three input layouts:
883
+ # 1. A single dictionary
884
+ if len(args) == 1 and isinstance(args[0], dict):
885
+ for key in args[0].keys():
886
+ if not self.sanitizer.match(key):
887
+ raise HowlerKeyError(f"[{'.'.join(self.context)}]: Illegal key: {key}")
888
+
889
+ return super().update({key: self.type.check(item, context=self.context) for key, item in args[0].items()})
890
+
891
+ # 2. A list of key value pairs as if you were constructing a dictionary
892
+ elif args:
893
+ for key, _ in args:
894
+ if not self.sanitizer.match(key):
895
+ raise HowlerKeyError(f"[{'.'.join(self.context)}]: Illegal key: {key}")
896
+
897
+ return super().update({key: self.type.check(item, context=self.context) for key, item in args})
898
+
899
+ # 3. Key values as arguments, can be combined with others
900
+ if kwargs:
901
+ for key in kwargs.keys():
902
+ if not self.sanitizer.match(key):
903
+ raise HowlerKeyError(f"[{'.'.join(self.context)}]: Illegal key: {key}")
904
+
905
+ return super().update({key: self.type.check(item, context=self.context) for key, item in kwargs.items()})
906
+
907
+
908
+ class Mapping(_Field):
909
+ """A field storing a sequence of typed elements."""
910
+
911
+ def __init__(self, child_type, **kwargs):
912
+ self.child_type = child_type
913
+ super().__init__(**kwargs)
914
+
915
+ def check(self, value, **kwargs):
916
+ if self.optional and value is None:
917
+ return None
918
+
919
+ if self.index or self.store:
920
+ sanitizer = FIELD_SANITIZER
921
+ else:
922
+ sanitizer = NOT_INDEXED_SANITIZER
923
+
924
+ return TypedMapping(self.child_type, self.index, self.store, sanitizer, **value)
925
+
926
+ def apply_defaults(self, index, store):
927
+ """Initialize the default settings for the child field."""
928
+ # First apply the default to the list itself
929
+ super().apply_defaults(index, store)
930
+ # Then pass through the initialized values on the list to the child type
931
+ self.child_type.apply_defaults(self.index, self.store)
932
+
933
+
934
+ class FlattenedListObject(Mapping):
935
+ """A field storing a flattened object"""
936
+
937
+ def __init__(self, **kwargs):
938
+ super().__init__(List(Json()), **kwargs)
939
+
940
+ def check(self, value, **kwargs):
941
+ if self.optional and value is None:
942
+ return None
943
+
944
+ return TypedMapping(self.child_type, self.index, self.store, FLATTENED_OBJECT_SANITIZER, **value)
945
+
946
+ def apply_defaults(self, index, store):
947
+ """Initialize the default settings for the child field."""
948
+ # First apply the default to the list itself
949
+ super().apply_defaults(index, store)
950
+ # Then pass through the initialized values on the list to the child type
951
+ self.child_type.apply_defaults(self.index, self.store)
952
+
953
+
954
+ class FlattenedObject(Mapping):
955
+ """A field storing a flattened object"""
956
+
957
+ def __init__(self, **kwargs):
958
+ super().__init__(Json(), **kwargs)
959
+
960
+ def check(self, value, context=[], **kwargs):
961
+ if self.optional and value is None:
962
+ return None
963
+
964
+ return TypedMapping(
965
+ self.child_type,
966
+ self.index,
967
+ self.store,
968
+ FLATTENED_OBJECT_SANITIZER,
969
+ context=context,
970
+ **value,
971
+ )
972
+
973
+ def apply_defaults(self, index, store):
974
+ """Initialize the default settings for the child field."""
975
+ # First apply the default to the list itself
976
+ super().apply_defaults(index, store)
977
+ # Then pass through the initialized values on the list to the child type
978
+ self.child_type.apply_defaults(self.index, self.store)
979
+
980
+
981
+ class Compound(_Field):
982
+ def __init__(self, field_type, **kwargs):
983
+ super().__init__(**kwargs)
984
+ self.child_type = field_type
985
+
986
+ def check(
987
+ self,
988
+ value,
989
+ mask=None,
990
+ ignore_extra_values=False,
991
+ extra_fields={},
992
+ context=[],
993
+ **kwargs,
994
+ ):
995
+ if self.optional and value is None:
996
+ return None
997
+
998
+ if isinstance(value, self.child_type):
999
+ return value
1000
+
1001
+ return self.child_type(
1002
+ value,
1003
+ mask=mask,
1004
+ ignore_extra_values=ignore_extra_values,
1005
+ extra_fields=extra_fields,
1006
+ context=context,
1007
+ )
1008
+
1009
+ def fields(self):
1010
+ out = dict()
1011
+ for name, field_data in self.child_type.fields().items():
1012
+ field_data = copy.deepcopy(field_data)
1013
+ field_data.apply_defaults(self.index, self.store)
1014
+ out[name] = field_data
1015
+ return out
1016
+
1017
+
1018
+ class Optional(_Field):
1019
+ """A wrapper field to allow simple types (int, float, bool) to take None values."""
1020
+
1021
+ def __init__(self, child_type: _Field, **kwargs):
1022
+ if child_type.default_set:
1023
+ kwargs["default"] = child_type.default
1024
+ else:
1025
+ child_type.default_set = True
1026
+ super().__init__(**kwargs)
1027
+ self.default_set = True
1028
+ self.child_type = child_type
1029
+ self.child_type.optional = True
1030
+
1031
+ def check(self, value, *args, **kwargs):
1032
+ if value is None:
1033
+ return None
1034
+
1035
+ return self.child_type.check(value, *args, **kwargs)
1036
+
1037
+ def fields(self):
1038
+ return self.child_type.fields()
1039
+
1040
+ def apply_defaults(self, index, store):
1041
+ super().apply_defaults(index, store)
1042
+ self.child_type.apply_defaults(self.index, self.store)
1043
+
1044
+
1045
+ class Model:
1046
+ @classmethod
1047
+ def fields(cls, skip_mappings=False) -> dict[str, _Field]:
1048
+ """Describe the elements of the model.
1049
+
1050
+ For compound fields return the field object.
1051
+
1052
+ Args:
1053
+ skip_mappings (bool): Skip over mappings where the real subfield names are unknown.
1054
+ """
1055
+ if skip_mappings and hasattr(cls, "_odm_field_cache_skip"):
1056
+ return cls._odm_field_cache_skip
1057
+
1058
+ if not skip_mappings and hasattr(cls, "_odm_field_cache"):
1059
+ return cls._odm_field_cache
1060
+
1061
+ out = dict()
1062
+ for name, field_data in cls.__dict__.items():
1063
+ if isinstance(field_data, _Field):
1064
+ if skip_mappings and isinstance(field_data, Mapping):
1065
+ continue
1066
+ out[name.rstrip("_")] = field_data
1067
+
1068
+ if skip_mappings:
1069
+ cls._odm_field_cache_skip = out
1070
+ else:
1071
+ cls._odm_field_cache = out
1072
+ return out
1073
+
1074
+ @classmethod
1075
+ def add_namespace(cls, namespace: str, field: _Field):
1076
+ field.name = namespace
1077
+
1078
+ if hasattr(cls, "_odm_field_cache_skip"):
1079
+ cls._odm_field_cache_skip[namespace.rstrip("_")] = field
1080
+
1081
+ if hasattr(cls, "_odm_field_cache"):
1082
+ cls._odm_field_cache[namespace.rstrip("_")] = field
1083
+
1084
+ return setattr(cls, namespace, field)
1085
+
1086
+ @staticmethod
1087
+ def _recurse_fields(name, field, show_compound, skip_mappings, multivalued=False):
1088
+ name = name.rstrip("_")
1089
+ out = dict()
1090
+ for sub_name, sub_field in field.fields().items():
1091
+ sub_field.multivalued = multivalued or isinstance(field, List)
1092
+
1093
+ if skip_mappings and isinstance(sub_field, Mapping):
1094
+ continue
1095
+
1096
+ elif isinstance(sub_field, (List, Optional, Compound)) and sub_name != "":
1097
+ out.update(
1098
+ Model._recurse_fields(
1099
+ f"{name}.{sub_name}",
1100
+ sub_field.child_type,
1101
+ show_compound,
1102
+ skip_mappings,
1103
+ multivalued=multivalued or isinstance(sub_field, List),
1104
+ )
1105
+ )
1106
+
1107
+ elif sub_name:
1108
+ out[f"{name}.{sub_name}"] = sub_field
1109
+
1110
+ else:
1111
+ out[name] = sub_field
1112
+
1113
+ if isinstance(field, Compound) and show_compound:
1114
+ out[name] = field
1115
+
1116
+ return out
1117
+
1118
+ @classmethod
1119
+ def flat_fields(cls, show_compound=False, skip_mappings=False) -> dict[str, _Field]:
1120
+ """Describe the elements of the model.
1121
+
1122
+ Recurse into compound fields, concatenating the names with '.' separators.
1123
+
1124
+ Args:
1125
+ show_compound (bool): Show compound as valid fields.
1126
+ skip_mappings (bool): Skip over mappings where the real subfield names are unknown.
1127
+ """
1128
+ out = dict()
1129
+ for name, field in cls.__dict__.items():
1130
+ if isinstance(field, _Field):
1131
+ if skip_mappings and isinstance(field, Mapping):
1132
+ continue
1133
+ out.update(
1134
+ Model._recurse_fields(
1135
+ name,
1136
+ field,
1137
+ show_compound,
1138
+ skip_mappings,
1139
+ multivalued=isinstance(field, List),
1140
+ )
1141
+ )
1142
+ return out
1143
+
1144
+ @classmethod
1145
+ def markdown(
1146
+ cls,
1147
+ toc_depth=1,
1148
+ include_autogen_note=True,
1149
+ defaults=None,
1150
+ url_prefix="/howler-docs/odm/class/",
1151
+ ) -> Union[str, Dict]:
1152
+ markdown_content = (
1153
+ (
1154
+ '??? success "Auto-Generated Documentation"\n '
1155
+ "This set of documentation is automatically generated from source, and will help ensure any change to "
1156
+ "functionality will always be documented and available on release.\n\n"
1157
+ )
1158
+ if include_autogen_note
1159
+ else ""
1160
+ )
1161
+
1162
+ # Header
1163
+ markdown_content += f"{'#'*toc_depth} {cls.__name__}\n\n> {cls.__description}\n\n"
1164
+
1165
+ # Table
1166
+ table = "| Field | Type | Description | Required | Default |\n| :--- | :--- | :--- | :--- | :--- |\n"
1167
+
1168
+ # Determine the type of Field we're dealing with
1169
+ # if possible return the Model class if wrapped in Compound
1170
+ def get_type(field_class: _Field) -> Tuple[str, Model]:
1171
+ if field_class.__class__ == Optional:
1172
+ return get_type(field_class.child_type)
1173
+ elif field_class.__class__ == Compound:
1174
+ name = field_class.child_type.__name__
1175
+
1176
+ return (
1177
+ f"[{name}]({url_prefix}{name.lower()})",
1178
+ field_class.child_type,
1179
+ )
1180
+ elif field_class.__class__ in [Mapping, List]:
1181
+ child_type, child_class = (
1182
+ field_class.child_type.__class__.__name__,
1183
+ field_class.child_type.__class__,
1184
+ )
1185
+ if field_class.child_type.__class__ == Compound:
1186
+ child_type, child_class = get_type(field_class.child_type)
1187
+ return f"{field_class.__class__.__name__} [{child_type}]", child_class
1188
+ elif field_class.__class__.__name__ == "type":
1189
+ return field_class.__name__, None
1190
+
1191
+ return field_class.__class__.__name__, None
1192
+
1193
+ for field, info in cls.fields().items():
1194
+ field_type, field_class = get_type(info)
1195
+
1196
+ # Field description
1197
+ description = info.description
1198
+ if description is None and info.__class__ == Optional:
1199
+ description = info.child_type.description
1200
+ if info.child_type.reference:
1201
+ description += f'<br><a href="{info.child_type.reference}">Reference Link</a><br>'
1202
+ elif info.reference:
1203
+ description += f'<br><a href="{info.reference}">Reference Link</a><br>'
1204
+
1205
+ # If field type is Enum, then show the possible values that can be used in the description
1206
+ if field_type == "Enum":
1207
+ values = info.child_type.values if info.__class__ != Enum else info.values
1208
+ none_value = False
1209
+ if None in values:
1210
+ none_value = True
1211
+ values.remove(None)
1212
+
1213
+ values = [f'"{v}"' if v else str(v) for v in sorted(values)]
1214
+ values.append("None") if none_value else None
1215
+ description = f'{description}<br>Values:<br>`{", ".join(values)}`'
1216
+
1217
+ # Is this a required field?
1218
+ if info.__class__ != Optional and not info.optional:
1219
+ required = ":material-checkbox-marked-outline: Yes"
1220
+ else:
1221
+ required = ":material-minus-box-outline: Optional"
1222
+
1223
+ if info.deprecated:
1224
+ required += " :material-alert-box-outline: Deprecated - "
1225
+ required += info.deprecated_description
1226
+ elif info.__class__ == Optional and info.child_type.deprecated:
1227
+ required += " :material-alert-box-outline: Deprecated - "
1228
+ required += info.child_type.deprecated_description
1229
+
1230
+ # Determine the correct default values to display
1231
+ default = f"`{info.default}`"
1232
+ # If the field is a model, then provide a link to that documentation
1233
+ if field_class and issubclass(field_class, Model) and isinstance(info.default, dict):
1234
+ ref_link = field_type[field_type.index("(") : field_type.index(")") + 1]
1235
+ default = f"See [{field_class.__name__}]{ref_link} for more details."
1236
+
1237
+ # Handle how to display values from provided defaults (different from field defaults)
1238
+ elif isinstance(defaults, dict):
1239
+ val = defaults.get(field, {})
1240
+ default = f"`{val if not isinstance(val, dict) else info.default}`"
1241
+ elif isinstance(defaults, list):
1242
+ default = f"`{defaults}`"
1243
+ row = f"| {field} | {field_type} | {description} | {required} | {default} |\n"
1244
+ table += row
1245
+
1246
+ markdown_content += table + "\n\n"
1247
+
1248
+ return markdown_content
1249
+
1250
+ # Allow attribute assignment by default in the constructor until it is removed
1251
+ __frozen = False
1252
+ # Descriptions of the model should be class-accessible only for markdown()
1253
+ __description = None
1254
+
1255
+ def __init__(
1256
+ self,
1257
+ data: dict = None,
1258
+ mask: list = None,
1259
+ docid=None,
1260
+ ignore_extra_values=True,
1261
+ extra_fields={},
1262
+ context=[],
1263
+ ):
1264
+ if len(context) == 0:
1265
+ context = [self.__class__.__name__.lower()]
1266
+
1267
+ if data is None:
1268
+ data = {}
1269
+
1270
+ if not hasattr(data, "items"):
1271
+ raise HowlerTypeError(f"'{self.__class__.__name__}' object must be constructed with dict like")
1272
+ self._odm_py_obj = {}
1273
+ self._id = docid
1274
+ self.context = context
1275
+
1276
+ # Parse the field mask for sub models
1277
+ mask_map = {}
1278
+ if mask is not None:
1279
+ for entry in mask:
1280
+ if "." in entry:
1281
+ child, sub_key = entry.split(".", 1)
1282
+ try:
1283
+ mask_map[child].append(sub_key)
1284
+ except KeyError:
1285
+ mask_map[child] = [sub_key]
1286
+ else:
1287
+ mask_map[entry] = None
1288
+
1289
+ # Get the list of fields we expect this object to have
1290
+ fields = self.fields()
1291
+ self._odm_removed = {}
1292
+ if mask is not None:
1293
+ self._odm_removed = {k: v for k, v in fields.items() if k not in mask_map}
1294
+ fields = {k: v for k, v in fields.items() if k in mask_map}
1295
+
1296
+ # Trim out keys that actually belong to sub sections
1297
+ data = flat_to_nested(data)
1298
+
1299
+ # Check to make sure we can use all the data we are given
1300
+ self.unused_keys = set(data.keys()) - set(fields.keys()) - BANNED_FIELDS
1301
+ extra_keys = set(extra_fields.keys()) - set(data.keys())
1302
+ if self.unused_keys and not ignore_extra_values:
1303
+ raise HowlerValueError(
1304
+ f"[{'.'.join(context)}]: object was created with invalid parameters: " f"{', '.join(self.unused_keys)}"
1305
+ )
1306
+
1307
+ # Pass each value through it's respective validator, and store it
1308
+ for name, field_type in fields.items():
1309
+ params = {"ignore_extra_values": ignore_extra_values}
1310
+ if name in mask_map and mask_map[name]:
1311
+ params["mask"] = mask_map[name]
1312
+ if name in extra_fields and extra_fields[name]:
1313
+ params["extra_fields"] = extra_fields[name]
1314
+
1315
+ try:
1316
+ value = data[name]
1317
+ except KeyError:
1318
+ if field_type.default_set:
1319
+ value = copy.copy(field_type.default)
1320
+ elif not field_type.optional:
1321
+ raise HowlerValueError(f"[{'.'.join([*context, name])}]: value is missing from the object!")
1322
+ else:
1323
+ value = None
1324
+
1325
+ self._odm_py_obj[name.rstrip("_")] = field_type.check(value, context=[*context, name], **params)
1326
+
1327
+ value = None
1328
+
1329
+ for key in extra_keys:
1330
+ self._odm_py_obj[key.rstrip("_")] = Any().check(extra_fields[key], context=[*context, name])
1331
+
1332
+ # Since the layout of model objects should be fixed, don't allow any further
1333
+ # attribute assignment
1334
+ self.__frozen = True
1335
+
1336
+ def as_primitives(self, hidden_fields=False, strip_null=True) -> dict[str, typing.Any]:
1337
+ """Convert the object back into primitives that can be json serialized."""
1338
+ out = {}
1339
+
1340
+ fields = self.fields()
1341
+ for key, value in self._odm_py_obj.items():
1342
+ field_type = fields.get(key, Any)
1343
+ if value is not None or (value is None and field_type.default_set):
1344
+ if strip_null and value is None:
1345
+ continue
1346
+
1347
+ if isinstance(value, Model):
1348
+ out[key] = value.as_primitives(strip_null=strip_null)
1349
+ elif isinstance(value, datetime):
1350
+ out[key] = value.strftime(DATEFORMAT)
1351
+ elif isinstance(value, TypedMapping):
1352
+ out[key] = {
1353
+ k: (v.as_primitives(strip_null=strip_null) if isinstance(v, Model) else v)
1354
+ for k, v in value.items()
1355
+ }
1356
+ elif isinstance(value, TypedList):
1357
+ out[key] = [(v.as_primitives(strip_null=strip_null) if isinstance(v, Model) else v) for v in value]
1358
+ elif isinstance(value, ClassificationObject):
1359
+ out[key] = str(value)
1360
+ if hidden_fields:
1361
+ out.update(value.get_access_control_parts())
1362
+ else:
1363
+ out[key] = value
1364
+ return out
1365
+
1366
+ def json(self):
1367
+ return json.dumps(self.as_primitives())
1368
+
1369
+ def __eq__(self, other):
1370
+ if isinstance(other, dict):
1371
+ try:
1372
+ other = self.__class__(other)
1373
+ except (ValueError, KeyError):
1374
+ return False
1375
+
1376
+ elif not isinstance(other, self.__class__):
1377
+ return False
1378
+
1379
+ if len(self._odm_py_obj) != len(other._odm_py_obj):
1380
+ return False
1381
+
1382
+ for name, field in self.fields().items():
1383
+ if name in self._odm_removed:
1384
+ continue
1385
+ if field.__get__(self) != field.__get__(other):
1386
+ return False
1387
+
1388
+ return True
1389
+
1390
+ def __repr__(self):
1391
+ if self._id:
1392
+ return f"<{self.__class__.__name__} [{self._id}] {self.json()}>"
1393
+ return f"<{self.__class__.__name__} {self.json()}>"
1394
+
1395
+ def __getitem__(self, name):
1396
+ data = self._odm_py_obj
1397
+ for component in name.split("."):
1398
+ data = data[component.rstrip("_")]
1399
+
1400
+ return data
1401
+
1402
+ def get(self, name, default=None):
1403
+ try:
1404
+ return self[name]
1405
+ except KeyError:
1406
+ return default
1407
+
1408
+ def __setitem__(self, name, value):
1409
+ if name not in self._odm_field_cache:
1410
+ raise HowlerKeyError(f"[{'.'.join(self.context)}]: {name}")
1411
+ return self.__setattr__(name, value)
1412
+
1413
+ def __getattr__(self, name):
1414
+ # Any attribute that hasn't been explicitly declared is forbidden
1415
+ if name.rstrip("_") not in self.fields():
1416
+ raise HowlerKeyError(f"[{'.'.join(self.context)}]: {name}")
1417
+
1418
+ return super().__getattr__(name)
1419
+
1420
+ def __setattr__(self, name, value):
1421
+ # Any attribute that hasn't been explicitly declared is forbidden
1422
+ if self.__frozen and name.rstrip("_") not in self.fields():
1423
+ raise HowlerKeyError(f"[{'.'.join(self.context)}]: {name}")
1424
+ return object.__setattr__(self, name, value)
1425
+
1426
+ def __contains__(self, name):
1427
+ return name.rstrip("_") in self.fields()
1428
+
1429
+
1430
+ def recursive_set_name(field, name, to_parent=False):
1431
+ if not to_parent:
1432
+ field.name = name
1433
+ else:
1434
+ field.parent_name = name
1435
+
1436
+ if isinstance(field, Optional):
1437
+ recursive_set_name(field.child_type, name)
1438
+ if isinstance(field, List):
1439
+ recursive_set_name(field.child_type, name, to_parent=True)
1440
+
1441
+
1442
+ def model(index=None, store=None, description=None):
1443
+ """Decorator to create model objects."""
1444
+
1445
+ def _finish_model(cls):
1446
+ cls._Model__description = description
1447
+ for name, field_data in cls.fields().items():
1448
+ if not FIELD_SANITIZER.match(name) or name in BANNED_FIELDS:
1449
+ raise HowlerValueError(f"Illegal variable name: {name}")
1450
+
1451
+ recursive_set_name(field_data, name)
1452
+ field_data.apply_defaults(index=index, store=store)
1453
+ return cls
1454
+
1455
+ return _finish_model
1456
+
1457
+
1458
+ def _construct_field(field, value):
1459
+ if isinstance(field, List):
1460
+ clean, dropped = [], []
1461
+ for item in value:
1462
+ _c, _d = _construct_field(field.child_type, item)
1463
+ if _c is not None:
1464
+ clean.append(_c)
1465
+ if _d is not None and _d != "":
1466
+ dropped.append(_d)
1467
+ return clean or None, dropped or None
1468
+
1469
+ elif isinstance(field, Compound):
1470
+ _c, _d = construct_safe(field.child_type, value)
1471
+ if len(_d) == 0:
1472
+ _d = None
1473
+ return _c, _d
1474
+ elif isinstance(field, Optional):
1475
+ return _construct_field(field.child_type, value)
1476
+ else:
1477
+ try:
1478
+ return field.check(value), None
1479
+ except (ValueError, TypeError):
1480
+ return None, value
1481
+
1482
+
1483
+ def construct_safe(mod, data) -> tuple[_Any, dict]:
1484
+ if not isinstance(data, dict):
1485
+ return None, data
1486
+ fields = mod.fields()
1487
+ clean = {}
1488
+ dropped = {}
1489
+ for key, value in data.items():
1490
+ if key not in fields:
1491
+ dropped[key] = value
1492
+ continue
1493
+
1494
+ _c, _d = _construct_field(fields[key], value)
1495
+
1496
+ if _c is not None:
1497
+ clean[key] = _c
1498
+ if _d is not None:
1499
+ dropped[key] = _d
1500
+
1501
+ try:
1502
+ return mod(clean), dropped
1503
+ except ValueError:
1504
+ return None, recursive_update(dropped, clean)