howler-api 3.0.0.dev374__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 (198) hide show
  1. howler/__init__.py +0 -0
  2. howler/actions/__init__.py +168 -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/clue.py +99 -0
  21. howler/api/v1/configs.py +58 -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 +788 -0
  28. howler/api/v1/template.py +206 -0
  29. howler/api/v1/tool.py +183 -0
  30. howler/api/v1/user.py +416 -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 +125 -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/loader.py +154 -0
  41. howler/common/logging/__init__.py +241 -0
  42. howler/common/logging/audit.py +138 -0
  43. howler/common/logging/format.py +38 -0
  44. howler/common/net.py +79 -0
  45. howler/common/net_static.py +1494 -0
  46. howler/common/random_user.py +316 -0
  47. howler/common/swagger.py +117 -0
  48. howler/config.py +64 -0
  49. howler/cronjobs/__init__.py +29 -0
  50. howler/cronjobs/retention.py +61 -0
  51. howler/cronjobs/rules.py +274 -0
  52. howler/cronjobs/view_cleanup.py +88 -0
  53. howler/datastore/README.md +112 -0
  54. howler/datastore/__init__.py +0 -0
  55. howler/datastore/bulk.py +72 -0
  56. howler/datastore/collection.py +2342 -0
  57. howler/datastore/constants.py +119 -0
  58. howler/datastore/exceptions.py +41 -0
  59. howler/datastore/howler_store.py +105 -0
  60. howler/datastore/migrations/fix_process.py +41 -0
  61. howler/datastore/operations.py +130 -0
  62. howler/datastore/schemas.py +90 -0
  63. howler/datastore/store.py +231 -0
  64. howler/datastore/support/__init__.py +0 -0
  65. howler/datastore/support/build.py +215 -0
  66. howler/datastore/support/schemas.py +90 -0
  67. howler/datastore/types.py +22 -0
  68. howler/error.py +91 -0
  69. howler/external/__init__.py +0 -0
  70. howler/external/generate_mitre.py +96 -0
  71. howler/external/generate_sigma_rules.py +31 -0
  72. howler/external/generate_tlds.py +47 -0
  73. howler/external/reindex_data.py +66 -0
  74. howler/external/wipe_databases.py +58 -0
  75. howler/gunicorn_config.py +25 -0
  76. howler/healthz.py +47 -0
  77. howler/helper/__init__.py +0 -0
  78. howler/helper/azure.py +50 -0
  79. howler/helper/discover.py +59 -0
  80. howler/helper/hit.py +236 -0
  81. howler/helper/oauth.py +247 -0
  82. howler/helper/search.py +92 -0
  83. howler/helper/workflow.py +110 -0
  84. howler/helper/ws.py +378 -0
  85. howler/odm/README.md +102 -0
  86. howler/odm/__init__.py +1 -0
  87. howler/odm/base.py +1543 -0
  88. howler/odm/charter.txt +146 -0
  89. howler/odm/helper.py +416 -0
  90. howler/odm/howler_enum.py +25 -0
  91. howler/odm/models/__init__.py +0 -0
  92. howler/odm/models/action.py +33 -0
  93. howler/odm/models/analytic.py +90 -0
  94. howler/odm/models/assemblyline.py +48 -0
  95. howler/odm/models/aws.py +23 -0
  96. howler/odm/models/azure.py +16 -0
  97. howler/odm/models/cbs.py +44 -0
  98. howler/odm/models/config.py +558 -0
  99. howler/odm/models/dossier.py +33 -0
  100. howler/odm/models/ecs/__init__.py +0 -0
  101. howler/odm/models/ecs/agent.py +17 -0
  102. howler/odm/models/ecs/autonomous_system.py +16 -0
  103. howler/odm/models/ecs/client.py +149 -0
  104. howler/odm/models/ecs/cloud.py +141 -0
  105. howler/odm/models/ecs/code_signature.py +27 -0
  106. howler/odm/models/ecs/container.py +32 -0
  107. howler/odm/models/ecs/dns.py +62 -0
  108. howler/odm/models/ecs/egress.py +10 -0
  109. howler/odm/models/ecs/elf.py +74 -0
  110. howler/odm/models/ecs/email.py +122 -0
  111. howler/odm/models/ecs/error.py +14 -0
  112. howler/odm/models/ecs/event.py +140 -0
  113. howler/odm/models/ecs/faas.py +24 -0
  114. howler/odm/models/ecs/file.py +84 -0
  115. howler/odm/models/ecs/geo.py +30 -0
  116. howler/odm/models/ecs/group.py +18 -0
  117. howler/odm/models/ecs/hash.py +16 -0
  118. howler/odm/models/ecs/host.py +17 -0
  119. howler/odm/models/ecs/http.py +37 -0
  120. howler/odm/models/ecs/ingress.py +12 -0
  121. howler/odm/models/ecs/interface.py +21 -0
  122. howler/odm/models/ecs/network.py +30 -0
  123. howler/odm/models/ecs/observer.py +45 -0
  124. howler/odm/models/ecs/organization.py +12 -0
  125. howler/odm/models/ecs/os.py +21 -0
  126. howler/odm/models/ecs/pe.py +17 -0
  127. howler/odm/models/ecs/process.py +216 -0
  128. howler/odm/models/ecs/registry.py +26 -0
  129. howler/odm/models/ecs/related.py +45 -0
  130. howler/odm/models/ecs/rule.py +51 -0
  131. howler/odm/models/ecs/server.py +24 -0
  132. howler/odm/models/ecs/threat.py +247 -0
  133. howler/odm/models/ecs/tls.py +58 -0
  134. howler/odm/models/ecs/url.py +51 -0
  135. howler/odm/models/ecs/user.py +57 -0
  136. howler/odm/models/ecs/user_agent.py +20 -0
  137. howler/odm/models/ecs/vulnerability.py +41 -0
  138. howler/odm/models/gcp.py +16 -0
  139. howler/odm/models/hit.py +356 -0
  140. howler/odm/models/howler_data.py +328 -0
  141. howler/odm/models/lead.py +24 -0
  142. howler/odm/models/localized_label.py +13 -0
  143. howler/odm/models/overview.py +16 -0
  144. howler/odm/models/pivot.py +40 -0
  145. howler/odm/models/template.py +24 -0
  146. howler/odm/models/user.py +83 -0
  147. howler/odm/models/view.py +34 -0
  148. howler/odm/random_data.py +888 -0
  149. howler/odm/randomizer.py +609 -0
  150. howler/patched.py +5 -0
  151. howler/plugins/__init__.py +25 -0
  152. howler/plugins/config.py +123 -0
  153. howler/remote/__init__.py +0 -0
  154. howler/remote/datatypes/README.md +355 -0
  155. howler/remote/datatypes/__init__.py +98 -0
  156. howler/remote/datatypes/counters.py +63 -0
  157. howler/remote/datatypes/events.py +66 -0
  158. howler/remote/datatypes/hash.py +206 -0
  159. howler/remote/datatypes/lock.py +42 -0
  160. howler/remote/datatypes/queues/__init__.py +0 -0
  161. howler/remote/datatypes/queues/comms.py +59 -0
  162. howler/remote/datatypes/queues/multi.py +32 -0
  163. howler/remote/datatypes/queues/named.py +93 -0
  164. howler/remote/datatypes/queues/priority.py +215 -0
  165. howler/remote/datatypes/set.py +118 -0
  166. howler/remote/datatypes/user_quota_tracker.py +54 -0
  167. howler/security/__init__.py +253 -0
  168. howler/security/socket.py +108 -0
  169. howler/security/utils.py +185 -0
  170. howler/services/__init__.py +0 -0
  171. howler/services/action_service.py +111 -0
  172. howler/services/analytic_service.py +128 -0
  173. howler/services/auth_service.py +323 -0
  174. howler/services/config_service.py +128 -0
  175. howler/services/dossier_service.py +252 -0
  176. howler/services/event_service.py +93 -0
  177. howler/services/hit_service.py +893 -0
  178. howler/services/jwt_service.py +158 -0
  179. howler/services/lucene_service.py +286 -0
  180. howler/services/notebook_service.py +119 -0
  181. howler/services/overview_service.py +44 -0
  182. howler/services/template_service.py +45 -0
  183. howler/services/user_service.py +331 -0
  184. howler/utils/__init__.py +0 -0
  185. howler/utils/annotations.py +28 -0
  186. howler/utils/chunk.py +38 -0
  187. howler/utils/dict_utils.py +200 -0
  188. howler/utils/isotime.py +17 -0
  189. howler/utils/list_utils.py +11 -0
  190. howler/utils/lucene.py +77 -0
  191. howler/utils/path.py +27 -0
  192. howler/utils/socket_utils.py +61 -0
  193. howler/utils/str_utils.py +256 -0
  194. howler/utils/uid.py +47 -0
  195. howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
  196. howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
  197. howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
  198. howler_api-3.0.0.dev374.dist-info/entry_points.txt +8 -0
