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.
Files changed (31) hide show
  1. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/PKG-INFO +1 -1
  2. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/client.py +2 -2
  3. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/common/utils.py +15 -0
  4. howler_client-2.4.0.dev286/howler_client/module/v2/__init__.py +30 -0
  5. howler_client-2.4.0.dev286/howler_client/module/v2/case.py +177 -0
  6. howler_client-2.4.0.dev286/howler_client/module/v2/ingest.py +160 -0
  7. howler_client-2.4.0.dev286/howler_client/module/v2/search.py +153 -0
  8. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/pyproject.toml +1 -1
  9. howler_client-2.4.0.dev242/howler_client/module/bundle.py +0 -132
  10. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/LICENSE +0 -0
  11. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/README.md +0 -0
  12. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/__init__.py +0 -0
  13. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/common/__init__.py +0 -0
  14. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/common/dict_utils.py +0 -0
  15. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/connection.py +0 -0
  16. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/logger.py +0 -0
  17. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/__init__.py +0 -0
  18. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/comment.py +0 -0
  19. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/help.py +0 -0
  20. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/hit.py +0 -0
  21. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/__init__.py +0 -0
  22. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/chunk.py +0 -0
  23. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/facet.py +0 -0
  24. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/fields.py +0 -0
  25. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/grouped.py +0 -0
  26. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/histogram.py +0 -0
  27. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/stats.py +0 -0
  28. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/search/stream.py +0 -0
  29. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/module/user.py +0 -0
  30. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/utils/__init__.py +0 -0
  31. {howler_client-2.4.0.dev242 → howler_client-2.4.0.dev286}/howler_client/utils/json_encoders.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: howler-client
3
- Version: 2.4.0.dev242
3
+ Version: 2.4.0.dev286
4
4
  Summary: The Howler client library facilitates issuing requests to Howler
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -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.dev242"
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)