howler-client 2.4.0.dev37__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.
@@ -0,0 +1,46 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from howler_client.client import Client
4
+ from howler_client.connection import Connection
5
+
6
+ try:
7
+ __version__ = version("howler-client")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0.unknown"
10
+
11
+ RETRY_FOREVER = 0
12
+ SUPPORTED_APIS = {"v1"}
13
+
14
+
15
+ def get_client(
16
+ server,
17
+ auth=None,
18
+ cert=None,
19
+ debug=lambda x: None,
20
+ headers=None,
21
+ retries=RETRY_FOREVER,
22
+ silence_requests_warnings=True,
23
+ apikey=None,
24
+ verify=True,
25
+ timeout=None,
26
+ throw_on_bad_request=True,
27
+ throw_on_max_retries=True,
28
+ token=None,
29
+ ):
30
+ "Initialize a howler client object"
31
+ connection = Connection(
32
+ server,
33
+ auth,
34
+ cert,
35
+ debug,
36
+ headers,
37
+ retries,
38
+ silence_requests_warnings,
39
+ apikey,
40
+ verify,
41
+ timeout,
42
+ throw_on_bad_request,
43
+ throw_on_max_retries,
44
+ token,
45
+ )
46
+ return Client(connection)
@@ -0,0 +1,32 @@
1
+ import sys
2
+
3
+ from howler_client.common.utils import walk_api_path
4
+ from howler_client.connection import Connection
5
+ from howler_client.module.bundle import Bundle
6
+ from howler_client.module.help import Help
7
+ from howler_client.module.hit import Hit
8
+ from howler_client.module.search import Search
9
+ from howler_client.module.user import User
10
+
11
+ if sys.version_info >= (3, 11):
12
+ from typing import Self
13
+ else:
14
+ from typing_extensions import Self
15
+
16
+
17
+ class Client(object):
18
+ "Main howler client object, wrapping API calls"
19
+
20
+ def __init__(self: Self, connection: Connection):
21
+ self._connection: Connection = connection
22
+
23
+ self.help = Help(self._connection)
24
+ self.search = Search(self._connection)
25
+ self.hit = Hit(self._connection, self.search)
26
+ self.bundle = Bundle(self._connection, self.hit)
27
+ self.user = User(self._connection)
28
+
29
+ paths: list[str] = []
30
+ walk_api_path(self, [""], paths)
31
+
32
+ self.__doc__ = "Client provides the following methods:\n\n" + "\n".join(["\n".join(p + "\n") for p in paths])
File without changes
@@ -0,0 +1,138 @@
1
+ from collections.abc import Mapping
2
+ from typing import TYPE_CHECKING, Any, AnyStr, Optional, cast
3
+ from typing import Mapping as _Mapping
4
+
5
+ if TYPE_CHECKING:
6
+ pass
7
+
8
+
9
+ def strip_nulls(d: Any):
10
+ """Remove null values from a dict"""
11
+ if isinstance(d, dict):
12
+ return {k: strip_nulls(v) for k, v in d.items() if v is not None}
13
+ else:
14
+ return d
15
+
16
+
17
+ def recursive_update(
18
+ d: Optional[dict[str, Any]],
19
+ u: Optional[_Mapping[str, Any]],
20
+ stop_keys: list[AnyStr] = [],
21
+ allow_recursion: bool = True,
22
+ ) -> dict[str, Any]:
23
+ "Recursively update a dict with another value"
24
+ if d is None:
25
+ return cast(dict, u or {})
26
+
27
+ if u is None:
28
+ return d
29
+
30
+ for k, v in u.items():
31
+ if isinstance(v, Mapping) and allow_recursion:
32
+ d[k] = recursive_update(d.get(k, {}), v, stop_keys=stop_keys, allow_recursion=k not in stop_keys)
33
+ else:
34
+ d[k] = v
35
+
36
+ return d
37
+
38
+
39
+ def get_recursive_delta(
40
+ d1: Optional[_Mapping[str, Any]],
41
+ d2: Optional[_Mapping[str, Any]],
42
+ stop_keys: list[AnyStr] = [],
43
+ allow_recursion: bool = True,
44
+ ) -> Optional[dict[str, Any]]:
45
+ "Get the recursive difference between two objects"
46
+ if d1 is None:
47
+ return cast(dict, d2)
48
+
49
+ if d2 is None:
50
+ return cast(dict, d1)
51
+
52
+ out = {}
53
+ for k1, v1 in d1.items():
54
+ if isinstance(v1, Mapping) and allow_recursion:
55
+ internal = get_recursive_delta(
56
+ v1,
57
+ d2.get(k1, {}),
58
+ stop_keys=stop_keys,
59
+ allow_recursion=k1 not in stop_keys,
60
+ )
61
+ if internal:
62
+ out[k1] = internal
63
+ else:
64
+ if k1 in d2:
65
+ v2 = d2[k1]
66
+ if v1 != v2:
67
+ out[k1] = v2
68
+
69
+ for k2, v2 in d2.items():
70
+ if k2 not in d1:
71
+ out[k2] = v2
72
+
73
+ return out
74
+
75
+
76
+ def flatten(data: dict, fields: list[str] = [], parent_key: Optional[str] = None) -> dict:
77
+ "Flatten a nested dict"
78
+ items: list[tuple[str, Any]] = []
79
+
80
+ # We special case howler.data
81
+ if parent_key == "howler.data":
82
+ return {"howler.data": data}
83
+
84
+ for k, v in data.items():
85
+ cur_key = f"{parent_key}.{k}" if parent_key is not None else k
86
+ if isinstance(v, dict) and (len(fields) == 0 or any(k.startswith(field) for field in fields)):
87
+ items.extend(flatten(v, fields=fields, parent_key=cur_key).items())
88
+ else:
89
+ items.append((cur_key, v))
90
+
91
+ return dict(items)
92
+
93
+
94
+ def unflatten(data: _Mapping) -> _Mapping:
95
+ "Unflatted a nested dict"
96
+ out: dict[str, Any] = dict()
97
+ for k, v in data.items():
98
+ parts = k.split(".")
99
+ d = out
100
+ for p in parts[:-1]:
101
+ if p not in d:
102
+ d[p] = dict()
103
+ d = d[p]
104
+ d[parts[-1]] = v
105
+ return out
106
+
107
+
108
+ def prune(data: _Mapping, keys: list[str], parent_key: Optional[str] = None) -> dict[str, Any]:
109
+ "Remove all keys in the given list from the dict if they exist"
110
+ pruned_items: list[tuple[str, Any]] = []
111
+
112
+ for key, val in data.items():
113
+ cur_key = f"{parent_key}.{key}" if parent_key else key
114
+
115
+ if isinstance(val, dict):
116
+ child_keys = [_key for _key in keys if _key.startswith(cur_key)]
117
+
118
+ if len(child_keys) > 0:
119
+ pruned_items.append((key, prune(val, child_keys, cur_key)))
120
+ elif isinstance(val, list):
121
+ if cur_key not in keys and not any(_key.startswith(cur_key) for _key in keys):
122
+ continue
123
+
124
+ list_result = []
125
+ for entry in val:
126
+ if isinstance(val, dict):
127
+ child_keys = [_key for _key in keys if _key.startswith(cur_key)]
128
+
129
+ if len(child_keys) > 0:
130
+ pruned_items.append((key, prune(val, child_keys, cur_key)))
131
+ else:
132
+ list_result.append(entry)
133
+
134
+ pruned_items.append((key, list_result))
135
+ elif cur_key in keys:
136
+ pruned_items.append((key, val))
137
+
138
+ return {k: v for k, v in pruned_items}
@@ -0,0 +1,113 @@
1
+ import re
2
+ import sys
3
+ from types import FrameType
4
+ from typing import cast
5
+ from urllib.parse import quote
6
+
7
+ INVALID_STREAM_SEARCH_PARAMS = ("deep_paging_id", "rows", "sort")
8
+ SEARCHABLE = ["hit"]
9
+ API = "v1"
10
+
11
+
12
+ class ClientError(Exception):
13
+ "Generic Exception for the howler client"
14
+
15
+ def __init__(self, message, status_code, api_response=None, api_version=None, resp_data=None):
16
+ super(ClientError, self).__init__(message)
17
+ self.api_response = api_response
18
+ self.api_version = api_version
19
+ self.status_code = status_code
20
+ self.resp_data = resp_data
21
+
22
+
23
+ def _join_param(k, v):
24
+ val = quote(str(v))
25
+ if not val:
26
+ return k
27
+ return f"{k}={val}"
28
+
29
+
30
+ def _join_kw(kw):
31
+ return "&".join([_join_param(k, v) for k, v in kw.items() if v is not None])
32
+
33
+
34
+ def api_path_by_module(obj, *args, **kw):
35
+ """Calculate the API path using the class and method names as shown below:
36
+
37
+ /api/v1/<class_name>/<method_name>/[arg1/[arg2/[...]]][?k1=v1[...]]
38
+ """
39
+ c = obj.__class__.__name__.lower()
40
+ m = cast(FrameType, sys._getframe().f_back).f_code.co_name
41
+
42
+ return api_path(f"{c}/{m}", *args, **kw)
43
+
44
+
45
+ def _param_ok(k):
46
+ return k not in ("q", "df", "wt")
47
+
48
+
49
+ def api_path(prefix, *args, **kw):
50
+ """Calculate the API path using the prefix as shown:
51
+
52
+ /api/v1/<prefix>/[arg1/[arg2/[...]]][?k1=v1[...]]
53
+ """
54
+ path = "/".join(["api", API, prefix] + list(args))
55
+
56
+ params_tuples = kw.pop("params_tuples", [])
57
+ params = "&".join([_join_kw(kw)] + [_join_param(*e) for e in params_tuples if _param_ok(e)])
58
+ if not params:
59
+ return path
60
+
61
+ return f"{path}?{params}"
62
+
63
+
64
+ def stream_output(output):
65
+ "Stream the output of a response"
66
+
67
+ def _do_stream(response):
68
+ f = output
69
+ if isinstance(output, str):
70
+ f = open(output, "wb")
71
+ for chunk in response.iter_content(chunk_size=1024):
72
+ if chunk:
73
+ f.write(chunk)
74
+ if f != output:
75
+ f.close()
76
+ return True
77
+
78
+ return _do_stream
79
+
80
+
81
+ def walk_api_path(obj, path, paths):
82
+ "Walk a module and populate a provided list with the paths and their documentation"
83
+ if isinstance(obj, int):
84
+ return
85
+
86
+ for m in dir(obj):
87
+ mobj = getattr(obj, m)
88
+ if m == "__call__":
89
+ doc = str(mobj.__doc__)
90
+ if doc in ("x.__call__(...) <==> x(...)", "Call self as a function."):
91
+ doc = str(obj.__doc__)
92
+ doc = doc.split("\n\n", 1)[0]
93
+ doc = re.sub(r"\s+", " ", doc.strip())
94
+ if doc != "For internal use.":
95
+ paths.append(f'{".".join(path)}(): {doc}')
96
+
97
+ continue
98
+ elif m.startswith(("_", "im_")):
99
+ continue
100
+
101
+ walk_api_path(mobj, path + [m], paths)
102
+
103
+
104
+ def to_pascal_case(snake_str):
105
+ "Convert a snake_case string to PascalCase"
106
+ components = snake_str.split("_")
107
+ return "".join(component.title() for component in components)
108
+
109
+
110
+ def to_camel_case(snake_str):
111
+ "Convert a PascalCase string to snake_case"
112
+ components = snake_str.split("_")
113
+ return components[0] + "".join(component.title() for component in components[1:])
@@ -0,0 +1,204 @@
1
+ import base64
2
+ import json
3
+ import sys
4
+ import time
5
+ import warnings
6
+ from typing import Any, Callable, MutableMapping, Optional, Union
7
+
8
+ import requests
9
+
10
+ from howler_client.common.utils import ClientError
11
+ from howler_client.logger import get_logger
12
+
13
+ if sys.version_info >= (3, 11):
14
+ from typing import Self
15
+ else:
16
+ from typing_extensions import Self
17
+
18
+ SUPPORTED_APIS = {"v1"}
19
+
20
+ logger = get_logger("connection")
21
+
22
+
23
+ def convert_api_output(response: requests.Response):
24
+ "Convert a requests response to a python object based on the returned JSON data"
25
+ logger.debug("Converting response %s", response.text)
26
+
27
+ if response.status_code != 204:
28
+ return response.json()["api_response"]
29
+
30
+ return None
31
+
32
+
33
+ class Connection(object):
34
+ "Abstraction for executing network requests to the Howler API"
35
+
36
+ def __init__( # pylint: disable=R0913
37
+ self: Self,
38
+ server: str,
39
+ auth: Optional[Union[str, tuple[str, str]]],
40
+ cert: Optional[Union[str, tuple[str, str]]],
41
+ debug: Callable[[str], None],
42
+ headers: Optional[MutableMapping[str, Union[str, bytes]]],
43
+ retries: int,
44
+ silence_warnings: bool,
45
+ apikey: Optional[tuple[str, str]],
46
+ verify: bool,
47
+ timeout: Optional[int],
48
+ throw_on_bad_request: bool,
49
+ throw_on_max_retries: bool,
50
+ # TODO: Not sure what this argument is for (if used at all)
51
+ token: Optional[Any],
52
+ ):
53
+ self.apikey = apikey
54
+ self.debug = debug
55
+ self.max_retries = retries
56
+ self.server = server
57
+ self.silence_warnings = silence_warnings
58
+ self.default_timeout = timeout
59
+ self.throw_on_bad_request = throw_on_bad_request
60
+ self.throw_on_max_retries = throw_on_max_retries
61
+ self.token = token
62
+
63
+ session = requests.Session()
64
+
65
+ session.headers.update({"Content-Type": "application/json"})
66
+
67
+ if auth:
68
+ if not isinstance(auth, str):
69
+ auth = base64.b64encode(":".join(auth).encode("utf-8")).decode("utf-8")
70
+
71
+ if "." in auth:
72
+ logger.info("Using JWT Authentication")
73
+ session.headers.update({"Authorization": f"Bearer {auth}"})
74
+ else:
75
+ logger.info("Using Password Authentication")
76
+ session.headers.update({"Authorization": f"Basic {auth}"})
77
+ elif apikey:
78
+ logger.info("Using API Key Authentication")
79
+ session.headers.update(
80
+ {"Authorization": f"Basic {base64.b64encode(':'.join(apikey).encode('utf-8')).decode('utf-8')}"}
81
+ )
82
+
83
+ session.verify = verify
84
+
85
+ if cert:
86
+ session.cert = cert
87
+
88
+ if headers:
89
+ logger.debug("Adding additional headers")
90
+ session.headers.update(headers)
91
+
92
+ self.session = session
93
+
94
+ if "pytest" in sys.modules:
95
+ logger.info("Skipping API validation, running in a test environment")
96
+ else:
97
+ r = self.request(self.session.get, "api/", convert_api_output)
98
+ if not isinstance(r, list) or not set(r).intersection(SUPPORTED_APIS):
99
+ raise ClientError("Supported APIS (%s) are not available" % SUPPORTED_APIS, 400)
100
+
101
+ def delete(self, path, **kw):
102
+ "Execute a DELETE request"
103
+ return self.request(self.session.delete, path, convert_api_output, **kw)
104
+
105
+ def download(self, path, process, **kw):
106
+ "Download a file from the remote server"
107
+ return self.request(self.session.get, path, process, **kw)
108
+
109
+ def get(self, path, **kw):
110
+ "Execute a GET request"
111
+ return self.request(self.session.get, path, convert_api_output, **kw)
112
+
113
+ def post(self, path, **kw):
114
+ "Execute a POST request"
115
+ return self.request(self.session.post, path, convert_api_output, **kw)
116
+
117
+ def put(self, path, **kw):
118
+ "Execute a PUT request"
119
+ return self.request(self.session.put, path, convert_api_output, **kw)
120
+
121
+ def request(self, func, path, process, **kw): # noqa: C901
122
+ "Main request function - prepare and execute a request"
123
+ self.debug(path)
124
+
125
+ # Apply default timeout parameter if not passed elsewhere
126
+ kw.setdefault("timeout", self.default_timeout)
127
+
128
+ retries = 0
129
+ with warnings.catch_warnings():
130
+ if self.silence_warnings:
131
+ warnings.simplefilter("ignore")
132
+ while self.max_retries < 1 or retries <= self.max_retries:
133
+ if retries:
134
+ time.sleep(min(2, 2 ** (retries - 7)))
135
+
136
+ try:
137
+ response = func(f"{self.server}/{path}", **kw)
138
+ if "XSRF-TOKEN" in response.cookies:
139
+ self.session.headers.update({"X-XSRF-TOKEN": response.cookies["XSRF-TOKEN"]})
140
+
141
+ if response.text:
142
+ try:
143
+ _warnings = json.loads(response.text).get("api_warning", None)
144
+ except json.JSONDecodeError:
145
+ logger.warning(
146
+ "There was an error when decoding the JSON response from the server, no warnings will "
147
+ "be shown."
148
+ )
149
+ _warnings = None
150
+
151
+ if _warnings:
152
+ for warning in _warnings:
153
+ logger.warning(warning)
154
+
155
+ if response.ok:
156
+ return process(response)
157
+ elif response.status_code not in (502, 503, 504):
158
+ try:
159
+ resp_data = response.json()
160
+
161
+ message = "\n".join(
162
+ [item["error"] for item in resp_data["api_response"] if "error" in item]
163
+ )
164
+
165
+ err_msg = resp_data["api_error_message"]
166
+
167
+ if message:
168
+ err_msg = f"{err_msg}\n{message}"
169
+
170
+ logger.error("%s: %s", response.status_code, err_msg)
171
+
172
+ if response.status_code != 400 or self.throw_on_bad_request:
173
+ raise ClientError( # noqa: TRY301
174
+ err_msg,
175
+ response.status_code,
176
+ api_version=resp_data["api_server_version"],
177
+ api_response=resp_data["api_response"],
178
+ resp_data=resp_data,
179
+ )
180
+ else:
181
+ break
182
+
183
+ except Exception as e:
184
+ if isinstance(e, ClientError):
185
+ raise
186
+
187
+ if response.status_code != 400 or self.throw_on_bad_request:
188
+ raise ClientError(response.content, response.status_code) from e
189
+ else:
190
+ break
191
+ except (requests.exceptions.SSLError, requests.exceptions.ProxyError):
192
+ raise
193
+ except requests.exceptions.ConnectionError:
194
+ pass
195
+ except OSError as e:
196
+ if "Connection aborted" not in str(e):
197
+ raise
198
+
199
+ retries += 1
200
+
201
+ if self.throw_on_max_retries:
202
+ raise ClientError("Max retry reached, could not perform the request.", None)
203
+ else:
204
+ logger.error("Max retry reached, could not perform the request.")
@@ -0,0 +1,14 @@
1
+ import logging
2
+
3
+ HWL_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s | %(message)s"
4
+ HWL_DATE_FORMAT = "%y/%m/%d %H:%M:%S"
5
+
6
+ BASE_LOGGER = logging.getLogger("howler")
7
+ console = logging.StreamHandler()
8
+ console.setFormatter(logging.Formatter(HWL_LOG_FORMAT, HWL_DATE_FORMAT))
9
+ BASE_LOGGER.addHandler(console)
10
+
11
+
12
+ def get_logger(name: str) -> logging.Logger:
13
+ "Create a logger instance with the given name"
14
+ return BASE_LOGGER.getChild(name)
File without changes
@@ -0,0 +1,132 @@
1
+ import sys
2
+ from typing import TYPE_CHECKING, Any, Optional, Union
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
+ ) -> dict[str, Union[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, Optional[str]]]: 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: Optional[Union[dict[str, Any], list[dict[str, Any]]]] = [],
78
+ ignore_extra_values: bool = False,
79
+ ) -> dict[str, Any]:
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: Union[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 (Union[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: Union[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 (Union[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)