@@ -0,0 +1,331 @@
1
+ from typing import Any, Optional, Union
2
+
3
+ import elasticapm
4
+ from authlib.integrations.flask_client import OAuth
5
+ from flask import current_app, request
6
+
7
+ from howler.common.exceptions import AccessDeniedException, HowlerValueError, InvalidDataException
8
+ from howler.common.loader import datastore
9
+ from howler.common.logging import get_logger
10
+ from howler.config import CLASSIFICATION, config
11
+ from howler.helper.oauth import fetch_avatar, parse_profile
12
+ from howler.odm.models.user import User
13
+ from howler.odm.models.view import View
14
+ from howler.utils.str_utils import safe_str
15
+
16
+ ACCOUNT_USER_MODIFIABLE = ["name", "email", "avatar", "password", "dashboard"]
17
+
18
+ logger = get_logger(__file__)
19
+
20
+
21
+ def get_user(
22
+ id: str,
23
+ as_odm: bool = False,
24
+ version: bool = False,
25
+ ) -> Union[User, dict[str, Any]]:
26
+ """Return hit object as either an ODM or Dict"""
27
+ return datastore().user.get_if_exists(key=id, as_obj=as_odm, version=version)
28
+
29
+
30
+ def convert_user(user: User) -> dict[str, Any]:
31
+ """Converts a User ODM into a dict for frontend usage, stripping out private or irrelevant fields.
32
+
33
+ Args:
34
+ user (User): The user object to parse
35
+
36
+ Returns:
37
+ dict: The parsed user data object
38
+ """
39
+ user_data = {
40
+ k: v
41
+ for k, v in user.as_primitives().items()
42
+ if k
43
+ in [
44
+ "classification",
45
+ "email",
46
+ "groups",
47
+ "is_active",
48
+ "name",
49
+ "type",
50
+ "uname",
51
+ "api_quota",
52
+ "favourite_views",
53
+ "favourite_analytics",
54
+ "dashboard",
55
+ ]
56
+ }
57
+
58
+ user_data["apikeys"] = [
59
+ (key, value["acl"], value["expiry_date"])
60
+ for key, value in datastore().user.get_if_exists(user["uname"]).apikeys.items()
61
+ ]
62
+
63
+ user_data["avatar"] = datastore().user_avatar.get_if_exists(user["uname"])
64
+ user_data["username"] = user_data.pop("uname")
65
+ user_data["is_admin"] = "admin" in user_data["type"]
66
+ user_data["roles"] = list(set(user_data.pop("type")))
67
+
68
+ return user_data
69
+
70
+
71
+ @elasticapm.capture_span(span_type="authentication")
72
+ def parse_user_data( # noqa: C901
73
+ data: dict,
74
+ oauth_provider: str,
75
+ skip_setup: bool = True,
76
+ access_token: Optional[str] = None,
77
+ ) -> User:
78
+ """Convert a JSON Web Token into a Howler User.
79
+
80
+ Args:
81
+ data (dict): The JWT to parse
82
+ oauth_provider (str): The provider of the JWT
83
+ skip_setup (bool, optional): Skip the extra setup steps we run at login, for performance reasons.
84
+ Defaults to True.
85
+ access_token (str, optional): The access token to use when fetching the user's avatar. Defaults to None.
86
+
87
+ Raises:
88
+ InvalidDataException: Some required data was missing.
89
+ AccessDeniedException: The user is not permitted to access the application, or user auto-creation is disabled
90
+ and the user doesn't exist in the database.
91
+
92
+ Returns:
93
+ User: The parsed User ODM
94
+ """
95
+ if not data or not oauth_provider:
96
+ raise InvalidDataException("Both the JWT and OAuth provider must be supplied")
97
+
98
+ oauth = current_app.extensions.get("authlib.integrations.flask_client")
99
+ if not oauth: # pragma: no cover
100
+ logger.critical("Authlib integration missing!")
101
+ raise HowlerValueError("Authlib integration missing!")
102
+
103
+ provider: OAuth = oauth.create_client(oauth_provider)
104
+
105
+ if "id_token" in data:
106
+ data = provider.parse_id_token(
107
+ data, nonce=request.args.get("nonce", data.get("userinfo", {}).get("nonce", None))
108
+ )
109
+
110
+ oauth_provider_config = config.auth.oauth.providers[oauth_provider]
111
+
112
+ if not data and oauth_provider_config.user_get:
113
+ response = provider.get(oauth_provider_config.user_get)
114
+ if response.ok:
115
+ data = response.json()
116
+
117
+ user_data = parse_profile(data, oauth_provider_config)
118
+
119
+ if len(oauth_provider_config.required_groups) > 0:
120
+ required_groups = set(oauth_provider_config.required_groups)
121
+ if len(required_groups) != len(required_groups & set(user_data["groups"])):
122
+ logger.warning(
123
+ f"User {user_data['uname']} is missing groups from their JWT:"
124
+ f" {', '.join(required_groups - (required_groups & set(user_data['groups'])))}"
125
+ )
126
+ raise AccessDeniedException("This user is not allowed access to the system")
127
+
128
+ has_access = user_data.pop("access", False)
129
+ storage = datastore()
130
+ current_user: Optional[dict[str, Any]] = None
131
+ if has_access and user_data["email"] is not None:
132
+ # Find if user already exists
133
+ users: list[dict[str, Any]] = storage.user.search(f"email:{user_data['email']}", fl="*", as_obj=False)["items"]
134
+
135
+ if users:
136
+ current_user = users[0]
137
+ # Do not update username and password from the current user
138
+ user_data["uname"] = current_user.get("uname", user_data["uname"])
139
+ user_data.pop("password", None)
140
+ else:
141
+ if user_data["uname"] != user_data["email"]:
142
+ # Username was computed using a regular expression, lets make sure we don't
143
+ # assign the same username to two users
144
+ exists = storage.user.exists(user_data["uname"])
145
+ if exists:
146
+ count = 1
147
+ new_uname = f"{user_data['uname']}{count}"
148
+ while storage.user.exists(new_uname):
149
+ count += 1
150
+ new_uname = f"{user_data['uname']}{count}"
151
+ user_data["uname"] = new_uname
152
+ current_user = {}
153
+
154
+ username = user_data["uname"]
155
+
156
+ # Add add dynamic classification group
157
+ user_data["classification"] = get_dynamic_classification(user_data["classification"], user_data["email"])
158
+
159
+ # Make sure the user exists in howler and is in sync
160
+ if (not current_user and oauth_provider_config.auto_create) or (
161
+ current_user and oauth_provider_config.auto_sync
162
+ ):
163
+ old_user = {**current_user}
164
+ old_user.pop("id", None)
165
+ old_user.pop("avatar", None)
166
+
167
+ # Update the current user
168
+ current_user.update(user_data)
169
+
170
+ user_id = current_user.pop("id", None)
171
+ avatar = current_user.pop("avatar", None)
172
+
173
+ # Save updated user if there are changes to sync or it doesn't exist
174
+ if old_user != current_user:
175
+ if user_id:
176
+ logger.info("Updating %s with new data", user_id if not isinstance(user_id, list) else user_id[0])
177
+ else:
178
+ logger.info("Creating new user %s", username)
179
+
180
+ if user_id:
181
+ current_user["id"] = user_id
182
+
183
+ if avatar:
184
+ current_user["avatar"] = avatar
185
+
186
+ storage.user.save(username, current_user)
187
+ storage.user.commit()
188
+
189
+ if not skip_setup:
190
+ if avatar:
191
+ logger.info("Updating avatar for %s", username)
192
+
193
+ avatar = fetch_avatar(
194
+ avatar,
195
+ provider,
196
+ oauth_provider,
197
+ access_token=access_token,
198
+ )
199
+
200
+ if avatar:
201
+ storage.user_avatar.save(username, avatar)
202
+
203
+ view_query = f"owner:{current_user['uname']} AND title:view.assigned_to_me AND type:readonly"
204
+ if len(storage.view.search(view_query)["items"]) == 0:
205
+ new_assigned_view = View(
206
+ {
207
+ "title": "view.assigned_to_me",
208
+ "query": f"howler.assignment:{current_user['uname']}",
209
+ "type": "readonly",
210
+ "owner": current_user["uname"],
211
+ }
212
+ )
213
+
214
+ current_user["favourite_views"] = [
215
+ *current_user.get("favourite_views", []),
216
+ new_assigned_view.view_id,
217
+ ]
218
+
219
+ storage.view.save(new_assigned_view.view_id, new_assigned_view)
220
+ storage.user.save(username, current_user)
221
+ storage.user.commit()
222
+
223
+ if not current_user:
224
+ raise AccessDeniedException("User auto-creation is disabled")
225
+ else:
226
+ raise AccessDeniedException("This user is not allowed access to the system")
227
+
228
+ return User(current_user)
229
+
230
+
231
+ def add_access_control(user: dict[str, Any]):
232
+ """Add access control to the specified user.
233
+
234
+ Args:
235
+ user (dict[str, Any]): The user to add access control information to.
236
+ """
237
+ user.update(
238
+ CLASSIFICATION.get_access_control_parts(
239
+ user.get("classification", CLASSIFICATION.UNRESTRICTED),
240
+ user_classification=True,
241
+ )
242
+ )
243
+
244
+ gl2_query = " OR ".join(
245
+ [
246
+ "__access_grp2__:__EMPTY__",
247
+ *[f'__access_grp2__:"{x}"' for x in user["__access_grp2__"]],
248
+ ],
249
+ )
250
+ gl2_query = f"({gl2_query}) AND "
251
+
252
+ gl1_query = " OR ".join(
253
+ [
254
+ "__access_grp1__:__EMPTY__",
255
+ *[f'__access_grp1__:"{x}"' for x in user["__access_grp1__"]],
256
+ ],
257
+ )
258
+ gl1_query = f"({gl1_query}) AND "
259
+
260
+ req = list(set(CLASSIFICATION.get_access_control_req()).difference(set(user["__access_req__"])))
261
+ req_query = " OR ".join([f'__access_req__:"{r}"' for r in req])
262
+ if req_query:
263
+ req_query = f"-({req_query}) AND "
264
+
265
+ lvl_query = f'__access_lvl__:[0 TO {user["__access_lvl__"]}]'
266
+
267
+ query = f"{gl2_query}{gl1_query}{req_query}{lvl_query}"
268
+ user["access_control"] = safe_str(query)
269
+
270
+
271
+ def save_user_account(username: str, data: dict[str, Any], user: dict[str, Any]) -> bool:
272
+ """Create or update a user in the database
273
+
274
+ Args:
275
+ username (str): The username to create or update the user under
276
+ data (dict[str, Any]): The user's data
277
+ user (dict[str, Any]): The account that is creating this new user
278
+
279
+ Raises:
280
+ AccessDeniedException: Parts of the user data is overwriting fields that cannot be changed.
281
+ InvalidDataException: The username in question doesn't match any existing users
282
+
283
+ Returns:
284
+ bool: If the save operation was successful
285
+ """
286
+ # Clear non user account data
287
+ avatar = data.pop("avatar", None)
288
+ data.pop("security_token_enabled", None)
289
+ data.pop("has_password", None)
290
+
291
+ data = User(data).as_primitives()
292
+
293
+ if username != data["uname"]:
294
+ raise AccessDeniedException("You are not allowed to change the username.")
295
+
296
+ if username != user["uname"] and "admin" not in user["type"]:
297
+ raise AccessDeniedException("You are not allowed to change another user than yourself.")
298
+
299
+ storage = datastore()
300
+ current = storage.user.get_if_exists(username, as_obj=False)
301
+ if current:
302
+ if "admin" not in user["type"]:
303
+ for key in current.keys():
304
+ if data[key] != current[key] and key not in ACCOUNT_USER_MODIFIABLE:
305
+ raise AccessDeniedException(f"Only Administrators can change the value of the field [{key}].")
306
+ else:
307
+ raise InvalidDataException(f"You cannot save a user that does not exists [{username}].")
308
+
309
+ if avatar == "DELETE":
310
+ storage.user_avatar.delete(username)
311
+ elif avatar is not None:
312
+ storage.user_avatar.save(username, avatar)
313
+
314
+ return storage.user.save(username, data)
315
+
316
+
317
+ def get_dynamic_classification(current_c12n: str | None, email: str) -> str | None:
318
+ """Get the classification of the user
319
+
320
+ Args:
321
+ current_c12n (str): The current classification of the user
322
+ email (str): The user's email
323
+
324
+ Returns:
325
+ str: The classification
326
+ """
327
+ if CLASSIFICATION.dynamic_groups and email:
328
+ dyn_group = email.upper().split("@")[1]
329
+ return CLASSIFICATION.build_user_classification(current_c12n, f"{CLASSIFICATION.UNRESTRICTED}//{dyn_group}")
330
+
331
+ return current_c12n
File without changes
@@ -0,0 +1,28 @@
1
+ def docstring_parameters(**kwargs: dict[str, str]): # pragma: no cover
2
+ """Substitute variables in docstring.
3
+
4
+ This annotation modifies the docstring of an objects to insure that Howler's dynamic api documentation is
5
+ always up to date.
6
+
7
+ Args:
8
+ substitutions (dict[str, Any]): Dictionary of substitutions
9
+ **args (str): Individual substitutions
10
+
11
+ Returns:
12
+ None: This annotation directly modifies an object's docstring
13
+
14
+ Examples:
15
+ @docstring_parameters({cake="Black Forest", topping="Cherry"})\n
16
+ def bake():\n
17
+ '''Bake a cake of flavour $(cake) with topping $(topping)'''\n
18
+
19
+ @docstring_parameters(danger="low")\n
20
+ def assess():\n
21
+ '''This docstring's danger level is $(danger)'''
22
+ """
23
+
24
+ def dec(obj):
25
+ obj.__doc__ = obj.__doc__ % ({**kwargs})
26
+ return obj
27
+
28
+ return dec
howler/utils/chunk.py ADDED
@@ -0,0 +1,38 @@
1
+ """Sequence manipulation methods used in parsing raw datastore output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Generator, Sequence, TypeVar, overload
6
+
7
+ _T = TypeVar("_T")
8
+
9
+
10
+ @overload
11
+ def chunk(items: bytes, n: int) -> Generator[bytes, None, None]: ...
12
+
13
+
14
+ @overload
15
+ def chunk(items: str, n: int) -> Generator[str, None, None]: ...
16
+
17
+
18
+ @overload
19
+ def chunk(items: Sequence[_T], n: int) -> Generator[Sequence[_T], None, None]: ...
20
+
21
+
22
+ def chunk(items, n: int):
23
+ """Yield n-sized chunks from list.
24
+
25
+ >>> list(chunk([1,2,3,4,5,6,7], 2))
26
+ [[1,2], [3,4], [5,6], [7,]]
27
+ """
28
+ for i in range(0, len(items), n):
29
+ yield items[i : i + n]
30
+
31
+
32
+ def chunked_list(items: Sequence[_T], n: int) -> list[Sequence[_T]]:
33
+ """Create a list of n-sized chunks from list.
34
+
35
+ >>> chunked_list([1,2,3,4,5,6,7], 2)
36
+ [[1,2], [3,4], [5,6], [7,]]
37
+ """
38
+ return list(chunk(items, n))
@@ -0,0 +1,200 @@
1
+ from collections.abc import Mapping
2
+ from typing import TYPE_CHECKING, Any, AnyStr, Optional, cast
3
+ from typing import Mapping as _Mapping
4
+
5
+ if TYPE_CHECKING:
6
+ from howler.odm.base import Model, _Field
7
+
8
+
9
+ def strip_nulls(d: Any):
10
+ """Remove null values from a dict"""
11
+ if isinstance(d, dict):
12
+ return {k: strip_nulls(v) for k, v in d.items() if v is not None}
13
+ else:
14
+ return d
15
+
16
+
17
+ def recursive_update(
18
+ d: Optional[dict[str, Any]],
19
+ u: Optional[_Mapping[str, Any]],
20
+ stop_keys: list[AnyStr] = [],
21
+ allow_recursion: bool = True,
22
+ ) -> dict[str, Any]:
23
+ "Recursively update a dict with another value"
24
+ if d is None:
25
+ return cast(dict, u or {})
26
+
27
+ if u is None:
28
+ return d
29
+
30
+ for k, v in u.items():
31
+ if isinstance(v, Mapping) and allow_recursion:
32
+ d[k] = recursive_update(d.get(k, {}), v, stop_keys=stop_keys, allow_recursion=k not in stop_keys)
33
+ else:
34
+ d[k] = v
35
+
36
+ return d
37
+
38
+
39
+ def get_recursive_delta(
40
+ d1: Optional[_Mapping[str, Any]],
41
+ d2: Optional[_Mapping[str, Any]],
42
+ stop_keys: list[AnyStr] = [],
43
+ allow_recursion: bool = True,
44
+ ) -> Optional[dict[str, Any]]:
45
+ "Get the recursive difference between two objects"
46
+ if d1 is None:
47
+ return cast(dict, d2)
48
+
49
+ if d2 is None:
50
+ return cast(dict, d1)
51
+
52
+ out = {}
53
+ for k1, v1 in d1.items():
54
+ if isinstance(v1, Mapping) and allow_recursion:
55
+ internal = get_recursive_delta(
56
+ v1,
57
+ d2.get(k1, {}),
58
+ stop_keys=stop_keys,
59
+ allow_recursion=k1 not in stop_keys,
60
+ )
61
+ if internal:
62
+ out[k1] = internal
63
+ else:
64
+ if k1 in d2:
65
+ v2 = d2[k1]
66
+ if v1 != v2:
67
+ out[k1] = v2
68
+
69
+ for k2, v2 in d2.items():
70
+ if k2 not in d1:
71
+ out[k2] = v2
72
+
73
+ return out
74
+
75
+
76
+ def flatten(data: _Mapping, parent_key: Optional[str] = None, odm: Optional[type["Model"]] = None) -> dict[str, Any]:
77
+ "Flatten a nested dict"
78
+ items: list[tuple[str, Any]] = []
79
+ for k, v in data.items():
80
+ cur_key = f"{parent_key}.{k}" if parent_key is not None else k
81
+
82
+ if isinstance(v, dict):
83
+ if odm:
84
+ valid_keys = list(odm.flat_fields().keys())
85
+ if not next((key for key in valid_keys if key.startswith(f"{cur_key}.")), False):
86
+ items.append((cur_key, v))
87
+ continue
88
+
89
+ items.extend(flatten(v, cur_key, odm=odm).items())
90
+ else:
91
+ items.append((cur_key, v))
92
+
93
+ return dict(items)
94
+
95
+
96
+ def flatten_deep(data: _Mapping):
97
+ "Aggressively and completely flatten an object."
98
+ partially_flattened = flatten(data)
99
+
100
+ final: dict[str, Any] = {}
101
+ for key, value in partially_flattened.items():
102
+ if not isinstance(value, list) or len(value) == 0 or all(not isinstance(entry, dict) for entry in value):
103
+ final[key] = value
104
+ else:
105
+ for entry in value:
106
+ flat_value = flatten_deep(entry)
107
+ for child_key, child_value in flat_value.items():
108
+ full_key = f"{key}.{child_key}"
109
+ if full_key not in final:
110
+ if isinstance(child_value, list):
111
+ final[full_key] = child_value
112
+ else:
113
+ final[full_key] = [child_value]
114
+ else:
115
+ if isinstance(child_value, list):
116
+ final[full_key].extend(child_value)
117
+ else:
118
+ final[full_key].append(child_value)
119
+
120
+ return final
121
+
122
+
123
+ def unflatten(data: _Mapping) -> _Mapping:
124
+ "Unflatten a nested dict"
125
+ out: dict[str, Any] = dict()
126
+ for k, v in data.items():
127
+ parts = k.split(".")
128
+ d = out
129
+ for p in parts[:-1]:
130
+ if p not in d:
131
+ d[p] = dict()
132
+ d = d[p]
133
+ d[parts[-1]] = v
134
+ return out
135
+
136
+
137
+ def extra_keys(odm: type["Model"], data: _Mapping) -> set[str]:
138
+ "Geta list of extra keys when compared to a list of permitted keys"
139
+ from howler.odm.base import Mapping, Optional
140
+
141
+ data = flatten_deep(data)
142
+
143
+ result: set[str] = set()
144
+ for key in data.keys():
145
+ parts = key.split(".")
146
+ current_odm = odm
147
+ for part in parts:
148
+ sub_fields: dict[str, Any] = current_odm.fields()
149
+
150
+ if part in sub_fields:
151
+ current_odm = sub_fields[part]
152
+ else:
153
+ if isinstance(current_odm, Optional):
154
+ current_odm = current_odm.child_type
155
+
156
+ if isinstance(current_odm, Mapping):
157
+ current_odm = current_odm.child_type
158
+ else:
159
+ result.add(key)
160
+ break
161
+
162
+ return result
163
+
164
+
165
+ def prune( # noqa: C901
166
+ data: _Mapping, keys: list[str], fields: dict[str, "_Field"], mapping_class: type, parent_key: Optional[str] = None
167
+ ) -> dict[str, Any]:
168
+ "Remove all keys in the given list from the dict if they exist"
169
+ pruned_items: list[tuple[str, Any]] = []
170
+
171
+ for key, val in data.items():
172
+ cur_key = f"{parent_key}.{key}" if parent_key else key
173
+
174
+ # If this key is a mapping, preserve all children
175
+ if isinstance(fields.get(cur_key, None), mapping_class):
176
+ pruned_items.append((key, val))
177
+ elif isinstance(val, dict):
178
+ child_keys = [_key for _key in keys if _key.startswith(cur_key)]
179
+
180
+ if len(child_keys) > 0:
181
+ pruned_items.append((key, prune(val, child_keys, fields, mapping_class, cur_key)))
182
+ elif isinstance(val, list):
183
+ if cur_key not in keys and not any(_key.startswith(cur_key) for _key in keys):
184
+ continue
185
+
186
+ list_result = []
187
+ for entry in val:
188
+ if isinstance(val, dict):
189
+ child_keys = [_key for _key in keys if _key.startswith(cur_key)]
190
+
191
+ if len(child_keys) > 0:
192
+ pruned_items.append((key, prune(val, child_keys, fields, mapping_class, cur_key)))
193
+ else:
194
+ list_result.append(entry)
195
+
196
+ pruned_items.append((key, list_result))
197
+ elif cur_key in keys:
198
+ pruned_items.append((key, val))
199
+
200
+ return {k: v for k, v in pruned_items}
@@ -0,0 +1,17 @@
1
+ import sys
2
+ from datetime import datetime
3
+
4
+ # DO NOT REMOVE!!! THIS IS MAGIC!
5
+ # strptime Thread safe fix... yeah ...
6
+ datetime.strptime("2000", "%Y")
7
+ # END OF MAGIC
8
+
9
+
10
+ def now_as_iso() -> str:
11
+ """Get the current time as an ISO formatted string"""
12
+ if sys.version_info.minor < 11:
13
+ return f"{datetime.utcnow().isoformat()}Z"
14
+ else:
15
+ from datetime import UTC
16
+
17
+ return datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,11 @@
1
+ from typing import Any
2
+
3
+
4
+ def flatten_list(_list: list[list[Any]]):
5
+ "Flatten a nested list"
6
+ flat_list = []
7
+ for sublist in _list:
8
+ for item in sublist:
9
+ flat_list.append(item)
10
+
11
+ return flat_list