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.
Files changed (72) hide show
  1. docs/conf.py +65 -0
  2. pyoaev/__init__.py +26 -0
  3. pyoaev/_version.py +6 -0
  4. pyoaev/apis/__init__.py +20 -0
  5. pyoaev/apis/attack_pattern.py +28 -0
  6. pyoaev/apis/collector.py +29 -0
  7. pyoaev/apis/cve.py +18 -0
  8. pyoaev/apis/document.py +29 -0
  9. pyoaev/apis/endpoint.py +38 -0
  10. pyoaev/apis/inject.py +29 -0
  11. pyoaev/apis/inject_expectation/__init__.py +1 -0
  12. pyoaev/apis/inject_expectation/inject_expectation.py +118 -0
  13. pyoaev/apis/inject_expectation/model/__init__.py +7 -0
  14. pyoaev/apis/inject_expectation/model/expectation.py +173 -0
  15. pyoaev/apis/inject_expectation_trace.py +36 -0
  16. pyoaev/apis/injector.py +26 -0
  17. pyoaev/apis/injector_contract.py +56 -0
  18. pyoaev/apis/inputs/__init__.py +0 -0
  19. pyoaev/apis/inputs/search.py +72 -0
  20. pyoaev/apis/kill_chain_phase.py +22 -0
  21. pyoaev/apis/me.py +17 -0
  22. pyoaev/apis/organization.py +11 -0
  23. pyoaev/apis/payload.py +27 -0
  24. pyoaev/apis/security_platform.py +33 -0
  25. pyoaev/apis/tag.py +19 -0
  26. pyoaev/apis/team.py +25 -0
  27. pyoaev/apis/user.py +31 -0
  28. pyoaev/backends/__init__.py +14 -0
  29. pyoaev/backends/backend.py +136 -0
  30. pyoaev/backends/protocol.py +32 -0
  31. pyoaev/base.py +320 -0
  32. pyoaev/client.py +596 -0
  33. pyoaev/configuration/__init__.py +3 -0
  34. pyoaev/configuration/configuration.py +188 -0
  35. pyoaev/configuration/sources.py +44 -0
  36. pyoaev/contracts/__init__.py +5 -0
  37. pyoaev/contracts/contract_builder.py +44 -0
  38. pyoaev/contracts/contract_config.py +292 -0
  39. pyoaev/contracts/contract_utils.py +22 -0
  40. pyoaev/contracts/variable_helper.py +124 -0
  41. pyoaev/daemons/__init__.py +4 -0
  42. pyoaev/daemons/base_daemon.py +131 -0
  43. pyoaev/daemons/collector_daemon.py +91 -0
  44. pyoaev/exceptions.py +219 -0
  45. pyoaev/helpers.py +451 -0
  46. pyoaev/mixins.py +242 -0
  47. pyoaev/signatures/__init__.py +0 -0
  48. pyoaev/signatures/signature_match.py +12 -0
  49. pyoaev/signatures/signature_type.py +51 -0
  50. pyoaev/signatures/types.py +17 -0
  51. pyoaev/utils.py +211 -0
  52. pyoaev-1.18.20.dist-info/METADATA +134 -0
  53. pyoaev-1.18.20.dist-info/RECORD +72 -0
  54. pyoaev-1.18.20.dist-info/WHEEL +5 -0
  55. pyoaev-1.18.20.dist-info/licenses/LICENSE +201 -0
  56. pyoaev-1.18.20.dist-info/top_level.txt +4 -0
  57. scripts/release.py +127 -0
  58. test/__init__.py +0 -0
  59. test/apis/__init__.py +0 -0
  60. test/apis/expectation/__init__.py +0 -0
  61. test/apis/expectation/test_expectation.py +338 -0
  62. test/apis/injector_contract/__init__.py +0 -0
  63. test/apis/injector_contract/test_injector_contract.py +58 -0
  64. test/configuration/__init__.py +0 -0
  65. test/configuration/test_configuration.py +257 -0
  66. test/configuration/test_sources.py +69 -0
  67. test/daemons/__init__.py +0 -0
  68. test/daemons/test_base_daemon.py +109 -0
  69. test/daemons/test_collector_daemon.py +39 -0
  70. test/signatures/__init__.py +0 -0
  71. test/signatures/test_signature_match.py +25 -0
  72. 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
@@ -0,0 +1,3 @@
1
+ from .configuration import Configuration
2
+
3
+ __all__ = ["Configuration"]