pyoaev 1.18.20__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.
- docs/conf.py +65 -0
- pyoaev/__init__.py +26 -0
- pyoaev/_version.py +6 -0
- pyoaev/apis/__init__.py +20 -0
- pyoaev/apis/attack_pattern.py +28 -0
- pyoaev/apis/collector.py +29 -0
- pyoaev/apis/cve.py +18 -0
- pyoaev/apis/document.py +29 -0
- pyoaev/apis/endpoint.py +38 -0
- pyoaev/apis/inject.py +29 -0
- pyoaev/apis/inject_expectation/__init__.py +1 -0
- pyoaev/apis/inject_expectation/inject_expectation.py +118 -0
- pyoaev/apis/inject_expectation/model/__init__.py +7 -0
- pyoaev/apis/inject_expectation/model/expectation.py +173 -0
- pyoaev/apis/inject_expectation_trace.py +36 -0
- pyoaev/apis/injector.py +26 -0
- pyoaev/apis/injector_contract.py +56 -0
- pyoaev/apis/inputs/__init__.py +0 -0
- pyoaev/apis/inputs/search.py +72 -0
- pyoaev/apis/kill_chain_phase.py +22 -0
- pyoaev/apis/me.py +17 -0
- pyoaev/apis/organization.py +11 -0
- pyoaev/apis/payload.py +27 -0
- pyoaev/apis/security_platform.py +33 -0
- pyoaev/apis/tag.py +19 -0
- pyoaev/apis/team.py +25 -0
- pyoaev/apis/user.py +31 -0
- pyoaev/backends/__init__.py +14 -0
- pyoaev/backends/backend.py +136 -0
- pyoaev/backends/protocol.py +32 -0
- pyoaev/base.py +320 -0
- pyoaev/client.py +596 -0
- pyoaev/configuration/__init__.py +3 -0
- pyoaev/configuration/configuration.py +188 -0
- pyoaev/configuration/sources.py +44 -0
- pyoaev/contracts/__init__.py +5 -0
- pyoaev/contracts/contract_builder.py +44 -0
- pyoaev/contracts/contract_config.py +292 -0
- pyoaev/contracts/contract_utils.py +22 -0
- pyoaev/contracts/variable_helper.py +124 -0
- pyoaev/daemons/__init__.py +4 -0
- pyoaev/daemons/base_daemon.py +131 -0
- pyoaev/daemons/collector_daemon.py +91 -0
- pyoaev/exceptions.py +219 -0
- pyoaev/helpers.py +451 -0
- pyoaev/mixins.py +242 -0
- pyoaev/signatures/__init__.py +0 -0
- pyoaev/signatures/signature_match.py +12 -0
- pyoaev/signatures/signature_type.py +51 -0
- pyoaev/signatures/types.py +17 -0
- pyoaev/utils.py +211 -0
- pyoaev-1.18.20.dist-info/METADATA +134 -0
- pyoaev-1.18.20.dist-info/RECORD +72 -0
- pyoaev-1.18.20.dist-info/WHEEL +5 -0
- pyoaev-1.18.20.dist-info/licenses/LICENSE +201 -0
- pyoaev-1.18.20.dist-info/top_level.txt +4 -0
- scripts/release.py +127 -0
- test/__init__.py +0 -0
- test/apis/__init__.py +0 -0
- test/apis/expectation/__init__.py +0 -0
- test/apis/expectation/test_expectation.py +338 -0
- test/apis/injector_contract/__init__.py +0 -0
- test/apis/injector_contract/test_injector_contract.py +58 -0
- test/configuration/__init__.py +0 -0
- test/configuration/test_configuration.py +257 -0
- test/configuration/test_sources.py +69 -0
- test/daemons/__init__.py +0 -0
- test/daemons/test_base_daemon.py +109 -0
- test/daemons/test_collector_daemon.py +39 -0
- test/signatures/__init__.py +0 -0
- test/signatures/test_signature_match.py +25 -0
- test/signatures/test_signature_type.py +57 -0
pyoaev/client.py
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, Optional, Union
|
|
2
|
+
from urllib import parse
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from pyoaev import exceptions, utils
|
|
7
|
+
from pyoaev._version import __version__ # noqa: F401
|
|
8
|
+
|
|
9
|
+
REDIRECT_MSG = (
|
|
10
|
+
"pyoaev detected a {status_code} ({reason!r}) redirection. You must update "
|
|
11
|
+
"your OpenAEV URL to the correct URL to avoid issues. The redirection was from: "
|
|
12
|
+
"{source!r} to {target!r}"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenAEV:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
url: str,
|
|
20
|
+
token: str,
|
|
21
|
+
timeout: Optional[float] = None,
|
|
22
|
+
per_page: Optional[int] = None,
|
|
23
|
+
pagination: Optional[str] = None,
|
|
24
|
+
order_by: Optional[str] = None,
|
|
25
|
+
ssl_verify: Union[bool, str] = True,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> None:
|
|
28
|
+
|
|
29
|
+
if url is None or len(url) == 0:
|
|
30
|
+
raise ValueError("An URL must be set")
|
|
31
|
+
if token is None or len(token) == 0 or token == "ChangeMe":
|
|
32
|
+
raise ValueError("A TOKEN must be set")
|
|
33
|
+
|
|
34
|
+
self.url = url
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
#: Headers that will be used in request to OpenAEV
|
|
37
|
+
self.headers = {
|
|
38
|
+
"User-Agent": "pyoaev/" + __version__,
|
|
39
|
+
"Authorization": "Bearer " + token,
|
|
40
|
+
}
|
|
41
|
+
#: Whether SSL certificates should be validated
|
|
42
|
+
self.ssl_verify = ssl_verify
|
|
43
|
+
|
|
44
|
+
# Import backends
|
|
45
|
+
from pyoaev import backends
|
|
46
|
+
|
|
47
|
+
self.backend = backends.RequestsBackend(**kwargs)
|
|
48
|
+
self._auth = backends.TokenAuth(token)
|
|
49
|
+
self.session = self.backend.client
|
|
50
|
+
|
|
51
|
+
self.per_page = per_page
|
|
52
|
+
self.pagination = pagination
|
|
53
|
+
self.order_by = order_by
|
|
54
|
+
|
|
55
|
+
# Import all apis
|
|
56
|
+
from pyoaev import apis
|
|
57
|
+
|
|
58
|
+
self.me = apis.MeManager(self)
|
|
59
|
+
self.organization = apis.OrganizationManager(self)
|
|
60
|
+
self.injector = apis.InjectorManager(self)
|
|
61
|
+
self.collector = apis.CollectorManager(self)
|
|
62
|
+
self.cve = apis.CveManager(self)
|
|
63
|
+
self.inject = apis.InjectManager(self)
|
|
64
|
+
self.injector_contract = apis.InjectorContractManager(self)
|
|
65
|
+
self.document = apis.DocumentManager(self)
|
|
66
|
+
self.kill_chain_phase = apis.KillChainPhaseManager(self)
|
|
67
|
+
self.attack_pattern = apis.AttackPatternManager(self)
|
|
68
|
+
self.team = apis.TeamManager(self)
|
|
69
|
+
self.endpoint = apis.EndpointManager(self)
|
|
70
|
+
self.user = apis.UserManager(self)
|
|
71
|
+
self.inject_expectation = apis.InjectExpectationManager(self)
|
|
72
|
+
self.payload = apis.PayloadManager(self)
|
|
73
|
+
self.security_platform = apis.SecurityPlatformManager(self)
|
|
74
|
+
self.inject_expectation_trace = apis.InjectExpectationTraceManager(self)
|
|
75
|
+
self.tag = apis.TagManager(self)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _check_redirects(result: requests.Response) -> None:
|
|
79
|
+
# Check the requests history to detect 301/302 redirections.
|
|
80
|
+
# If the initial verb is POST or PUT, the redirected request will use a
|
|
81
|
+
# GET request, leading to unwanted behaviour.
|
|
82
|
+
# If we detect a redirection with a POST or a PUT request, we
|
|
83
|
+
# raise an exception with a useful error message.
|
|
84
|
+
if not result.history:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
for item in result.history:
|
|
88
|
+
if item.status_code not in (301, 302):
|
|
89
|
+
continue
|
|
90
|
+
# GET methods can be redirected without issue
|
|
91
|
+
if item.request.method == "GET":
|
|
92
|
+
continue
|
|
93
|
+
target = item.headers.get("location")
|
|
94
|
+
raise exceptions.RedirectError(
|
|
95
|
+
REDIRECT_MSG.format(
|
|
96
|
+
status_code=item.status_code,
|
|
97
|
+
reason=item.reason,
|
|
98
|
+
source=item.url,
|
|
99
|
+
target=target,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _build_url(self, path: str) -> str:
|
|
104
|
+
"""Returns the full url from path.
|
|
105
|
+
|
|
106
|
+
If path is already a url, return it unchanged. If it's a path, append
|
|
107
|
+
it to the stored url.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The full URL
|
|
111
|
+
"""
|
|
112
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
113
|
+
return path
|
|
114
|
+
return f"{self.url}/api{path}"
|
|
115
|
+
|
|
116
|
+
def _get_session_opts(self) -> Dict[str, Any]:
|
|
117
|
+
return {
|
|
118
|
+
"headers": self.headers.copy(),
|
|
119
|
+
"auth": self._auth,
|
|
120
|
+
"timeout": self.timeout,
|
|
121
|
+
"verify": self.ssl_verify,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def http_request(
|
|
125
|
+
self,
|
|
126
|
+
verb: str,
|
|
127
|
+
path: str,
|
|
128
|
+
query_data: Optional[Dict[str, Any]] = None,
|
|
129
|
+
post_data: Optional[Union[Dict[str, Any], bytes, BinaryIO]] = None,
|
|
130
|
+
raw: bool = False,
|
|
131
|
+
streamed: bool = False,
|
|
132
|
+
files: Optional[Dict[str, Any]] = None,
|
|
133
|
+
timeout: Optional[float] = None,
|
|
134
|
+
**kwargs: Any,
|
|
135
|
+
) -> requests.Response:
|
|
136
|
+
"""Make an HTTP request to the OpenAEV server.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
verb: The HTTP method to call ('get', 'post', 'put', 'delete')
|
|
140
|
+
path: Path or full URL to query ('/projects' or
|
|
141
|
+
'http://whatever/v4/api/projecs')
|
|
142
|
+
query_data: Data to send as query parameters
|
|
143
|
+
post_data: Data to send in the body (will be converted to
|
|
144
|
+
json by default)
|
|
145
|
+
raw: If True, do not convert post_data to json
|
|
146
|
+
streamed: Whether the data should be streamed
|
|
147
|
+
files: The files to send to the server
|
|
148
|
+
timeout: The timeout, in seconds, for the request
|
|
149
|
+
**kwargs: Extra options to send to the server (e.g. sudo)
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
A requests result object.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
OpenAEVHttpError: When the return code is not 2xx
|
|
156
|
+
"""
|
|
157
|
+
query_data = query_data or {}
|
|
158
|
+
raw_url = self._build_url(path)
|
|
159
|
+
|
|
160
|
+
# parse user-provided URL params to ensure we don't add our own duplicates
|
|
161
|
+
parsed = parse.urlparse(raw_url)
|
|
162
|
+
params = parse.parse_qs(parsed.query)
|
|
163
|
+
utils.copy_dict(src=query_data, dest=params)
|
|
164
|
+
|
|
165
|
+
url = parse.urlunparse(parsed._replace(query=""))
|
|
166
|
+
|
|
167
|
+
if "query_parameters" in kwargs:
|
|
168
|
+
utils.copy_dict(src=kwargs["query_parameters"], dest=params)
|
|
169
|
+
for arg in ("per_page", "page"):
|
|
170
|
+
if arg in kwargs:
|
|
171
|
+
params[arg] = kwargs[arg]
|
|
172
|
+
else:
|
|
173
|
+
utils.copy_dict(src=kwargs, dest=params)
|
|
174
|
+
|
|
175
|
+
opts = self._get_session_opts()
|
|
176
|
+
|
|
177
|
+
verify = opts.pop("verify")
|
|
178
|
+
opts_timeout = opts.pop("timeout")
|
|
179
|
+
# If timeout was passed into kwargs, allow it to override the default
|
|
180
|
+
if timeout is None:
|
|
181
|
+
timeout = opts_timeout
|
|
182
|
+
|
|
183
|
+
# We need to deal with json vs. data when uploading files
|
|
184
|
+
send_data = self.backend.prepare_send_data(files, post_data, raw)
|
|
185
|
+
opts["headers"]["Content-type"] = send_data.content_type
|
|
186
|
+
|
|
187
|
+
# cur_retries = 0
|
|
188
|
+
while True:
|
|
189
|
+
# noinspection PyTypeChecker
|
|
190
|
+
result = self.backend.http_request(
|
|
191
|
+
method=verb,
|
|
192
|
+
url=url,
|
|
193
|
+
json=send_data.json,
|
|
194
|
+
data=send_data.data,
|
|
195
|
+
params=params,
|
|
196
|
+
timeout=timeout,
|
|
197
|
+
verify=verify,
|
|
198
|
+
stream=streamed,
|
|
199
|
+
**opts,
|
|
200
|
+
)
|
|
201
|
+
self._check_redirects(result.response)
|
|
202
|
+
|
|
203
|
+
if 200 <= result.status_code < 300:
|
|
204
|
+
return result.response
|
|
205
|
+
|
|
206
|
+
# Extract a meaningful error message from the server response
|
|
207
|
+
error_message: Any = None
|
|
208
|
+
|
|
209
|
+
# First, try to get the raw text content
|
|
210
|
+
try:
|
|
211
|
+
raw_text = result.content.decode("utf-8", errors="ignore").strip()
|
|
212
|
+
# If it's a simple text message (not JSON), use it directly
|
|
213
|
+
if (
|
|
214
|
+
raw_text
|
|
215
|
+
and not raw_text.startswith("{")
|
|
216
|
+
and not raw_text.startswith("[")
|
|
217
|
+
):
|
|
218
|
+
error_message = raw_text[:500]
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
# If we don't have a message yet, try JSON parsing
|
|
223
|
+
if not error_message:
|
|
224
|
+
try:
|
|
225
|
+
error_json = result.json()
|
|
226
|
+
# Common fields
|
|
227
|
+
if isinstance(error_json, dict):
|
|
228
|
+
# Check for nested validation errors first (more specific)
|
|
229
|
+
if "errors" in error_json:
|
|
230
|
+
errs = error_json.get("errors")
|
|
231
|
+
if isinstance(errs, list) and errs:
|
|
232
|
+
# Join any messages in the list
|
|
233
|
+
messages = []
|
|
234
|
+
for item in errs:
|
|
235
|
+
if isinstance(item, dict) and "message" in item:
|
|
236
|
+
messages.append(str(item.get("message")))
|
|
237
|
+
else:
|
|
238
|
+
messages.append(str(item))
|
|
239
|
+
error_message = "; ".join(messages)
|
|
240
|
+
elif isinstance(errs, dict):
|
|
241
|
+
# Handle nested validation errors from OpenAEV
|
|
242
|
+
if "children" in errs:
|
|
243
|
+
# This is a validation error structure
|
|
244
|
+
validation_errors = []
|
|
245
|
+
children = errs.get("children", {})
|
|
246
|
+
for field, field_errors in children.items():
|
|
247
|
+
if (
|
|
248
|
+
isinstance(field_errors, dict)
|
|
249
|
+
and "errors" in field_errors
|
|
250
|
+
):
|
|
251
|
+
field_error_list = field_errors.get(
|
|
252
|
+
"errors", []
|
|
253
|
+
)
|
|
254
|
+
if field_error_list:
|
|
255
|
+
for err_msg in field_error_list:
|
|
256
|
+
validation_errors.append(
|
|
257
|
+
f"{field}: {err_msg}"
|
|
258
|
+
)
|
|
259
|
+
if validation_errors:
|
|
260
|
+
base_msg = error_json.get(
|
|
261
|
+
"message", "Validation Failed"
|
|
262
|
+
)
|
|
263
|
+
error_message = f"{base_msg}: {'; '.join(validation_errors)}"
|
|
264
|
+
elif isinstance(errs, str):
|
|
265
|
+
error_message = errs
|
|
266
|
+
|
|
267
|
+
# If no error message from errors field, check other fields
|
|
268
|
+
if not error_message:
|
|
269
|
+
if "message" in error_json:
|
|
270
|
+
error_message = error_json.get("message")
|
|
271
|
+
elif "execution_message" in error_json:
|
|
272
|
+
error_message = error_json.get("execution_message")
|
|
273
|
+
elif "error" in error_json:
|
|
274
|
+
err = error_json.get("error")
|
|
275
|
+
if isinstance(err, dict) and "message" in err:
|
|
276
|
+
error_message = err.get("message")
|
|
277
|
+
elif err and err not in [
|
|
278
|
+
"Internal Server Error",
|
|
279
|
+
"Bad Request",
|
|
280
|
+
"Not Found",
|
|
281
|
+
"Unauthorized",
|
|
282
|
+
"Forbidden",
|
|
283
|
+
]:
|
|
284
|
+
# Only use 'error' field if it's not a generic HTTP status
|
|
285
|
+
error_message = str(err)
|
|
286
|
+
elif isinstance(error_json, str):
|
|
287
|
+
error_message = error_json
|
|
288
|
+
# Fallback to serialized json if we still have nothing
|
|
289
|
+
if not error_message:
|
|
290
|
+
error_message = utils.json_dumps(error_json)[:500]
|
|
291
|
+
except Exception:
|
|
292
|
+
# If JSON parsing fails, use the raw text we might have
|
|
293
|
+
if not error_message:
|
|
294
|
+
try:
|
|
295
|
+
error_message = result.response.text[:500]
|
|
296
|
+
except Exception:
|
|
297
|
+
try:
|
|
298
|
+
error_message = result.content.decode(errors="ignore")[
|
|
299
|
+
:500
|
|
300
|
+
]
|
|
301
|
+
except Exception:
|
|
302
|
+
error_message = str(result.content)[:500]
|
|
303
|
+
|
|
304
|
+
# If still no message or a generic HTTP status, use status text
|
|
305
|
+
if not error_message or error_message == result.response.reason:
|
|
306
|
+
error_message = result.response.reason or "Unknown error"
|
|
307
|
+
|
|
308
|
+
if result.status_code == 401:
|
|
309
|
+
raise exceptions.OpenAEVAuthenticationError(
|
|
310
|
+
response_code=result.status_code,
|
|
311
|
+
error_message=error_message or "Authentication failed",
|
|
312
|
+
response_body=result.content,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Use the extracted error message, not the HTTP reason
|
|
316
|
+
final_error_message = error_message
|
|
317
|
+
if not final_error_message or final_error_message == result.response.reason:
|
|
318
|
+
# Only use HTTP reason as last resort
|
|
319
|
+
final_error_message = result.response.reason or "Unknown error"
|
|
320
|
+
|
|
321
|
+
raise exceptions.OpenAEVHttpError(
|
|
322
|
+
response_code=result.status_code,
|
|
323
|
+
error_message=final_error_message,
|
|
324
|
+
response_body=result.content,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def http_get(
|
|
328
|
+
self,
|
|
329
|
+
path: str,
|
|
330
|
+
query_data: Optional[Dict[str, Any]] = None,
|
|
331
|
+
streamed: bool = False,
|
|
332
|
+
raw: bool = False,
|
|
333
|
+
**kwargs: Any,
|
|
334
|
+
) -> Union[Dict[str, Any], requests.Response]:
|
|
335
|
+
query_data = query_data or {}
|
|
336
|
+
result = self.http_request(
|
|
337
|
+
"get", path, query_data=query_data, streamed=streamed, **kwargs
|
|
338
|
+
)
|
|
339
|
+
content_type = utils.get_content_type(result.headers.get("Content-Type"))
|
|
340
|
+
|
|
341
|
+
if content_type == "application/json" and not streamed and not raw:
|
|
342
|
+
try:
|
|
343
|
+
json_result = result.json()
|
|
344
|
+
return json_result
|
|
345
|
+
except Exception as e:
|
|
346
|
+
raise exceptions.OpenAEVParsingError(
|
|
347
|
+
error_message="Failed to parse the server message"
|
|
348
|
+
) from e
|
|
349
|
+
else:
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
def http_head(
|
|
353
|
+
self, path: str, query_data: Optional[Dict[str, Any]] = None, **kwargs: Any
|
|
354
|
+
) -> "requests.structures.CaseInsensitiveDict[Any]":
|
|
355
|
+
query_data = query_data or {}
|
|
356
|
+
result = self.http_request("head", path, query_data=query_data, **kwargs)
|
|
357
|
+
return result.headers
|
|
358
|
+
|
|
359
|
+
def http_post(
|
|
360
|
+
self,
|
|
361
|
+
path: str,
|
|
362
|
+
query_data: Optional[Dict[str, Any]] = None,
|
|
363
|
+
post_data: Optional[Dict[str, Any]] = None,
|
|
364
|
+
raw: bool = False,
|
|
365
|
+
files: Optional[Dict[str, Any]] = None,
|
|
366
|
+
**kwargs: Any,
|
|
367
|
+
) -> Union[Dict[str, Any], requests.Response]:
|
|
368
|
+
query_data = query_data or {}
|
|
369
|
+
post_data = post_data or {}
|
|
370
|
+
result = self.http_request(
|
|
371
|
+
"post",
|
|
372
|
+
path,
|
|
373
|
+
query_data=query_data,
|
|
374
|
+
post_data=post_data,
|
|
375
|
+
files=files,
|
|
376
|
+
raw=raw,
|
|
377
|
+
**kwargs,
|
|
378
|
+
)
|
|
379
|
+
content_type = utils.get_content_type(result.headers.get("Content-Type"))
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
if content_type == "application/json":
|
|
383
|
+
json_result = result.json()
|
|
384
|
+
return json_result
|
|
385
|
+
except Exception as e:
|
|
386
|
+
raise exceptions.OpenAEVParsingError(
|
|
387
|
+
error_message="Failed to parse the server message"
|
|
388
|
+
) from e
|
|
389
|
+
return result
|
|
390
|
+
|
|
391
|
+
def http_put(
|
|
392
|
+
self,
|
|
393
|
+
path: str,
|
|
394
|
+
query_data: Optional[Dict[str, Any]] = None,
|
|
395
|
+
post_data: Optional[Union[Dict[str, Any], bytes, BinaryIO]] = None,
|
|
396
|
+
raw: bool = False,
|
|
397
|
+
files: Optional[Dict[str, Any]] = None,
|
|
398
|
+
**kwargs: Any,
|
|
399
|
+
) -> Union[Dict[str, Any], requests.Response]:
|
|
400
|
+
query_data = query_data or {}
|
|
401
|
+
post_data = post_data or {}
|
|
402
|
+
result = self.http_request(
|
|
403
|
+
"put",
|
|
404
|
+
path,
|
|
405
|
+
query_data=query_data,
|
|
406
|
+
post_data=post_data,
|
|
407
|
+
files=files,
|
|
408
|
+
raw=raw,
|
|
409
|
+
**kwargs,
|
|
410
|
+
)
|
|
411
|
+
try:
|
|
412
|
+
json_result = result.json()
|
|
413
|
+
if TYPE_CHECKING:
|
|
414
|
+
assert isinstance(json_result, dict)
|
|
415
|
+
return json_result
|
|
416
|
+
except Exception as e:
|
|
417
|
+
raise exceptions.OpenAEVParsingError(
|
|
418
|
+
error_message="Failed to parse the server message"
|
|
419
|
+
) from e
|
|
420
|
+
|
|
421
|
+
def http_patch(
|
|
422
|
+
self,
|
|
423
|
+
path: str,
|
|
424
|
+
*,
|
|
425
|
+
query_data: Optional[Dict[str, Any]] = None,
|
|
426
|
+
post_data: Optional[Union[Dict[str, Any], bytes]] = None,
|
|
427
|
+
raw: bool = False,
|
|
428
|
+
**kwargs: Any,
|
|
429
|
+
) -> Union[Dict[str, Any], requests.Response]:
|
|
430
|
+
query_data = query_data or {}
|
|
431
|
+
post_data = post_data or {}
|
|
432
|
+
|
|
433
|
+
result = self.http_request(
|
|
434
|
+
"patch",
|
|
435
|
+
path,
|
|
436
|
+
query_data=query_data,
|
|
437
|
+
post_data=post_data,
|
|
438
|
+
raw=raw,
|
|
439
|
+
**kwargs,
|
|
440
|
+
)
|
|
441
|
+
try:
|
|
442
|
+
json_result = result.json()
|
|
443
|
+
if TYPE_CHECKING:
|
|
444
|
+
assert isinstance(json_result, dict)
|
|
445
|
+
return json_result
|
|
446
|
+
except Exception as e:
|
|
447
|
+
raise exceptions.OpenAEVParsingError(
|
|
448
|
+
error_message="Failed to parse the server message"
|
|
449
|
+
) from e
|
|
450
|
+
|
|
451
|
+
def http_delete(self, path: str, **kwargs: Any) -> requests.Response:
|
|
452
|
+
return self.http_request("delete", path, **kwargs)
|
|
453
|
+
|
|
454
|
+
def http_list(
|
|
455
|
+
self,
|
|
456
|
+
path: str,
|
|
457
|
+
query_data: Optional[Dict[str, Any]] = None,
|
|
458
|
+
*,
|
|
459
|
+
iterator: Optional[bool] = None,
|
|
460
|
+
**kwargs: Any,
|
|
461
|
+
) -> Union["OpenAEVList", List[Dict[str, Any]]]:
|
|
462
|
+
query_data = query_data or {}
|
|
463
|
+
|
|
464
|
+
url = self._build_url(path)
|
|
465
|
+
|
|
466
|
+
page = kwargs.get("page")
|
|
467
|
+
|
|
468
|
+
if iterator and page is None:
|
|
469
|
+
# Generator requested
|
|
470
|
+
return OpenAEVList(self, url, query_data, **kwargs)
|
|
471
|
+
|
|
472
|
+
# pagination requested, we return a list
|
|
473
|
+
bas_list = OpenAEVList(self, url, query_data, get_next=False, **kwargs)
|
|
474
|
+
items = list(bas_list)
|
|
475
|
+
return items
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class OpenAEVList:
|
|
479
|
+
"""Generator representing a list of remote objects.
|
|
480
|
+
|
|
481
|
+
The object handles the links returned by a query to the API, and will call
|
|
482
|
+
the API again when needed.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
def __init__(
|
|
486
|
+
self,
|
|
487
|
+
openaev: OpenAEV,
|
|
488
|
+
url: str,
|
|
489
|
+
query_data: Dict[str, Any],
|
|
490
|
+
get_next: bool = True,
|
|
491
|
+
**kwargs: Any,
|
|
492
|
+
) -> None:
|
|
493
|
+
self._openaev = openaev
|
|
494
|
+
|
|
495
|
+
# Preserve kwargs for subsequent queries
|
|
496
|
+
self._kwargs = kwargs.copy()
|
|
497
|
+
|
|
498
|
+
self._query(url, query_data, **self._kwargs)
|
|
499
|
+
self._get_next = get_next
|
|
500
|
+
|
|
501
|
+
# Remove query_parameters from kwargs, which are saved via the `next` URL
|
|
502
|
+
self._kwargs.pop("query_parameters", None)
|
|
503
|
+
|
|
504
|
+
def _query(
|
|
505
|
+
self, url: str, query_data: Optional[Dict[str, Any]] = None, **kwargs: Any
|
|
506
|
+
) -> None:
|
|
507
|
+
query_data = query_data or {}
|
|
508
|
+
result = self._openaev.http_request("get", url, query_data=query_data, **kwargs)
|
|
509
|
+
try:
|
|
510
|
+
next_url = result.links["next"]["url"]
|
|
511
|
+
except KeyError:
|
|
512
|
+
next_url = None
|
|
513
|
+
|
|
514
|
+
self._next_url = next_url
|
|
515
|
+
self._current_page: Optional[str] = result.headers.get("X-Page")
|
|
516
|
+
self._prev_page: Optional[str] = result.headers.get("X-Prev-Page")
|
|
517
|
+
self._next_page: Optional[str] = result.headers.get("X-Next-Page")
|
|
518
|
+
self._per_page: Optional[str] = result.headers.get("X-Per-Page")
|
|
519
|
+
self._total_pages: Optional[str] = result.headers.get("X-Total-Pages")
|
|
520
|
+
self._total: Optional[str] = result.headers.get("X-Total")
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
self._data: List[Dict[str, Any]] = result.json()
|
|
524
|
+
except Exception as e:
|
|
525
|
+
raise exceptions.OpenAEVParsingError(
|
|
526
|
+
error_message="Failed to parse the server message"
|
|
527
|
+
) from e
|
|
528
|
+
|
|
529
|
+
self._current = 0
|
|
530
|
+
|
|
531
|
+
@property
|
|
532
|
+
def current_page(self) -> int:
|
|
533
|
+
"""The current page number."""
|
|
534
|
+
if TYPE_CHECKING:
|
|
535
|
+
assert self._current_page is not None
|
|
536
|
+
return int(self._current_page)
|
|
537
|
+
|
|
538
|
+
@property
|
|
539
|
+
def prev_page(self) -> Optional[int]:
|
|
540
|
+
"""The previous page number.
|
|
541
|
+
|
|
542
|
+
If None, the current page is the first.
|
|
543
|
+
"""
|
|
544
|
+
return int(self._prev_page) if self._prev_page else None
|
|
545
|
+
|
|
546
|
+
@property
|
|
547
|
+
def next_page(self) -> Optional[int]:
|
|
548
|
+
"""The next page number.
|
|
549
|
+
|
|
550
|
+
If None, the current page is the last.
|
|
551
|
+
"""
|
|
552
|
+
return int(self._next_page) if self._next_page else None
|
|
553
|
+
|
|
554
|
+
@property
|
|
555
|
+
def per_page(self) -> Optional[int]:
|
|
556
|
+
"""The number of items per page."""
|
|
557
|
+
return int(self._per_page) if self._per_page is not None else None
|
|
558
|
+
|
|
559
|
+
@property
|
|
560
|
+
def total_pages(self) -> Optional[int]:
|
|
561
|
+
"""The total number of pages."""
|
|
562
|
+
if self._total_pages is not None:
|
|
563
|
+
return int(self._total_pages)
|
|
564
|
+
return None
|
|
565
|
+
|
|
566
|
+
@property
|
|
567
|
+
def total(self) -> Optional[int]:
|
|
568
|
+
"""The total number of items."""
|
|
569
|
+
if self._total is not None:
|
|
570
|
+
return int(self._total)
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
def __iter__(self) -> "OpenAEVList":
|
|
574
|
+
return self
|
|
575
|
+
|
|
576
|
+
def __len__(self) -> int:
|
|
577
|
+
if self._total is None:
|
|
578
|
+
return 0
|
|
579
|
+
return int(self._total)
|
|
580
|
+
|
|
581
|
+
def __next__(self) -> Dict[str, Any]:
|
|
582
|
+
return self.next()
|
|
583
|
+
|
|
584
|
+
def next(self) -> Dict[str, Any]:
|
|
585
|
+
try:
|
|
586
|
+
item = self._data[self._current]
|
|
587
|
+
self._current += 1
|
|
588
|
+
return item
|
|
589
|
+
except IndexError:
|
|
590
|
+
pass
|
|
591
|
+
|
|
592
|
+
if self._next_url and self._get_next is True:
|
|
593
|
+
self._query(self._next_url, **self._kwargs)
|
|
594
|
+
return self.next()
|
|
595
|
+
|
|
596
|
+
raise StopIteration
|