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.
- howler/__init__.py +0 -0
- howler/actions/__init__.py +167 -0
- howler/actions/add_label.py +111 -0
- howler/actions/add_to_bundle.py +159 -0
- howler/actions/change_field.py +76 -0
- howler/actions/demote.py +160 -0
- howler/actions/example_plugin.py +104 -0
- howler/actions/prioritization.py +93 -0
- howler/actions/promote.py +147 -0
- howler/actions/remove_from_bundle.py +133 -0
- howler/actions/remove_label.py +111 -0
- howler/actions/transition.py +200 -0
- howler/api/__init__.py +249 -0
- howler/api/base.py +88 -0
- howler/api/socket.py +114 -0
- howler/api/v1/__init__.py +97 -0
- howler/api/v1/action.py +372 -0
- howler/api/v1/analytic.py +748 -0
- howler/api/v1/auth.py +382 -0
- howler/api/v1/borealis.py +101 -0
- howler/api/v1/configs.py +55 -0
- howler/api/v1/dossier.py +222 -0
- howler/api/v1/help.py +28 -0
- howler/api/v1/hit.py +1181 -0
- howler/api/v1/notebook.py +82 -0
- howler/api/v1/overview.py +191 -0
- howler/api/v1/search.py +715 -0
- howler/api/v1/template.py +206 -0
- howler/api/v1/tool.py +183 -0
- howler/api/v1/user.py +414 -0
- howler/api/v1/utils/__init__.py +0 -0
- howler/api/v1/utils/etag.py +84 -0
- howler/api/v1/view.py +288 -0
- howler/app.py +235 -0
- howler/common/README.md +144 -0
- howler/common/__init__.py +0 -0
- howler/common/classification.py +979 -0
- howler/common/classification.yml +107 -0
- howler/common/exceptions.py +167 -0
- howler/common/hexdump.py +48 -0
- howler/common/iprange.py +171 -0
- howler/common/loader.py +154 -0
- howler/common/logging/__init__.py +241 -0
- howler/common/logging/audit.py +138 -0
- howler/common/logging/format.py +38 -0
- howler/common/net.py +79 -0
- howler/common/net_static.py +1494 -0
- howler/common/random_user.py +316 -0
- howler/common/swagger.py +117 -0
- howler/config.py +64 -0
- howler/cronjobs/__init__.py +29 -0
- howler/cronjobs/retention.py +61 -0
- howler/cronjobs/rules.py +274 -0
- howler/cronjobs/view_cleanup.py +88 -0
- howler/datastore/README.md +112 -0
- howler/datastore/__init__.py +0 -0
- howler/datastore/bulk.py +72 -0
- howler/datastore/collection.py +2327 -0
- howler/datastore/constants.py +117 -0
- howler/datastore/exceptions.py +41 -0
- howler/datastore/howler_store.py +105 -0
- howler/datastore/migrations/fix_process.py +41 -0
- howler/datastore/operations.py +130 -0
- howler/datastore/schemas.py +90 -0
- howler/datastore/store.py +231 -0
- howler/datastore/support/__init__.py +0 -0
- howler/datastore/support/build.py +214 -0
- howler/datastore/support/schemas.py +90 -0
- howler/datastore/types.py +22 -0
- howler/error.py +91 -0
- howler/external/__init__.py +0 -0
- howler/external/generate_mitre.py +96 -0
- howler/external/generate_sigma_rules.py +31 -0
- howler/external/generate_tlds.py +47 -0
- howler/external/reindex_data.py +46 -0
- howler/external/wipe_databases.py +58 -0
- howler/gunicorn_config.py +25 -0
- howler/healthz.py +47 -0
- howler/helper/__init__.py +0 -0
- howler/helper/azure.py +50 -0
- howler/helper/discover.py +59 -0
- howler/helper/hit.py +236 -0
- howler/helper/oauth.py +247 -0
- howler/helper/search.py +92 -0
- howler/helper/workflow.py +110 -0
- howler/helper/ws.py +378 -0
- howler/odm/README.md +102 -0
- howler/odm/__init__.py +1 -0
- howler/odm/base.py +1504 -0
- howler/odm/charter.txt +146 -0
- howler/odm/helper.py +416 -0
- howler/odm/howler_enum.py +25 -0
- howler/odm/models/__init__.py +0 -0
- howler/odm/models/action.py +33 -0
- howler/odm/models/analytic.py +90 -0
- howler/odm/models/assemblyline.py +48 -0
- howler/odm/models/aws.py +23 -0
- howler/odm/models/azure.py +16 -0
- howler/odm/models/cbs.py +44 -0
- howler/odm/models/config.py +558 -0
- howler/odm/models/dossier.py +33 -0
- howler/odm/models/ecs/__init__.py +0 -0
- howler/odm/models/ecs/agent.py +17 -0
- howler/odm/models/ecs/autonomous_system.py +16 -0
- howler/odm/models/ecs/client.py +149 -0
- howler/odm/models/ecs/cloud.py +141 -0
- howler/odm/models/ecs/code_signature.py +27 -0
- howler/odm/models/ecs/container.py +32 -0
- howler/odm/models/ecs/dns.py +62 -0
- howler/odm/models/ecs/egress.py +10 -0
- howler/odm/models/ecs/elf.py +74 -0
- howler/odm/models/ecs/email.py +122 -0
- howler/odm/models/ecs/error.py +14 -0
- howler/odm/models/ecs/event.py +140 -0
- howler/odm/models/ecs/faas.py +24 -0
- howler/odm/models/ecs/file.py +84 -0
- howler/odm/models/ecs/geo.py +30 -0
- howler/odm/models/ecs/group.py +18 -0
- howler/odm/models/ecs/hash.py +16 -0
- howler/odm/models/ecs/host.py +17 -0
- howler/odm/models/ecs/http.py +37 -0
- howler/odm/models/ecs/ingress.py +12 -0
- howler/odm/models/ecs/interface.py +21 -0
- howler/odm/models/ecs/network.py +30 -0
- howler/odm/models/ecs/observer.py +45 -0
- howler/odm/models/ecs/organization.py +12 -0
- howler/odm/models/ecs/os.py +21 -0
- howler/odm/models/ecs/pe.py +17 -0
- howler/odm/models/ecs/process.py +216 -0
- howler/odm/models/ecs/registry.py +26 -0
- howler/odm/models/ecs/related.py +45 -0
- howler/odm/models/ecs/rule.py +51 -0
- howler/odm/models/ecs/server.py +24 -0
- howler/odm/models/ecs/threat.py +247 -0
- howler/odm/models/ecs/tls.py +58 -0
- howler/odm/models/ecs/url.py +51 -0
- howler/odm/models/ecs/user.py +57 -0
- howler/odm/models/ecs/user_agent.py +20 -0
- howler/odm/models/ecs/vulnerability.py +41 -0
- howler/odm/models/gcp.py +16 -0
- howler/odm/models/hit.py +356 -0
- howler/odm/models/howler_data.py +328 -0
- howler/odm/models/lead.py +33 -0
- howler/odm/models/localized_label.py +13 -0
- howler/odm/models/overview.py +16 -0
- howler/odm/models/pivot.py +40 -0
- howler/odm/models/template.py +24 -0
- howler/odm/models/user.py +83 -0
- howler/odm/models/view.py +34 -0
- howler/odm/random_data.py +888 -0
- howler/odm/randomizer.py +606 -0
- howler/patched.py +5 -0
- howler/plugins/__init__.py +25 -0
- howler/plugins/config.py +123 -0
- howler/remote/__init__.py +0 -0
- howler/remote/datatypes/README.md +355 -0
- howler/remote/datatypes/__init__.py +98 -0
- howler/remote/datatypes/counters.py +63 -0
- howler/remote/datatypes/events.py +66 -0
- howler/remote/datatypes/hash.py +206 -0
- howler/remote/datatypes/lock.py +42 -0
- howler/remote/datatypes/queues/__init__.py +0 -0
- howler/remote/datatypes/queues/comms.py +59 -0
- howler/remote/datatypes/queues/multi.py +32 -0
- howler/remote/datatypes/queues/named.py +93 -0
- howler/remote/datatypes/queues/priority.py +215 -0
- howler/remote/datatypes/set.py +118 -0
- howler/remote/datatypes/user_quota_tracker.py +54 -0
- howler/security/__init__.py +253 -0
- howler/security/socket.py +108 -0
- howler/security/utils.py +185 -0
- howler/services/__init__.py +0 -0
- howler/services/action_service.py +111 -0
- howler/services/analytic_service.py +128 -0
- howler/services/auth_service.py +323 -0
- howler/services/config_service.py +128 -0
- howler/services/dossier_service.py +252 -0
- howler/services/event_service.py +93 -0
- howler/services/hit_service.py +893 -0
- howler/services/jwt_service.py +158 -0
- howler/services/lucene_service.py +286 -0
- howler/services/notebook_service.py +119 -0
- howler/services/overview_service.py +44 -0
- howler/services/template_service.py +45 -0
- howler/services/user_service.py +330 -0
- howler/utils/__init__.py +0 -0
- howler/utils/annotations.py +28 -0
- howler/utils/chunk.py +38 -0
- howler/utils/dict_utils.py +200 -0
- howler/utils/isotime.py +17 -0
- howler/utils/list_utils.py +11 -0
- howler/utils/lucene.py +77 -0
- howler/utils/path.py +27 -0
- howler/utils/socket_utils.py +61 -0
- howler/utils/str_utils.py +256 -0
- howler/utils/uid.py +47 -0
- howler_api-2.13.0.dev329.dist-info/METADATA +71 -0
- howler_api-2.13.0.dev329.dist-info/RECORD +200 -0
- howler_api-2.13.0.dev329.dist-info/WHEEL +4 -0
- 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)
|