howler-client 2.4.0.dev37__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 (27) hide show
  1. howler_client-2.4.0.dev37/LICENSE +23 -0
  2. howler_client-2.4.0.dev37/PKG-INFO +61 -0
  3. howler_client-2.4.0.dev37/README.md +31 -0
  4. howler_client-2.4.0.dev37/howler_client/__init__.py +46 -0
  5. howler_client-2.4.0.dev37/howler_client/client.py +32 -0
  6. howler_client-2.4.0.dev37/howler_client/common/__init__.py +0 -0
  7. howler_client-2.4.0.dev37/howler_client/common/dict_utils.py +138 -0
  8. howler_client-2.4.0.dev37/howler_client/common/utils.py +113 -0
  9. howler_client-2.4.0.dev37/howler_client/connection.py +204 -0
  10. howler_client-2.4.0.dev37/howler_client/logger.py +14 -0
  11. howler_client-2.4.0.dev37/howler_client/module/__init__.py +0 -0
  12. howler_client-2.4.0.dev37/howler_client/module/bundle.py +132 -0
  13. howler_client-2.4.0.dev37/howler_client/module/comment.py +59 -0
  14. howler_client-2.4.0.dev37/howler_client/module/help.py +23 -0
  15. howler_client-2.4.0.dev37/howler_client/module/hit.py +299 -0
  16. howler_client-2.4.0.dev37/howler_client/module/search/__init__.py +84 -0
  17. howler_client-2.4.0.dev37/howler_client/module/search/chunk.py +38 -0
  18. howler_client-2.4.0.dev37/howler_client/module/search/facet.py +41 -0
  19. howler_client-2.4.0.dev37/howler_client/module/search/fields.py +19 -0
  20. howler_client-2.4.0.dev37/howler_client/module/search/grouped.py +67 -0
  21. howler_client-2.4.0.dev37/howler_client/module/search/histogram.py +63 -0
  22. howler_client-2.4.0.dev37/howler_client/module/search/stats.py +39 -0
  23. howler_client-2.4.0.dev37/howler_client/module/search/stream.py +81 -0
  24. howler_client-2.4.0.dev37/howler_client/module/user.py +97 -0
  25. howler_client-2.4.0.dev37/howler_client/utils/__init__.py +0 -0
  26. howler_client-2.4.0.dev37/howler_client/utils/json_encoders.py +36 -0
  27. howler_client-2.4.0.dev37/pyproject.toml +154 -0
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Crown Copyright, Government of Canada (Canadian Centre for Cyber Security / Communications Security Establishment)
4
+
5
+ Copyright title to all 3rd party software distributed with Howler is held by the respective copyright holders as noted in those files. Users are asked to read the 3rd Party Licenses referenced with those assets.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.3
2
+ Name: howler-client
3
+ Version: 2.4.0.dev37
4
+ Summary: The Howler client library facilitates issuing requests to Howler
5
+ License: MIT
6
+ Keywords: howler,alerting,gc,canada,cse-cst,cse,cst,cyber,cccs
7
+ Author: Canadian Centre for Cyber Security
8
+ Author-email: howler@cyber.gc.ca
9
+ Requires-Python: >=3.9,<4.0
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Dist: coverage[toml] (>=7.6.1,<8.0.0)
21
+ Requires-Dist: diff-cover (>=9.2.0,<10.0.0)
22
+ Requires-Dist: pycryptodome (>=3.20.0,<4.0.0)
23
+ Requires-Dist: python-baseconv (>=1.2.2,<2.0.0)
24
+ Requires-Dist: requests[security] (>=2.32.0,<3.0.0)
25
+ Project-URL: Documentation, https://cybercentrecanada.github.io/howler-docs/developer/client/
26
+ Project-URL: Homepage, https://cybercentrecanada.github.io/howler-docs/
27
+ Project-URL: Repository, https://github.com/CybercentreCanada/howler-client
28
+ Description-Content-Type: text/markdown
29
+
30
+ # Howler Client Library
31
+
32
+ The Howler client library facilitates issuing requests to Howler.
33
+
34
+ ## Requirements
35
+
36
+ 1. Python 3.9 and up
37
+
38
+ ## Running the Tests
39
+
40
+ 1. Prepare the howler-api:
41
+ 1. Start dependencies
42
+ 1. `howler-api > python howler/app.py`
43
+ 1. `howler-api > python howler/odm/random_data.py`
44
+ 2. Run python integration tests:
45
+ 1. `python -m venv env`
46
+ 1. `. env/bin/activate`
47
+ 1. `pip install -r requirements.txt`
48
+ 1. `pip install -r test/requirements.txt`
49
+ 1. `pip install -e .`
50
+ 1. `pytest -s -v test`
51
+
52
+ ## \_sqlite3 error
53
+
54
+ You'll likely have to reinstall python3.9 while libsqlite3-dev is installed
55
+
56
+ 1. libsqlite3-dev
57
+ `sudo apt install libsqlite3-dev`
58
+ 2. Python3.9 with loadable-sqlite-extensions enabled
59
+ - `./configure --enable-loadable-sqlite-extensions --enable-optimizations`
60
+ - `make altinstall`
61
+
@@ -0,0 +1,31 @@
1
+ # Howler Client Library
2
+
3
+ The Howler client library facilitates issuing requests to Howler.
4
+
5
+ ## Requirements
6
+
7
+ 1. Python 3.9 and up
8
+
9
+ ## Running the Tests
10
+
11
+ 1. Prepare the howler-api:
12
+ 1. Start dependencies
13
+ 1. `howler-api > python howler/app.py`
14
+ 1. `howler-api > python howler/odm/random_data.py`
15
+ 2. Run python integration tests:
16
+ 1. `python -m venv env`
17
+ 1. `. env/bin/activate`
18
+ 1. `pip install -r requirements.txt`
19
+ 1. `pip install -r test/requirements.txt`
20
+ 1. `pip install -e .`
21
+ 1. `pytest -s -v test`
22
+
23
+ ## \_sqlite3 error
24
+
25
+ You'll likely have to reinstall python3.9 while libsqlite3-dev is installed
26
+
27
+ 1. libsqlite3-dev
28
+ `sudo apt install libsqlite3-dev`
29
+ 2. Python3.9 with loadable-sqlite-extensions enabled
30
+ - `./configure --enable-loadable-sqlite-extensions --enable-optimizations`
31
+ - `make altinstall`
@@ -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])
@@ -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)