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.
- howler_client-2.4.0.dev37/LICENSE +23 -0
- howler_client-2.4.0.dev37/PKG-INFO +61 -0
- howler_client-2.4.0.dev37/README.md +31 -0
- howler_client-2.4.0.dev37/howler_client/__init__.py +46 -0
- howler_client-2.4.0.dev37/howler_client/client.py +32 -0
- howler_client-2.4.0.dev37/howler_client/common/__init__.py +0 -0
- howler_client-2.4.0.dev37/howler_client/common/dict_utils.py +138 -0
- howler_client-2.4.0.dev37/howler_client/common/utils.py +113 -0
- howler_client-2.4.0.dev37/howler_client/connection.py +204 -0
- howler_client-2.4.0.dev37/howler_client/logger.py +14 -0
- howler_client-2.4.0.dev37/howler_client/module/__init__.py +0 -0
- howler_client-2.4.0.dev37/howler_client/module/bundle.py +132 -0
- howler_client-2.4.0.dev37/howler_client/module/comment.py +59 -0
- howler_client-2.4.0.dev37/howler_client/module/help.py +23 -0
- howler_client-2.4.0.dev37/howler_client/module/hit.py +299 -0
- howler_client-2.4.0.dev37/howler_client/module/search/__init__.py +84 -0
- howler_client-2.4.0.dev37/howler_client/module/search/chunk.py +38 -0
- howler_client-2.4.0.dev37/howler_client/module/search/facet.py +41 -0
- howler_client-2.4.0.dev37/howler_client/module/search/fields.py +19 -0
- howler_client-2.4.0.dev37/howler_client/module/search/grouped.py +67 -0
- howler_client-2.4.0.dev37/howler_client/module/search/histogram.py +63 -0
- howler_client-2.4.0.dev37/howler_client/module/search/stats.py +39 -0
- howler_client-2.4.0.dev37/howler_client/module/search/stream.py +81 -0
- howler_client-2.4.0.dev37/howler_client/module/user.py +97 -0
- howler_client-2.4.0.dev37/howler_client/utils/__init__.py +0 -0
- howler_client-2.4.0.dev37/howler_client/utils/json_encoders.py +36 -0
- 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])
|
|
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
|