howler-client 2.4.0.dev242__tar.gz → 2.4.0.dev286__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/PKG-INFO +1 -1
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/client.py +2 -2
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/common/utils.py +15 -0
- howler_client-2.4.0.dev286/howler_client/module/v2/__init__.py +30 -0
- howler_client-2.4.0.dev286/howler_client/module/v2/case.py +177 -0
- howler_client-2.4.0.dev286/howler_client/module/v2/ingest.py +160 -0
- howler_client-2.4.0.dev286/howler_client/module/v2/search.py +153 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/pyproject.toml +1 -1
- howler_client-2.4.0.dev242/howler_client/module/bundle.py +0 -132
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/LICENSE +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/README.md +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/__init__.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/common/__init__.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/common/dict_utils.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/connection.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/logger.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/__init__.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/comment.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/help.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/hit.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/__init__.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/chunk.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/facet.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/fields.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/grouped.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/histogram.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/stats.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/stream.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/user.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/utils/__init__.py +0 -0
- {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/utils/json_encoders.py +0 -0
|
@@ -2,11 +2,11 @@ import sys
|
|
|
2
2
|
|
|
3
3
|
from howler_client.common.utils import walk_api_path
|
|
4
4
|
from howler_client.connection import Connection
|
|
5
|
-
from howler_client.module.bundle import Bundle
|
|
6
5
|
from howler_client.module.help import Help
|
|
7
6
|
from howler_client.module.hit import Hit
|
|
8
7
|
from howler_client.module.search import Search
|
|
9
8
|
from howler_client.module.user import User
|
|
9
|
+
from howler_client.module.v2 import V2
|
|
10
10
|
|
|
11
11
|
if sys.version_info >= (3, 11):
|
|
12
12
|
from typing import Self
|
|
@@ -23,8 +23,8 @@ class Client(object):
|
|
|
23
23
|
self.help = Help(self._connection)
|
|
24
24
|
self.search = Search(self._connection)
|
|
25
25
|
self.hit = Hit(self._connection, self.search)
|
|
26
|
-
self.bundle = Bundle(self._connection, self.hit)
|
|
27
26
|
self.user = User(self._connection)
|
|
27
|
+
self.v2 = V2(self._connection)
|
|
28
28
|
|
|
29
29
|
paths: list[str] = []
|
|
30
30
|
walk_api_path(self, [""], paths)
|
|
@@ -61,6 +61,21 @@ def api_path(prefix, *args, **kw):
|
|
|
61
61
|
return f"{path}?{params}"
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
def api_path_v2(prefix, *args, **kw):
|
|
65
|
+
"""Calculate the API path for v2 endpoints as shown:
|
|
66
|
+
|
|
67
|
+
/api/v2/<prefix>/[arg1/[arg2/[...]]][?k1=v1[...]]
|
|
68
|
+
"""
|
|
69
|
+
path = "/".join(["api", "v2", prefix] + list(args))
|
|
70
|
+
|
|
71
|
+
params_tuples = kw.pop("params_tuples", [])
|
|
72
|
+
params = "&".join([_join_kw(kw)] + [_join_param(*e) for e in params_tuples if _param_ok(e)])
|
|
73
|
+
if not params:
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
return f"{path}?{params}"
|
|
77
|
+
|
|
78
|
+
|
|
64
79
|
def stream_output(output):
|
|
65
80
|
"Stream the output of a response"
|
|
66
81
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""V2 API module container.
|
|
2
|
+
|
|
3
|
+
Provides access to v2-only endpoints: ``case``, ``ingest``, and ``search``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from howler_client.module.v2.case import Case
|
|
10
|
+
from howler_client.module.v2.ingest import Ingest
|
|
11
|
+
from howler_client.module.v2.search import SearchV2
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 11):
|
|
14
|
+
from typing import Self
|
|
15
|
+
else:
|
|
16
|
+
from typing_extensions import Self
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from howler_client import Connection
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class V2(object):
|
|
23
|
+
"""Access v2 API endpoints."""
|
|
24
|
+
|
|
25
|
+
def __init__(self: Self, connection: "Connection"):
|
|
26
|
+
self._connection = connection
|
|
27
|
+
|
|
28
|
+
self.case = Case(connection)
|
|
29
|
+
self.ingest = Ingest(connection)
|
|
30
|
+
self.search = SearchV2(connection)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Client module for the v2 Case API."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
5
|
+
|
|
6
|
+
from howler_client.common.utils import api_path_v2
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
from typing import Self
|
|
10
|
+
else:
|
|
11
|
+
from typing_extensions import Self
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from howler_client import Connection
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Case(object):
|
|
18
|
+
"""Operations for managing cases via the v2 API."""
|
|
19
|
+
|
|
20
|
+
def __init__(self: Self, connection: "Connection"):
|
|
21
|
+
self._connection = connection
|
|
22
|
+
|
|
23
|
+
def __call__(self: Self, case_id: str) -> dict[str, Any]:
|
|
24
|
+
"""Return a case by ID.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
case_id: Unique identifier of the case.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The case data.
|
|
31
|
+
"""
|
|
32
|
+
return self._connection.get(api_path_v2("case", case_id))
|
|
33
|
+
|
|
34
|
+
def create(self: Self, case_data: dict[str, Any]) -> dict[str, Any]:
|
|
35
|
+
"""Create a new case.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
case_data: Dictionary with at least ``title`` and ``summary``.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The created case data.
|
|
42
|
+
"""
|
|
43
|
+
return self._connection.post(api_path_v2("case/"), json=case_data)
|
|
44
|
+
|
|
45
|
+
def update(self: Self, case_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
46
|
+
"""Update fields on an existing case.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
case_id: ID of the case to update.
|
|
50
|
+
updates: Dictionary of fields to update.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The updated case data.
|
|
54
|
+
"""
|
|
55
|
+
return self._connection.put(api_path_v2("case", case_id), json=updates)
|
|
56
|
+
|
|
57
|
+
def delete(self: Self, case_ids: list[str]) -> None:
|
|
58
|
+
"""Delete one or more cases.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
case_ids: List of case IDs to delete.
|
|
62
|
+
"""
|
|
63
|
+
return self._connection.delete(api_path_v2("case/"), json=case_ids)
|
|
64
|
+
|
|
65
|
+
def hide(self: Self, case_ids: list[str]) -> dict[Literal["success"], bool]:
|
|
66
|
+
"""Hide one or more cases.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
case_ids: List of case IDs to hide.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Whether the operation succeeded.
|
|
73
|
+
"""
|
|
74
|
+
return self._connection.post(api_path_v2("case/hide"), json=case_ids)
|
|
75
|
+
|
|
76
|
+
def append_item(
|
|
77
|
+
self: Self,
|
|
78
|
+
case_id: str,
|
|
79
|
+
item_type: str,
|
|
80
|
+
value: str,
|
|
81
|
+
path: str | None = None,
|
|
82
|
+
name: str | None = None,
|
|
83
|
+
parent: str | None = None,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""Append an item to a case.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
case_id: ID of the case.
|
|
89
|
+
item_type: Type of item (``hit``, ``event``, ``case``, ``table``, ``lead``, or ``reference``).
|
|
90
|
+
value: The ID or reference value for the item.
|
|
91
|
+
path: Optional parent folder path for the item.
|
|
92
|
+
name: Optional display name for the item. Defaults to ``value``.
|
|
93
|
+
parent: Optional parent folder item ID. If both ``path`` and ``parent``
|
|
94
|
+
are provided, the API resolves and applies ``path``.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The updated case data.
|
|
98
|
+
"""
|
|
99
|
+
payload: dict[str, Any] = {
|
|
100
|
+
"type": item_type,
|
|
101
|
+
"value": value,
|
|
102
|
+
"name": name or value,
|
|
103
|
+
}
|
|
104
|
+
if parent is not None:
|
|
105
|
+
payload["parent"] = parent
|
|
106
|
+
if path is not None:
|
|
107
|
+
payload["path"] = path
|
|
108
|
+
|
|
109
|
+
return self._connection.post(
|
|
110
|
+
api_path_v2("case", case_id, "items"),
|
|
111
|
+
json=payload,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def delete_items(self: Self, case_id: str, item_ids: list[str]) -> dict[str, Any]:
|
|
115
|
+
"""Remove items from a case.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
case_id: ID of the case.
|
|
119
|
+
item_ids: List of item IDs to remove.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The updated case data.
|
|
123
|
+
"""
|
|
124
|
+
return self._connection.delete(api_path_v2("case", case_id, "items"), json={"ids": item_ids})
|
|
125
|
+
|
|
126
|
+
def rename_item(self: Self, case_id: str, item_id: str, new_name: str) -> dict[str, Any]:
|
|
127
|
+
"""Rename an item within a case.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
case_id: ID of the case.
|
|
131
|
+
item_id: ID identifying the item to rename.
|
|
132
|
+
new_name: New display name for the item.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The updated case data.
|
|
136
|
+
"""
|
|
137
|
+
return self._connection.put(
|
|
138
|
+
api_path_v2("case", case_id, "items"),
|
|
139
|
+
json={"id": item_id, "name": new_name},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def add_rule(self: Self, case_id: str, rule_data: dict[str, Any]) -> dict[str, Any]:
|
|
143
|
+
"""Add a correlation rule to a case.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
case_id: ID of the case.
|
|
147
|
+
rule_data: Rule definition (must include ``query``, ``destination``, ``author``).
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The updated case data.
|
|
151
|
+
"""
|
|
152
|
+
return self._connection.post(api_path_v2("case", case_id, "rules"), json=rule_data)
|
|
153
|
+
|
|
154
|
+
def delete_rule(self: Self, case_id: str, rule_id: str) -> dict[str, Any]:
|
|
155
|
+
"""Delete a correlation rule from a case.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
case_id: ID of the case.
|
|
159
|
+
rule_id: ID of the rule to delete.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The updated case data.
|
|
163
|
+
"""
|
|
164
|
+
return self._connection.delete(api_path_v2("case", case_id, "rules", rule_id))
|
|
165
|
+
|
|
166
|
+
def update_rule(self: Self, case_id: str, rule_id: str, rule_data: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
"""Update a correlation rule on a case.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
case_id: ID of the case.
|
|
171
|
+
rule_id: ID of the rule to update.
|
|
172
|
+
rule_data: Updated rule fields.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The updated case data.
|
|
176
|
+
"""
|
|
177
|
+
return self._connection.put(api_path_v2("case", case_id, "rules", rule_id), json=rule_data)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Client module for the v2 Ingest API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Literal, Union
|
|
6
|
+
|
|
7
|
+
from howler_client.common.utils import ClientError, api_path_v2
|
|
8
|
+
|
|
9
|
+
if sys.version_info >= (3, 11):
|
|
10
|
+
from typing import Self
|
|
11
|
+
else:
|
|
12
|
+
from typing_extensions import Self
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from howler_client import Connection
|
|
16
|
+
|
|
17
|
+
UPDATE_OPERATIONS = [
|
|
18
|
+
"SET",
|
|
19
|
+
"INC",
|
|
20
|
+
"DEC",
|
|
21
|
+
"MAX",
|
|
22
|
+
"MIN",
|
|
23
|
+
"APPEND",
|
|
24
|
+
"APPEND_IF_MISSING",
|
|
25
|
+
"REMOVE",
|
|
26
|
+
"DELETE",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Ingest(object):
|
|
31
|
+
"""Operations for ingesting records via the v2 API.
|
|
32
|
+
|
|
33
|
+
Unlike the v1 ``hit`` module, v2 ingest supports multiple indexes
|
|
34
|
+
(``hit`` and ``event``) and enqueues new records for correlation
|
|
35
|
+
processing.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self: Self, connection: "Connection"):
|
|
39
|
+
self._connection = connection
|
|
40
|
+
|
|
41
|
+
def create(
|
|
42
|
+
self: Self,
|
|
43
|
+
index: str,
|
|
44
|
+
data: Union[dict[str, Any], list[dict[str, Any]]],
|
|
45
|
+
) -> list[str]:
|
|
46
|
+
"""Create one or more records in the given index.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
index: Target index (``hit`` or ``event``).
|
|
50
|
+
data: A single record dict or a list of record dicts.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List of created record IDs.
|
|
54
|
+
"""
|
|
55
|
+
if not isinstance(data, list):
|
|
56
|
+
data = [data]
|
|
57
|
+
|
|
58
|
+
return self._connection.post(
|
|
59
|
+
api_path_v2("ingest", index),
|
|
60
|
+
data=json.dumps(data),
|
|
61
|
+
headers={"Content-Type": "application/json"},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def delete(self: Self, indexes: str, ids: list[str]) -> dict[str, Any]:
|
|
65
|
+
"""Delete records across one or more indexes.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
indexes: Comma-separated index names (e.g. ``"hit"`` or ``"hit,event"``).
|
|
69
|
+
ids: List of record IDs to delete.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Dictionary with deletion results.
|
|
73
|
+
"""
|
|
74
|
+
return self._connection.delete(api_path_v2("ingest", indexes), json=ids)
|
|
75
|
+
|
|
76
|
+
def validate(
|
|
77
|
+
self: Self,
|
|
78
|
+
index: str,
|
|
79
|
+
data: Union[dict[str, Any], list[dict[str, Any]]],
|
|
80
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
81
|
+
"""Validate records against an index schema without persisting.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
index: Target index (``hit`` or ``event``).
|
|
85
|
+
data: A single record dict or a list of record dicts.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Dictionary with ``valid`` and ``invalid`` lists.
|
|
89
|
+
"""
|
|
90
|
+
if not isinstance(data, list):
|
|
91
|
+
data = [data]
|
|
92
|
+
|
|
93
|
+
return self._connection.post(
|
|
94
|
+
api_path_v2("ingest", index, "validate"),
|
|
95
|
+
data=json.dumps(data),
|
|
96
|
+
headers={"Content-Type": "application/json"},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def overwrite(
|
|
100
|
+
self: Self,
|
|
101
|
+
index: str,
|
|
102
|
+
record_id: str,
|
|
103
|
+
new_data: dict[str, Any],
|
|
104
|
+
replace: bool = False,
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
"""Overwrite (patch) a single record.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
index: Index of the record (e.g. ``"hit"``).
|
|
110
|
+
record_id: ID of the record to overwrite.
|
|
111
|
+
new_data: Partial record data to merge.
|
|
112
|
+
replace: If ``True``, lists are replaced instead of merged.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The updated record data.
|
|
116
|
+
"""
|
|
117
|
+
if not isinstance(new_data, dict):
|
|
118
|
+
raise TypeError("new_data must be a dict.")
|
|
119
|
+
|
|
120
|
+
return self._connection.request(
|
|
121
|
+
self._connection.session.patch,
|
|
122
|
+
api_path_v2("ingest", index, record_id, "overwrite", replace=replace if replace else None),
|
|
123
|
+
lambda resp: resp.json()["api_response"],
|
|
124
|
+
json=new_data,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def update_by_query(
|
|
128
|
+
self: Self,
|
|
129
|
+
indexes: str,
|
|
130
|
+
query: str,
|
|
131
|
+
operations: list[tuple[str, str, Any]],
|
|
132
|
+
) -> dict[Literal["success"], bool]:
|
|
133
|
+
"""Bulk update records matching a query.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
indexes: Comma-separated index names.
|
|
137
|
+
query: Lucene query selecting records to update.
|
|
138
|
+
operations: List of ``(operation, key, value)`` tuples.
|
|
139
|
+
Valid operations: SET, INC, DEC, MAX, MIN, APPEND,
|
|
140
|
+
APPEND_IF_MISSING, REMOVE, DELETE.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Whether the operation succeeded.
|
|
144
|
+
"""
|
|
145
|
+
if not isinstance(operations, list):
|
|
146
|
+
raise TypeError("operations must be a list.")
|
|
147
|
+
|
|
148
|
+
for op in operations:
|
|
149
|
+
if not isinstance(op, tuple):
|
|
150
|
+
raise TypeError("Each operation must be a tuple.")
|
|
151
|
+
if op[0] not in UPDATE_OPERATIONS:
|
|
152
|
+
raise ClientError(
|
|
153
|
+
f"Invalid operation '{op[0]}' — must be one of {', '.join(UPDATE_OPERATIONS)}",
|
|
154
|
+
400,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return self._connection.put(
|
|
158
|
+
api_path_v2("ingest", indexes, "update"),
|
|
159
|
+
json={"query": query, "operations": operations},
|
|
160
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Client module for the v2 Search API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
6
|
+
|
|
7
|
+
from howler_client.common.utils import api_path_v2
|
|
8
|
+
|
|
9
|
+
if sys.version_info >= (3, 11):
|
|
10
|
+
from typing import Self
|
|
11
|
+
else:
|
|
12
|
+
from typing_extensions import Self
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from howler_client import Connection
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SearchV2(object):
|
|
19
|
+
"""Search operations via the v2 API.
|
|
20
|
+
|
|
21
|
+
Unlike the v1 search module, v2 supports searching across multiple
|
|
22
|
+
comma-separated indexes in a single request.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self: Self, connection: "Connection"):
|
|
26
|
+
self._connection = connection
|
|
27
|
+
|
|
28
|
+
def __call__(
|
|
29
|
+
self: Self,
|
|
30
|
+
indexes: str,
|
|
31
|
+
query: str,
|
|
32
|
+
filters: Optional[list[str]] = None,
|
|
33
|
+
fl: Optional[str] = None,
|
|
34
|
+
offset: int = 0,
|
|
35
|
+
rows: int = 25,
|
|
36
|
+
sort: Optional[str] = None,
|
|
37
|
+
timeout: Optional[int] = None,
|
|
38
|
+
use_archive: bool = False,
|
|
39
|
+
track_total_hits: bool = False,
|
|
40
|
+
metadata: Optional[list[str]] = None,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""Search across one or more indexes.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
indexes: Comma-separated index names (e.g. ``"hit"`` or ``"hit,event"``).
|
|
46
|
+
query: Lucene query string.
|
|
47
|
+
filters: Additional filter queries.
|
|
48
|
+
fl: Comma-separated list of fields to return.
|
|
49
|
+
offset: Result offset.
|
|
50
|
+
rows: Number of results per page.
|
|
51
|
+
sort: Sort specification (e.g. ``"event.created desc"``).
|
|
52
|
+
timeout: Maximum execution time in milliseconds.
|
|
53
|
+
use_archive: Include archived data.
|
|
54
|
+
track_total_hits: Track exact total hit count.
|
|
55
|
+
metadata: Additional features to include (e.g. ``["dossiers"]``).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Search result with ``total``, ``offset``, ``rows``, ``items``.
|
|
59
|
+
"""
|
|
60
|
+
body: dict[str, Any] = {"query": query, "offset": offset, "rows": rows}
|
|
61
|
+
|
|
62
|
+
if filters:
|
|
63
|
+
body["filters"] = filters
|
|
64
|
+
if fl:
|
|
65
|
+
body["fl"] = fl
|
|
66
|
+
if sort:
|
|
67
|
+
body["sort"] = sort
|
|
68
|
+
if timeout is not None:
|
|
69
|
+
body["timeout"] = timeout
|
|
70
|
+
if use_archive:
|
|
71
|
+
body["use_archive"] = True
|
|
72
|
+
if track_total_hits:
|
|
73
|
+
body["track_total_hits"] = True
|
|
74
|
+
if metadata:
|
|
75
|
+
body["metadata"] = metadata
|
|
76
|
+
|
|
77
|
+
return self._connection.post(api_path_v2("search", indexes), data=json.dumps(body))
|
|
78
|
+
|
|
79
|
+
def explain(self: Self, index: str, query: str) -> dict[str, Any]:
|
|
80
|
+
"""Explain a Lucene query against an index.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
index: Single index name (e.g. ``"hit"``).
|
|
84
|
+
query: Lucene query to explain.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Explanation with ``valid`` and ``explanations`` fields.
|
|
88
|
+
"""
|
|
89
|
+
return self._connection.post(api_path_v2("search", index, "explain"), json={"query": query})
|
|
90
|
+
|
|
91
|
+
def count(
|
|
92
|
+
self: Self,
|
|
93
|
+
index: str,
|
|
94
|
+
query: str,
|
|
95
|
+
filters: Optional[list[str]] = None,
|
|
96
|
+
timeout: Optional[int] = None,
|
|
97
|
+
use_archive: bool = False,
|
|
98
|
+
) -> int:
|
|
99
|
+
"""Count documents matching a query.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
index: Single index name (e.g. ``"hit"``).
|
|
103
|
+
query: Lucene query string.
|
|
104
|
+
filters: Additional filter queries.
|
|
105
|
+
timeout: Maximum execution time in milliseconds.
|
|
106
|
+
use_archive: Include archived data.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Number of matching documents.
|
|
110
|
+
"""
|
|
111
|
+
body: dict[str, Any] = {"query": query}
|
|
112
|
+
|
|
113
|
+
if filters:
|
|
114
|
+
body["filters"] = filters
|
|
115
|
+
if timeout is not None:
|
|
116
|
+
body["timeout"] = timeout
|
|
117
|
+
if use_archive:
|
|
118
|
+
body["use_archive"] = True
|
|
119
|
+
|
|
120
|
+
return self._connection.post(api_path_v2("search", "count", index), json=body)["count"]
|
|
121
|
+
|
|
122
|
+
def facet(
|
|
123
|
+
self: Self,
|
|
124
|
+
indexes: str,
|
|
125
|
+
fields: list[str],
|
|
126
|
+
query: str = "*:*",
|
|
127
|
+
mincount: Optional[int] = None,
|
|
128
|
+
rows: Optional[int] = None,
|
|
129
|
+
filters: Optional[list[str]] = None,
|
|
130
|
+
) -> dict[str, dict[str, int]]:
|
|
131
|
+
"""Perform field faceting across one or more indexes.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
indexes: Comma-separated index names.
|
|
135
|
+
fields: List of fields to facet on.
|
|
136
|
+
query: Lucene query to filter documents.
|
|
137
|
+
mincount: Minimum count for a value to be returned.
|
|
138
|
+
rows: Maximum number of facet values per field.
|
|
139
|
+
filters: Additional filter queries.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Dictionary mapping each field to its value counts.
|
|
143
|
+
"""
|
|
144
|
+
body: dict[str, Any] = {"query": query, "fields": fields}
|
|
145
|
+
|
|
146
|
+
if mincount is not None:
|
|
147
|
+
body["mincount"] = mincount
|
|
148
|
+
if rows is not None:
|
|
149
|
+
body["rows"] = rows
|
|
150
|
+
if filters:
|
|
151
|
+
body["filters"] = filters
|
|
152
|
+
|
|
153
|
+
return self._connection.post(api_path_v2("search", "facet", indexes), json=body)
|
|
@@ -91,7 +91,7 @@ suppress-none-returning = true
|
|
|
91
91
|
[tool.poetry]
|
|
92
92
|
package-mode = true
|
|
93
93
|
name = "howler-client"
|
|
94
|
-
version = "2.4.0.
|
|
94
|
+
version = "2.4.0.dev286"
|
|
95
95
|
description = "The Howler client library facilitates issuing requests to Howler"
|
|
96
96
|
authors = [
|
|
97
97
|
"Canadian Centre for Cyber Security <howler@cyber.gc.ca>",
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import sys
|
|
2
|
-
from typing import TYPE_CHECKING, Any
|
|
3
|
-
|
|
4
|
-
from howler_client.common.utils import api_path
|
|
5
|
-
from howler_client.logger import get_logger
|
|
6
|
-
from howler_client.module.hit import Hit
|
|
7
|
-
|
|
8
|
-
if sys.version_info >= (3, 11):
|
|
9
|
-
from typing import Self
|
|
10
|
-
else:
|
|
11
|
-
from typing_extensions import Self
|
|
12
|
-
|
|
13
|
-
if TYPE_CHECKING:
|
|
14
|
-
from howler_client import Connection
|
|
15
|
-
|
|
16
|
-
logger = get_logger("bundle")
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class Bundle(object):
|
|
20
|
-
"""Methods related to hit bundles"""
|
|
21
|
-
|
|
22
|
-
def __init__(self: Self, connection: "Connection", hit: Hit):
|
|
23
|
-
self._connection: Connection = connection
|
|
24
|
-
self._hit: Hit = hit
|
|
25
|
-
|
|
26
|
-
def __call__(self: Self, hit_id: str) -> dict[str, Any]:
|
|
27
|
-
"""Return the bundle for a given ID.
|
|
28
|
-
|
|
29
|
-
Args:
|
|
30
|
-
hit_id (str): ID of the bundle
|
|
31
|
-
|
|
32
|
-
Raises:
|
|
33
|
-
ClientError: The hit does not exist
|
|
34
|
-
AttributeError: The hit is not a bundle
|
|
35
|
-
|
|
36
|
-
Returns:
|
|
37
|
-
Hit: The bundle in question
|
|
38
|
-
"""
|
|
39
|
-
result = self._hit(hit_id)
|
|
40
|
-
|
|
41
|
-
if result["howler"]["is_bundle"]:
|
|
42
|
-
return result
|
|
43
|
-
else:
|
|
44
|
-
raise AttributeError("This hit is not a bundle! Use client.hit(...) instead.")
|
|
45
|
-
|
|
46
|
-
def create_from_map(
|
|
47
|
-
self: Self,
|
|
48
|
-
tool_name: str,
|
|
49
|
-
bundle_hit: dict[str, Any],
|
|
50
|
-
map: dict[str, list[str]],
|
|
51
|
-
documents: list[dict[str, Any]],
|
|
52
|
-
ignore_extra_values: bool = False,
|
|
53
|
-
) -> list[dict[str, str | list[str] | None]]:
|
|
54
|
-
"""Create a bundle using a format similar to the hit.create_from_map function
|
|
55
|
-
|
|
56
|
-
Args:
|
|
57
|
-
tool_name (str): Name of the tool the hits will be created for.
|
|
58
|
-
bundle_hit (Hit): The bundle hit
|
|
59
|
-
map (dict[str, list[str]]): Dictionary where the keys are the flattened path of the tool's raw document and
|
|
60
|
-
the values are a list of flattened path for Howler's fields where the data will be copied into.
|
|
61
|
-
documents (list[Hit]): A list of hits to create as children of the bundle hit provided
|
|
62
|
-
ignore_extra_values (bool, optional): Ignore invalid values and return a warning, or throw an error.
|
|
63
|
-
Defaults to False.
|
|
64
|
-
|
|
65
|
-
Returns:
|
|
66
|
-
list[dict[str, str | None]]: The list of IDs of the created hits
|
|
67
|
-
"""
|
|
68
|
-
map = {**map, "bundle": ["howler.is_bundle"]}
|
|
69
|
-
bundle_hit = {**bundle_hit, "bundle": True}
|
|
70
|
-
hit = [bundle_hit] + documents
|
|
71
|
-
|
|
72
|
-
return self._hit.create_from_map(tool_name, map, hit, ignore_extra_values=ignore_extra_values)
|
|
73
|
-
|
|
74
|
-
def create(
|
|
75
|
-
self: Self,
|
|
76
|
-
bundle_hit: dict[str, Any],
|
|
77
|
-
data: dict[str, Any] | list[dict[str, Any]] | None = None,
|
|
78
|
-
ignore_extra_values: bool = False,
|
|
79
|
-
) -> dict[str, Any] | None:
|
|
80
|
-
"""Create a bundle using a format similar to the hit.create function
|
|
81
|
-
|
|
82
|
-
Args:
|
|
83
|
-
bundle_hit (dict[str, Any]): The bundle hit to create
|
|
84
|
-
data (Union[dict[str, Any], list[dict[str, Any]]], optional): A Hit or list of Hits to create as
|
|
85
|
-
children of the bundle hit
|
|
86
|
-
ignore_extra_values (bool, optional): Ignore invalid values and return a warning, or throw an error.
|
|
87
|
-
Defaults to False.
|
|
88
|
-
|
|
89
|
-
Returns:
|
|
90
|
-
Hit: The created bundle hit
|
|
91
|
-
"""
|
|
92
|
-
if not data:
|
|
93
|
-
data = []
|
|
94
|
-
|
|
95
|
-
if not isinstance(data, list):
|
|
96
|
-
data = [data]
|
|
97
|
-
|
|
98
|
-
if len(data) > 0:
|
|
99
|
-
result = self._hit.create(data, ignore_extra_values=ignore_extra_values)
|
|
100
|
-
|
|
101
|
-
if not result or len(result["invalid"]) > 0:
|
|
102
|
-
return result
|
|
103
|
-
|
|
104
|
-
hit_ids = [h["howler"]["id"] for h in result["valid"]]
|
|
105
|
-
else:
|
|
106
|
-
hit_ids = []
|
|
107
|
-
|
|
108
|
-
return self._connection.post(api_path("hit/bundle"), json={"bundle": bundle_hit, "hits": hit_ids})
|
|
109
|
-
|
|
110
|
-
def add(self: Self, bundle_id: str, hit_ids: str | list[str]):
|
|
111
|
-
"""Add a list of hits to a bundle by their IDs
|
|
112
|
-
|
|
113
|
-
Args:
|
|
114
|
-
bundle_id (str): The ID of the bundle we want to add the hits to
|
|
115
|
-
hit_ids (str | list[str]): The list of hit IDs to add to the bundle
|
|
116
|
-
"""
|
|
117
|
-
if not isinstance(hit_ids, list):
|
|
118
|
-
hit_ids = [hit_ids]
|
|
119
|
-
|
|
120
|
-
return self._connection.put(api_path("hit/bundle", bundle_id), json=hit_ids)
|
|
121
|
-
|
|
122
|
-
def remove(self: Self, bundle_id: str, hit_ids: str | list[str]):
|
|
123
|
-
"""Remove a list of hits from a bundle by their IDs
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
bundle_id (str): The bundle ID from which to remove the hits
|
|
127
|
-
hit_ids (str | list[str]): A list of hit IDs to remove from the bundle
|
|
128
|
-
"""
|
|
129
|
-
if not isinstance(hit_ids, list):
|
|
130
|
-
hit_ids = [hit_ids]
|
|
131
|
-
|
|
132
|
-
return self._connection.delete(api_path("hit/bundle", bundle_id), json=hit_ids)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/common/dict_utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/__init__.py
RENAMED
|
File without changes
|
{howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/chunk.py
RENAMED
|
File without changes
|
{howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/facet.py
RENAMED
|
File without changes
|
{howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/fields.py
RENAMED
|
File without changes
|
{howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/grouped.py
RENAMED
|
File without changes
|
{howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/histogram.py
RENAMED
|
File without changes
|
{howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/stats.py
RENAMED
|
File without changes
|
{howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/stream.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/utils/json_encoders.py
RENAMED
|
File without changes
|