UGOS 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ugos/__about__.py +6 -0
- ugos/__init__.py +92 -0
- ugos/_auth.py +293 -0
- ugos/_client_base.py +52 -0
- ugos/_client_calls.py +200 -0
- ugos/_client_helpers.py +217 -0
- ugos/_client_lifecycle.py +144 -0
- ugos/_client_resources.py +192 -0
- ugos/_client_transfer.py +501 -0
- ugos/_codec.py +528 -0
- ugos/_config.py +183 -0
- ugos/_data/__init__.py +1 -0
- ugos/_data/action_contracts.json +325 -0
- ugos/_data/catalog.json +1256 -0
- ugos/_dynamic.py +221 -0
- ugos/_encoding.py +159 -0
- ugos/_pagination.py +158 -0
- ugos/_redaction.py +158 -0
- ugos/_transport.py +446 -0
- ugos/catalog.py +184 -0
- ugos/client.py +113 -0
- ugos/exceptions.py +137 -0
- ugos/models/__init__.py +37 -0
- ugos/models/common.py +87 -0
- ugos/models/entities.py +124 -0
- ugos/models/users.py +43 -0
- ugos/py.typed +0 -0
- ugos/resources/__init__.py +55 -0
- ugos/resources/administrators.py +71 -0
- ugos/resources/application.py +107 -0
- ugos/resources/base.py +38 -0
- ugos/resources/certificates.py +62 -0
- ugos/resources/contentrules.py +98 -0
- ugos/resources/contenttypes.py +9 -0
- ugos/resources/fwrules.py +103 -0
- ugos/resources/generic.py +283 -0
- ugos/resources/generic_controllers.py +169 -0
- ugos/resources/groups.py +94 -0
- ugos/resources/icap.py +132 -0
- ugos/resources/idps.py +125 -0
- ugos/resources/interfaces.py +40 -0
- ugos/resources/l7.py +110 -0
- ugos/resources/licensing.py +39 -0
- ugos/resources/mailsecurity.py +101 -0
- ugos/resources/networks.py +82 -0
- ugos/resources/netzones.py +78 -0
- ugos/resources/nlist.py +72 -0
- ugos/resources/overridedomains.py +47 -0
- ugos/resources/overrides.py +109 -0
- ugos/resources/overrides_content.py +254 -0
- ugos/resources/overrides_content_a.py +187 -0
- ugos/resources/overrides_lists.py +294 -0
- ugos/resources/overrides_misc.py +342 -0
- ugos/resources/overrides_network.py +291 -0
- ugos/resources/pcap.py +64 -0
- ugos/resources/registry.py +55 -0
- ugos/resources/settings.py +72 -0
- ugos/resources/sysinfo.py +39 -0
- ugos/resources/twofa.py +62 -0
- ugos/resources/urlcategory.py +69 -0
- ugos/resources/urlfiltering.py +23 -0
- ugos/resources/urllists.py +9 -0
- ugos/resources/users.py +74 -0
- ugos-1.0.0.dist-info/METADATA +139 -0
- ugos-1.0.0.dist-info/RECORD +67 -0
- ugos-1.0.0.dist-info/WHEEL +4 -0
- ugos-1.0.0.dist-info/licenses/LICENSE +16 -0
ugos/_dynamic.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Dynamic access to every controller action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from collections.abc import Iterator, Mapping
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from .exceptions import UGOSError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ActionCaller:
|
|
13
|
+
"""Callable for one exact ``controller/action`` pair."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
invoker: Any,
|
|
18
|
+
controller: str,
|
|
19
|
+
action: str,
|
|
20
|
+
*,
|
|
21
|
+
strict: bool | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._invoker = invoker
|
|
24
|
+
self.controller = controller
|
|
25
|
+
self.action = action
|
|
26
|
+
self._strict = strict
|
|
27
|
+
|
|
28
|
+
def __call__(
|
|
29
|
+
self,
|
|
30
|
+
payload: Mapping[str, Any] | None = None,
|
|
31
|
+
/,
|
|
32
|
+
*,
|
|
33
|
+
json_fields: Any = (),
|
|
34
|
+
method: str | None = None,
|
|
35
|
+
expect: str | None = None,
|
|
36
|
+
idempotent: bool | None = None,
|
|
37
|
+
timeout: Any = None,
|
|
38
|
+
strict: bool | None = None,
|
|
39
|
+
**params: Any,
|
|
40
|
+
) -> Any:
|
|
41
|
+
kwargs: dict[str, Any] = {
|
|
42
|
+
"payload": payload,
|
|
43
|
+
"json_fields": json_fields,
|
|
44
|
+
"method": method,
|
|
45
|
+
"expect": expect,
|
|
46
|
+
"idempotent": idempotent,
|
|
47
|
+
"strict": self._strict if strict is None else strict,
|
|
48
|
+
**params,
|
|
49
|
+
}
|
|
50
|
+
if timeout is not None:
|
|
51
|
+
kwargs["timeout"] = timeout
|
|
52
|
+
return self._invoker.call(self.controller, self.action, **kwargs)
|
|
53
|
+
|
|
54
|
+
def download(self, **kwargs: Any) -> Any:
|
|
55
|
+
kwargs.setdefault("strict", self._strict)
|
|
56
|
+
return self._invoker.download(self.controller, self.action, **kwargs)
|
|
57
|
+
|
|
58
|
+
def upload(self, **kwargs: Any) -> Any:
|
|
59
|
+
kwargs.setdefault("strict", self._strict)
|
|
60
|
+
return self._invoker.upload(self.controller, self.action, **kwargs)
|
|
61
|
+
|
|
62
|
+
def iter_all(self, **kwargs: Any) -> Iterator[Any]:
|
|
63
|
+
kwargs.setdefault("strict", self._strict)
|
|
64
|
+
result: Iterator[Any] = self._invoker.iter_all(self.controller, self.action, **kwargs)
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
def __repr__(self) -> str:
|
|
68
|
+
return f"ActionCaller({self.controller!r}, {self.action!r})"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ActionNamespace:
|
|
72
|
+
"""Exact action names for a controller."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
invoker: Any,
|
|
77
|
+
controller: str,
|
|
78
|
+
*,
|
|
79
|
+
strict: bool | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._invoker = invoker
|
|
82
|
+
self._controller = controller
|
|
83
|
+
self._strict = strict
|
|
84
|
+
|
|
85
|
+
def __getattr__(self, name: str) -> ActionCaller:
|
|
86
|
+
if name.startswith("_"):
|
|
87
|
+
raise AttributeError(name)
|
|
88
|
+
return ActionCaller(
|
|
89
|
+
self._invoker,
|
|
90
|
+
self._controller,
|
|
91
|
+
name,
|
|
92
|
+
strict=self._strict,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def __getitem__(self, name: str) -> ActionCaller:
|
|
96
|
+
return ActionCaller(
|
|
97
|
+
self._invoker,
|
|
98
|
+
self._controller,
|
|
99
|
+
name,
|
|
100
|
+
strict=self._strict,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
return f"ActionNamespace({self._controller!r})"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ControllerProxy:
|
|
108
|
+
"""Generic proxy for a controller.
|
|
109
|
+
|
|
110
|
+
Typed resources inherit from this class, so ``users.list()`` and
|
|
111
|
+
``users.UsersList()`` can coexist.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
controller: str
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
invoker: Any,
|
|
119
|
+
controller: str | None = None,
|
|
120
|
+
*,
|
|
121
|
+
strict: bool | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._invoker = invoker
|
|
124
|
+
if controller is not None:
|
|
125
|
+
self.controller = controller
|
|
126
|
+
self._strict = strict
|
|
127
|
+
|
|
128
|
+
def call(
|
|
129
|
+
self,
|
|
130
|
+
action: str,
|
|
131
|
+
/,
|
|
132
|
+
*,
|
|
133
|
+
payload: Mapping[str, Any] | None = None,
|
|
134
|
+
json_fields: Any = (),
|
|
135
|
+
method: str | None = None,
|
|
136
|
+
expect: str | None = None,
|
|
137
|
+
idempotent: bool | None = None,
|
|
138
|
+
timeout: Any = None,
|
|
139
|
+
strict: bool | None = None,
|
|
140
|
+
**params: Any,
|
|
141
|
+
) -> Any:
|
|
142
|
+
kwargs: dict[str, Any] = {
|
|
143
|
+
"payload": payload,
|
|
144
|
+
"json_fields": json_fields,
|
|
145
|
+
"method": method,
|
|
146
|
+
"expect": expect,
|
|
147
|
+
"idempotent": idempotent,
|
|
148
|
+
"strict": self._strict if strict is None else strict,
|
|
149
|
+
**params,
|
|
150
|
+
}
|
|
151
|
+
if timeout is not None:
|
|
152
|
+
kwargs["timeout"] = timeout
|
|
153
|
+
return self._invoker.call(self.controller, action, **kwargs)
|
|
154
|
+
|
|
155
|
+
def download(self, action: str, /, **kwargs: Any) -> Any:
|
|
156
|
+
kwargs.setdefault("strict", self._strict)
|
|
157
|
+
return self._invoker.download(self.controller, action, **kwargs)
|
|
158
|
+
|
|
159
|
+
def upload(self, action: str, /, **kwargs: Any) -> Any:
|
|
160
|
+
kwargs.setdefault("strict", self._strict)
|
|
161
|
+
return self._invoker.upload(self.controller, action, **kwargs)
|
|
162
|
+
|
|
163
|
+
def iter_all(self, action: str, /, **kwargs: Any) -> Iterator[Any]:
|
|
164
|
+
kwargs.setdefault("strict", self._strict)
|
|
165
|
+
result: Iterator[Any] = self._invoker.iter_all(self.controller, action, **kwargs)
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def actions(self) -> ActionNamespace:
|
|
170
|
+
return ActionNamespace(self._invoker, self.controller, strict=self._strict)
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _known_actions(invoker: Any, controller: str) -> frozenset[str] | None:
|
|
174
|
+
try:
|
|
175
|
+
return invoker.available_actions(controller)
|
|
176
|
+
except UGOSError:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _is_reserved(name: str) -> bool:
|
|
181
|
+
return name.startswith("_") or name == "controller"
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _missing_context(invoker: Any, controller: str | None) -> bool:
|
|
185
|
+
return invoker is None or controller is None
|
|
186
|
+
|
|
187
|
+
def _raise_if_unknown(self, name: str, invoker: Any, controller: str) -> None:
|
|
188
|
+
known = self._known_actions(invoker, controller)
|
|
189
|
+
if known is not None and name not in known:
|
|
190
|
+
raise AttributeError(f"unknown action {name!r} for controller {controller!r}")
|
|
191
|
+
|
|
192
|
+
def _validate_attr(self, name: str, invoker: Any, controller: str | None) -> None:
|
|
193
|
+
if self._is_reserved(name):
|
|
194
|
+
raise AttributeError(name)
|
|
195
|
+
if self._missing_context(invoker, controller):
|
|
196
|
+
raise AttributeError(name)
|
|
197
|
+
if self.__dict__.get("_strict") is False:
|
|
198
|
+
return
|
|
199
|
+
self._raise_if_unknown(name, invoker, cast(str, controller))
|
|
200
|
+
|
|
201
|
+
def __getattr__(self, name: str) -> ActionCaller:
|
|
202
|
+
invoker = self.__dict__.get("_invoker")
|
|
203
|
+
controller = self.__dict__.get("controller")
|
|
204
|
+
self._validate_attr(name, invoker, controller)
|
|
205
|
+
if invoker is None or controller is None: # pragma: no cover - checked above
|
|
206
|
+
raise AttributeError(name)
|
|
207
|
+
return ActionCaller(
|
|
208
|
+
invoker,
|
|
209
|
+
controller,
|
|
210
|
+
name,
|
|
211
|
+
strict=self.__dict__.get("_strict"),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def __dir__(self) -> list[str]:
|
|
215
|
+
names = set(super().__dir__())
|
|
216
|
+
with contextlib.suppress(UGOSError):
|
|
217
|
+
names.update(self._invoker.available_actions(self.controller))
|
|
218
|
+
return sorted(names)
|
|
219
|
+
|
|
220
|
+
def __repr__(self) -> str:
|
|
221
|
+
return f"{type(self).__name__}(controller={self.controller!r})"
|
ugos/_encoding.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Form encoding used by the web console."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Collection, Iterable, Mapping
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import quote, urlencode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _json_default(value: Any) -> Any:
|
|
17
|
+
if isinstance(value, (date, datetime)):
|
|
18
|
+
return value.isoformat()
|
|
19
|
+
if isinstance(value, Decimal):
|
|
20
|
+
return str(value)
|
|
21
|
+
if isinstance(value, Enum):
|
|
22
|
+
return value.value
|
|
23
|
+
if isinstance(value, os.PathLike):
|
|
24
|
+
return str(os.fspath(value))
|
|
25
|
+
raise TypeError(f"value of type {type(value).__name__} is not JSON serializable")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _format_float(value: float, key: str) -> str:
|
|
29
|
+
if math.isnan(value) or math.isinf(value):
|
|
30
|
+
raise ValueError(f"parameter {key!r} must be a finite number")
|
|
31
|
+
return repr(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _scalar_complex(value: Any, key: str) -> str:
|
|
35
|
+
if isinstance(value, Decimal):
|
|
36
|
+
return str(value)
|
|
37
|
+
if isinstance(value, Enum):
|
|
38
|
+
return _scalar(value.value, key)
|
|
39
|
+
if isinstance(value, os.PathLike):
|
|
40
|
+
return str(os.fspath(value))
|
|
41
|
+
raise TypeError(f"parameter {key!r} has unsupported type {type(value).__name__}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _scalar_text(value: Any) -> str | None:
|
|
45
|
+
if isinstance(value, bool):
|
|
46
|
+
return "true" if value else "false"
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
return value
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _scalar_number(value: Any, key: str) -> str | None:
|
|
53
|
+
if isinstance(value, int):
|
|
54
|
+
return str(value)
|
|
55
|
+
if isinstance(value, float):
|
|
56
|
+
return _format_float(value, key)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _scalar_simple(value: Any, key: str) -> str | None:
|
|
61
|
+
text = _scalar_text(value)
|
|
62
|
+
if text is not None:
|
|
63
|
+
return text
|
|
64
|
+
return _scalar_number(value, key)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _scalar(value: Any, key: str) -> str:
|
|
68
|
+
simple = _scalar_simple(value, key)
|
|
69
|
+
if simple is not None:
|
|
70
|
+
return simple
|
|
71
|
+
if isinstance(value, (datetime, date)):
|
|
72
|
+
return value.isoformat()
|
|
73
|
+
return _scalar_complex(value, key)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _json_dumps(value: Any) -> str:
|
|
77
|
+
return json.dumps(value, default=_json_default, ensure_ascii=False, separators=(",", ":"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _encode_none(key: str, none: str) -> list[tuple[str, str]]:
|
|
81
|
+
if none == "omit":
|
|
82
|
+
return []
|
|
83
|
+
return [(key, "" if none == "empty" else "null")]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _sequence_as_json(values: list[Any], list_mode: str) -> bool:
|
|
87
|
+
if list_mode == "json":
|
|
88
|
+
return True
|
|
89
|
+
return any(isinstance(item, (Mapping, list, tuple, set, frozenset)) for item in values)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _encode_sequence(
|
|
93
|
+
key: str,
|
|
94
|
+
value: Any,
|
|
95
|
+
list_mode: str,
|
|
96
|
+
) -> list[tuple[str, str]]:
|
|
97
|
+
values = list(value)
|
|
98
|
+
if _sequence_as_json(values, list_mode):
|
|
99
|
+
return [(key, _json_dumps(values))]
|
|
100
|
+
return [(key, _scalar(item, key)) for item in values if item is not None]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _encode_value(
|
|
104
|
+
key: str,
|
|
105
|
+
value: Any,
|
|
106
|
+
json_fields: Collection[str],
|
|
107
|
+
none: str,
|
|
108
|
+
list_mode: str,
|
|
109
|
+
) -> list[tuple[str, str]]:
|
|
110
|
+
if value is None:
|
|
111
|
+
return _encode_none(key, none)
|
|
112
|
+
if key in json_fields:
|
|
113
|
+
return [(key, _json_dumps(value))]
|
|
114
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
115
|
+
return _encode_sequence(key, value, list_mode)
|
|
116
|
+
if isinstance(value, Mapping):
|
|
117
|
+
return [(key, _json_dumps(value))]
|
|
118
|
+
return [(key, _scalar(value, key))]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def encode_pairs(
|
|
122
|
+
params: Mapping[str, Any] | Iterable[tuple[str, Any]] | None = None,
|
|
123
|
+
*,
|
|
124
|
+
json_fields: Collection[str] = (),
|
|
125
|
+
none: str = "omit",
|
|
126
|
+
list_mode: str = "repeat",
|
|
127
|
+
) -> list[tuple[str, str]]:
|
|
128
|
+
"""Convert Python values into form pairs.
|
|
129
|
+
|
|
130
|
+
Scalars become strings, booleans become ``true``/``false``, lists are sent
|
|
131
|
+
as repeated keys, and mappings are JSON encoded. ``json_fields`` forces
|
|
132
|
+
JSON encoding for individual keys.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
if params is None:
|
|
136
|
+
return []
|
|
137
|
+
source: Iterable[tuple[str, Any]] = params.items() if isinstance(params, Mapping) else params
|
|
138
|
+
pairs: list[tuple[str, str]] = []
|
|
139
|
+
for raw_key, value in source:
|
|
140
|
+
pairs.extend(_encode_value(str(raw_key), value, json_fields, none, list_mode))
|
|
141
|
+
return pairs
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def encode_params(
|
|
145
|
+
params: Mapping[str, Any] | Iterable[tuple[str, Any]] | None = None,
|
|
146
|
+
*,
|
|
147
|
+
json_fields: Collection[str] = (),
|
|
148
|
+
none: str = "omit",
|
|
149
|
+
list_mode: str = "repeat",
|
|
150
|
+
) -> str:
|
|
151
|
+
"""Return an ``application/x-www-form-urlencoded`` body."""
|
|
152
|
+
|
|
153
|
+
pairs = encode_pairs(
|
|
154
|
+
params,
|
|
155
|
+
json_fields=json_fields,
|
|
156
|
+
none=none,
|
|
157
|
+
list_mode=list_mode,
|
|
158
|
+
)
|
|
159
|
+
return urlencode(pairs, doseq=False, quote_via=quote)
|
ugos/_pagination.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Pagination helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .exceptions import UGOSError
|
|
9
|
+
from .models.common import ListResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _stable_key(item: Any) -> str | None:
|
|
13
|
+
if isinstance(item, dict):
|
|
14
|
+
for key in ("id", "guid"):
|
|
15
|
+
value = item.get(key)
|
|
16
|
+
if value is not None and value != "":
|
|
17
|
+
return f"{key}:{value!r}"
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _validate_sizes(page_size: int, start: int, max_pages: int) -> None:
|
|
22
|
+
if page_size <= 0:
|
|
23
|
+
raise ValueError("page_size must be positive")
|
|
24
|
+
if start < 0:
|
|
25
|
+
raise ValueError("start must be non-negative")
|
|
26
|
+
if max_pages <= 0:
|
|
27
|
+
raise ValueError("max_pages must be positive")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _validate_max_items(max_items: int | None) -> None:
|
|
31
|
+
if max_items is not None and max_items < 0:
|
|
32
|
+
raise ValueError("max_items must be non-negative")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate_reserved(params: dict[str, Any]) -> None:
|
|
36
|
+
for reserved in ("start", "limit"):
|
|
37
|
+
if reserved in params:
|
|
38
|
+
raise ValueError(f"{reserved!r} is managed by iter_all")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _page_signature(items: list[Any]) -> tuple[str, ...]:
|
|
42
|
+
return tuple(_stable_key(item) or f"repr:{item!r}" for item in items)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _record_page(signature: tuple[str, ...], seen_pages: set[tuple[str, ...]]) -> bool:
|
|
46
|
+
if signature in seen_pages:
|
|
47
|
+
return False
|
|
48
|
+
seen_pages.add(signature)
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _max_reached(max_items: int | None, yielded: int, accepted: int) -> bool:
|
|
53
|
+
return max_items is not None and yielded + accepted >= max_items
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _should_take_item(
|
|
57
|
+
item: Any,
|
|
58
|
+
seen_items: set[str],
|
|
59
|
+
max_items: int | None,
|
|
60
|
+
yielded: int,
|
|
61
|
+
accepted: int,
|
|
62
|
+
) -> bool:
|
|
63
|
+
if _max_reached(max_items, yielded, accepted):
|
|
64
|
+
return False
|
|
65
|
+
key = _stable_key(item)
|
|
66
|
+
if key is not None and key in seen_items:
|
|
67
|
+
return False
|
|
68
|
+
if key is not None:
|
|
69
|
+
seen_items.add(key)
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _new_items(
|
|
74
|
+
items: list[Any],
|
|
75
|
+
seen_items: set[str],
|
|
76
|
+
max_items: int | None,
|
|
77
|
+
yielded: int,
|
|
78
|
+
) -> list[Any]:
|
|
79
|
+
result: list[Any] = []
|
|
80
|
+
for item in items:
|
|
81
|
+
if not _should_take_item(item, seen_items, max_items, yielded, len(result)):
|
|
82
|
+
break
|
|
83
|
+
result.append(item)
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reached_total(result: ListResult[Any], yielded: int) -> bool:
|
|
88
|
+
return result.total is not None and yielded >= result.total
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _process_page(
|
|
92
|
+
result: ListResult[Any],
|
|
93
|
+
seen_pages: set[tuple[str, ...]],
|
|
94
|
+
seen_items: set[str],
|
|
95
|
+
max_items: int | None,
|
|
96
|
+
yielded: int,
|
|
97
|
+
) -> tuple[list[Any], bool]:
|
|
98
|
+
if not result.items:
|
|
99
|
+
return [], True
|
|
100
|
+
if not _record_page(_page_signature(result.items), seen_pages):
|
|
101
|
+
return [], True
|
|
102
|
+
new_items = _new_items(result.items, seen_items, max_items, yielded)
|
|
103
|
+
if not new_items:
|
|
104
|
+
return [], True
|
|
105
|
+
if _max_reached(max_items, yielded, len(new_items)):
|
|
106
|
+
return new_items, True
|
|
107
|
+
return new_items, _reached_total(result, yielded + len(new_items))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def paginate(
|
|
111
|
+
invoker: Any,
|
|
112
|
+
controller: str,
|
|
113
|
+
action: str,
|
|
114
|
+
*,
|
|
115
|
+
page_size: int = 100,
|
|
116
|
+
start: int = 0,
|
|
117
|
+
max_items: int | None = None,
|
|
118
|
+
max_pages: int = 1000,
|
|
119
|
+
strict: bool | None = None,
|
|
120
|
+
**params: Any,
|
|
121
|
+
) -> Iterator[Any]:
|
|
122
|
+
"""Yield items page by page.
|
|
123
|
+
|
|
124
|
+
Stops on an empty page or when a page repeats. The latter protects
|
|
125
|
+
against actions such as ``netzones/ZonesList`` that ignore ``start``.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
_validate_sizes(page_size, start, max_pages)
|
|
129
|
+
_validate_max_items(max_items)
|
|
130
|
+
if max_items == 0:
|
|
131
|
+
return
|
|
132
|
+
_validate_reserved(params)
|
|
133
|
+
|
|
134
|
+
seen_pages: set[tuple[str, ...]] = set()
|
|
135
|
+
seen_items: set[str] = set()
|
|
136
|
+
current = start
|
|
137
|
+
yielded = 0
|
|
138
|
+
pages = 0
|
|
139
|
+
|
|
140
|
+
while pages < max_pages:
|
|
141
|
+
pages += 1
|
|
142
|
+
data = invoker.call(
|
|
143
|
+
controller,
|
|
144
|
+
action,
|
|
145
|
+
start=current,
|
|
146
|
+
limit=page_size,
|
|
147
|
+
strict=strict,
|
|
148
|
+
**params,
|
|
149
|
+
)
|
|
150
|
+
result: ListResult[Any] = ListResult.from_response(data)
|
|
151
|
+
new_items, stop = _process_page(result, seen_pages, seen_items, max_items, yielded)
|
|
152
|
+
yield from new_items
|
|
153
|
+
yielded += len(new_items)
|
|
154
|
+
if stop:
|
|
155
|
+
return
|
|
156
|
+
current += len(result.items)
|
|
157
|
+
|
|
158
|
+
raise UGOSError(f"pagination limit exceeded for {controller}/{action}")
|
ugos/_redaction.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Helpers that keep secrets out of logs and ``repr()`` output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_SENSITIVE_PARTS = {
|
|
11
|
+
"password",
|
|
12
|
+
"passwd",
|
|
13
|
+
"pwd",
|
|
14
|
+
"secret",
|
|
15
|
+
"token",
|
|
16
|
+
"csrfmiddlewaretoken",
|
|
17
|
+
"session",
|
|
18
|
+
"cookie",
|
|
19
|
+
"authorization",
|
|
20
|
+
"api",
|
|
21
|
+
"key",
|
|
22
|
+
"privatekey",
|
|
23
|
+
"licensekey",
|
|
24
|
+
"accesskey",
|
|
25
|
+
"credential",
|
|
26
|
+
"credentials",
|
|
27
|
+
}
|
|
28
|
+
_SENSITIVE_WORDS = (
|
|
29
|
+
"password|passwd|pwd|secret|token|csrfmiddlewaretoken|csrf_token|"
|
|
30
|
+
"auth_token|session|session_id|_console_session|api_key|apikey|"
|
|
31
|
+
"private_key|license_key|access_key|authorization|cookie"
|
|
32
|
+
)
|
|
33
|
+
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
34
|
+
|
|
35
|
+
_TEXT_PATTERNS = (
|
|
36
|
+
(re.compile(r"(CSRF_TOKEN\s*=\s*['\"])[^'\"]+(['\"])", re.IGNORECASE), r"\1***\2"),
|
|
37
|
+
(re.compile(r"(csrfmiddlewaretoken=)[^&\s]+", re.IGNORECASE), r"\1***"),
|
|
38
|
+
(re.compile(r"(_console_session=)[^;\s]+", re.IGNORECASE), r"\1***"),
|
|
39
|
+
(
|
|
40
|
+
re.compile(
|
|
41
|
+
rf'("[\w.-]*(?:{_SENSITIVE_WORDS})[\w.-]*"\s*:\s*")[^"]+(")',
|
|
42
|
+
re.IGNORECASE,
|
|
43
|
+
),
|
|
44
|
+
r"\1***\2",
|
|
45
|
+
),
|
|
46
|
+
(
|
|
47
|
+
re.compile(
|
|
48
|
+
rf"('[\w.-]*(?:{_SENSITIVE_WORDS})[\w.-]*'\s*:\s*')[^']+(')",
|
|
49
|
+
re.IGNORECASE,
|
|
50
|
+
),
|
|
51
|
+
r"\1***\2",
|
|
52
|
+
),
|
|
53
|
+
(
|
|
54
|
+
re.compile(
|
|
55
|
+
r"((?:password|passwd|secret|token|api[_-]?key|license[_-]?key)"
|
|
56
|
+
r"\s*[:=]\s*['\"])[^'\"]+(['\"])",
|
|
57
|
+
re.IGNORECASE,
|
|
58
|
+
),
|
|
59
|
+
r"\1***\2",
|
|
60
|
+
),
|
|
61
|
+
(
|
|
62
|
+
re.compile(
|
|
63
|
+
r"((?:password|passwd|secret|token|api[_-]?key|license[_-]?key)"
|
|
64
|
+
r"\s*[:=]\s*)[^\s,;]+",
|
|
65
|
+
re.IGNORECASE,
|
|
66
|
+
),
|
|
67
|
+
r"\1***",
|
|
68
|
+
),
|
|
69
|
+
(
|
|
70
|
+
re.compile(r"(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+", re.IGNORECASE),
|
|
71
|
+
r"\1***",
|
|
72
|
+
),
|
|
73
|
+
(
|
|
74
|
+
re.compile(r"((?:set-)?cookie\s*[:=]\s*)[^\s,;]+", re.IGNORECASE),
|
|
75
|
+
r"\1***",
|
|
76
|
+
),
|
|
77
|
+
(
|
|
78
|
+
re.compile(
|
|
79
|
+
r"([?&](?:api[_-]?key|apikey|token|password|secret|session_id|access_token)=)[^&\s]+",
|
|
80
|
+
re.IGNORECASE,
|
|
81
|
+
),
|
|
82
|
+
r"\1***",
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
re.compile(
|
|
86
|
+
r'(name=["\']?(?:csrfmiddlewaretoken|password|passwd|secret|token)'
|
|
87
|
+
r'["\']?[^>]*value=["\'])[^"\']+(["\'])',
|
|
88
|
+
re.IGNORECASE,
|
|
89
|
+
),
|
|
90
|
+
r"\1***\2",
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _key_parts(key: object) -> list[str]:
|
|
96
|
+
text = _CAMEL_BOUNDARY.sub("_", str(key))
|
|
97
|
+
normalized = re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").lower()
|
|
98
|
+
return [part for part in normalized.split("_") if part]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_sensitive_key(key: object) -> bool:
|
|
102
|
+
parts = _key_parts(key)
|
|
103
|
+
return bool(set(parts) & _SENSITIVE_PARTS)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def redact_value(value: Any) -> Any:
|
|
107
|
+
if isinstance(value, Mapping):
|
|
108
|
+
return redact_mapping(value)
|
|
109
|
+
if isinstance(value, (list, tuple)):
|
|
110
|
+
return [redact_value(item) for item in value]
|
|
111
|
+
if isinstance(value, str):
|
|
112
|
+
return redact_text(value)
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def redact_mapping(data: Mapping[Any, Any]) -> dict[Any, Any]:
|
|
117
|
+
result: dict[Any, Any] = {}
|
|
118
|
+
for key, value in data.items():
|
|
119
|
+
if is_sensitive_key(key):
|
|
120
|
+
result[key] = "***"
|
|
121
|
+
else:
|
|
122
|
+
result[key] = redact_value(value)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def redact_text(text: str) -> str:
|
|
127
|
+
stripped = text.lstrip()
|
|
128
|
+
if stripped.startswith(("{", "[")):
|
|
129
|
+
try:
|
|
130
|
+
payload = json.loads(text)
|
|
131
|
+
except json.JSONDecodeError:
|
|
132
|
+
pass
|
|
133
|
+
else:
|
|
134
|
+
return json.dumps(redact_value(payload), ensure_ascii=False)
|
|
135
|
+
result = text
|
|
136
|
+
for pattern, replacement in _TEXT_PATTERNS:
|
|
137
|
+
result = pattern.sub(replacement, result)
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def redact_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
|
142
|
+
allowed = {
|
|
143
|
+
"content-type",
|
|
144
|
+
"content-length",
|
|
145
|
+
"content-disposition",
|
|
146
|
+
"accept",
|
|
147
|
+
"location",
|
|
148
|
+
"retry-after",
|
|
149
|
+
"x-requested-with",
|
|
150
|
+
}
|
|
151
|
+
result: dict[str, str] = {}
|
|
152
|
+
for key, value in headers.items():
|
|
153
|
+
lower = key.lower()
|
|
154
|
+
if lower in allowed:
|
|
155
|
+
result[key] = redact_text(value)
|
|
156
|
+
elif lower in {"set-cookie", "cookie", "authorization"}:
|
|
157
|
+
result[key] = "***"
|
|
158
|
+
return result
|