howler-client 2.4.0__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.
- howler_client/__init__.py +46 -0
- howler_client/client.py +32 -0
- howler_client/common/__init__.py +0 -0
- howler_client/common/dict_utils.py +138 -0
- howler_client/common/utils.py +113 -0
- howler_client/connection.py +204 -0
- howler_client/logger.py +14 -0
- howler_client/module/__init__.py +0 -0
- howler_client/module/bundle.py +132 -0
- howler_client/module/comment.py +59 -0
- howler_client/module/help.py +23 -0
- howler_client/module/hit.py +299 -0
- howler_client/module/search/__init__.py +84 -0
- howler_client/module/search/chunk.py +38 -0
- howler_client/module/search/facet.py +41 -0
- howler_client/module/search/fields.py +19 -0
- howler_client/module/search/grouped.py +67 -0
- howler_client/module/search/histogram.py +63 -0
- howler_client/module/search/stats.py +39 -0
- howler_client/module/search/stream.py +81 -0
- howler_client/module/user.py +97 -0
- howler_client/utils/__init__.py +0 -0
- howler_client/utils/json_encoders.py +36 -0
- howler_client-2.4.0.dist-info/LICENSE +23 -0
- howler_client-2.4.0.dist-info/METADATA +72 -0
- howler_client-2.4.0.dist-info/RECORD +28 -0
- howler_client-2.4.0.dist-info/WHEEL +4 -0
- howler_client-2.4.0.dist-info/entry_points.txt +5 -0
|
@@ -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)
|
howler_client/client.py
ADDED
|
@@ -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.")
|
howler_client/logger.py
ADDED
|
@@ -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)
|