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/__about__.py
ADDED
ugos/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Python client for the UGOS NGFW web-console API."""
|
|
2
|
+
|
|
3
|
+
from .__about__ import SUPPORTED_MAJOR_VERSION, SUPPORTED_NGFW, __version__
|
|
4
|
+
from .catalog import Catalog, load_catalog
|
|
5
|
+
from .client import UGOSClient
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
AccessDeniedError,
|
|
8
|
+
APIError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
CatalogError,
|
|
11
|
+
CleanupError,
|
|
12
|
+
ConfigurationError,
|
|
13
|
+
ConflictError,
|
|
14
|
+
CSRFError,
|
|
15
|
+
DecodeError,
|
|
16
|
+
DownloadError,
|
|
17
|
+
DuplicateError,
|
|
18
|
+
HTTPStatusError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
ProtocolError,
|
|
21
|
+
ReadOnlyListError,
|
|
22
|
+
RequestTimeoutError,
|
|
23
|
+
ServerError,
|
|
24
|
+
SessionExpiredError,
|
|
25
|
+
TransportError,
|
|
26
|
+
UnknownActionError,
|
|
27
|
+
UnknownControllerError,
|
|
28
|
+
UGOSError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
)
|
|
31
|
+
from .models.common import DownloadedFile, ListResult, SessionInfo
|
|
32
|
+
from .models.entities import (
|
|
33
|
+
Administrator,
|
|
34
|
+
Certificate,
|
|
35
|
+
FirewallRule,
|
|
36
|
+
Group,
|
|
37
|
+
Interface,
|
|
38
|
+
LicenseInfo,
|
|
39
|
+
NetworkList,
|
|
40
|
+
PcapFilter,
|
|
41
|
+
PcapRule,
|
|
42
|
+
Tag,
|
|
43
|
+
Zone,
|
|
44
|
+
)
|
|
45
|
+
from .resources.base import Resource
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"SUPPORTED_MAJOR_VERSION",
|
|
49
|
+
"SUPPORTED_NGFW",
|
|
50
|
+
"APIError",
|
|
51
|
+
"AccessDeniedError",
|
|
52
|
+
"Administrator",
|
|
53
|
+
"AuthenticationError",
|
|
54
|
+
"CSRFError",
|
|
55
|
+
"Catalog",
|
|
56
|
+
"CatalogError",
|
|
57
|
+
"Certificate",
|
|
58
|
+
"CleanupError",
|
|
59
|
+
"ConfigurationError",
|
|
60
|
+
"ConflictError",
|
|
61
|
+
"DecodeError",
|
|
62
|
+
"DownloadError",
|
|
63
|
+
"DownloadedFile",
|
|
64
|
+
"DuplicateError",
|
|
65
|
+
"FirewallRule",
|
|
66
|
+
"Group",
|
|
67
|
+
"HTTPStatusError",
|
|
68
|
+
"Interface",
|
|
69
|
+
"LicenseInfo",
|
|
70
|
+
"ListResult",
|
|
71
|
+
"NetworkList",
|
|
72
|
+
"NotFoundError",
|
|
73
|
+
"PcapFilter",
|
|
74
|
+
"PcapRule",
|
|
75
|
+
"ProtocolError",
|
|
76
|
+
"ReadOnlyListError",
|
|
77
|
+
"RequestTimeoutError",
|
|
78
|
+
"Resource",
|
|
79
|
+
"ServerError",
|
|
80
|
+
"SessionExpiredError",
|
|
81
|
+
"SessionInfo",
|
|
82
|
+
"Tag",
|
|
83
|
+
"TransportError",
|
|
84
|
+
"UnknownActionError",
|
|
85
|
+
"UnknownControllerError",
|
|
86
|
+
"UGOSClient",
|
|
87
|
+
"UGOSError",
|
|
88
|
+
"ValidationError",
|
|
89
|
+
"Zone",
|
|
90
|
+
"__version__",
|
|
91
|
+
"load_catalog",
|
|
92
|
+
]
|
ugos/_auth.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Console authentication, CSRF handling and session lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._codec import is_error_payload, parse_response
|
|
13
|
+
from ._encoding import encode_params
|
|
14
|
+
from ._redaction import redact_mapping
|
|
15
|
+
from ._transport import FORM_HEADERS, HTML_HEADERS, HttpTransport
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
CSRFError,
|
|
19
|
+
DecodeError,
|
|
20
|
+
HTTPStatusError,
|
|
21
|
+
ProtocolError,
|
|
22
|
+
SessionExpiredError,
|
|
23
|
+
UGOSError,
|
|
24
|
+
)
|
|
25
|
+
from .models.common import SessionInfo
|
|
26
|
+
|
|
27
|
+
_CSRF_RE = re.compile(r"CSRF_TOKEN\s*=\s*['\"]([^'\"]+)['\"]")
|
|
28
|
+
_CSRF_INPUT_RE = re.compile(
|
|
29
|
+
r"name=['\"]csrfmiddlewaretoken['\"][^>]*value=['\"]([^'\"]+)['\"]",
|
|
30
|
+
re.IGNORECASE,
|
|
31
|
+
)
|
|
32
|
+
_CSRF_META_RE = re.compile(
|
|
33
|
+
r"name=['\"]csrf-token['\"][^>]*content=['\"]([^'\"]+)['\"]",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthManager:
|
|
39
|
+
"""Owns login/logout, cookies and the CSRF token."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
transport: HttpTransport,
|
|
44
|
+
*,
|
|
45
|
+
username: str | None = None,
|
|
46
|
+
password: str | None = None,
|
|
47
|
+
login_path: str = "utm/login",
|
|
48
|
+
logout_path: str = "utm/CoreLogout",
|
|
49
|
+
auto_reauth: bool = True,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._transport = transport
|
|
52
|
+
self._username = username
|
|
53
|
+
self._password = password
|
|
54
|
+
self._login_path = login_path
|
|
55
|
+
self._logout_path = logout_path
|
|
56
|
+
self._auto_reauth = auto_reauth
|
|
57
|
+
self._session: SessionInfo | None = None
|
|
58
|
+
self._csrf: str | None = None
|
|
59
|
+
self._generation = 0
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def session(self) -> SessionInfo | None:
|
|
63
|
+
return self._session
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def authenticated(self) -> bool:
|
|
67
|
+
return self._session is not None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def csrf_token(self) -> str | None:
|
|
71
|
+
return self._csrf
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def generation(self) -> int:
|
|
75
|
+
return self._generation
|
|
76
|
+
|
|
77
|
+
def invalidate(self) -> None:
|
|
78
|
+
self._session = None
|
|
79
|
+
self._csrf = None
|
|
80
|
+
self._generation += 1
|
|
81
|
+
self._transport.clear_cookies(("_console_session", "csrf_token"))
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def auto_reauth(self) -> bool:
|
|
85
|
+
return self._auto_reauth
|
|
86
|
+
|
|
87
|
+
def ensure_csrf(self) -> str:
|
|
88
|
+
if self._csrf:
|
|
89
|
+
return self._csrf
|
|
90
|
+
token = self._transport.get_cookie("csrf_token")
|
|
91
|
+
if token:
|
|
92
|
+
self._csrf = token
|
|
93
|
+
return token
|
|
94
|
+
return self.refresh_csrf()
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _is_login_redirect(status_code: int, location: str) -> bool:
|
|
98
|
+
if status_code not in {301, 302, 303, 307, 308}:
|
|
99
|
+
return False
|
|
100
|
+
return "utm/login" in location or location.rstrip("/").endswith("/login")
|
|
101
|
+
|
|
102
|
+
def _raise_csrf_status(self, response: httpx.Response) -> None:
|
|
103
|
+
location = response.headers.get("location", "")
|
|
104
|
+
login_redirect = self._is_login_redirect(response.status_code, location)
|
|
105
|
+
if response.status_code == 401 or login_redirect:
|
|
106
|
+
raise SessionExpiredError(
|
|
107
|
+
"session expired while refreshing CSRF",
|
|
108
|
+
status_code=response.status_code,
|
|
109
|
+
)
|
|
110
|
+
if response.status_code >= 400:
|
|
111
|
+
raise CSRFError(
|
|
112
|
+
f"CSRF refresh failed with HTTP {response.status_code}",
|
|
113
|
+
status_code=response.status_code,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _extract_csrf_token(self, response: httpx.Response) -> str:
|
|
117
|
+
token = self._transport.get_cookie("csrf_token") or _extract_csrf(response.text)
|
|
118
|
+
if not token:
|
|
119
|
+
raise CSRFError("the login page did not contain a CSRF token")
|
|
120
|
+
return token
|
|
121
|
+
|
|
122
|
+
def refresh_csrf(self) -> str:
|
|
123
|
+
path = "/" if self.authenticated else self._login_path
|
|
124
|
+
try:
|
|
125
|
+
response = self._transport.request(
|
|
126
|
+
"GET",
|
|
127
|
+
path,
|
|
128
|
+
headers=HTML_HEADERS,
|
|
129
|
+
idempotent=True,
|
|
130
|
+
)
|
|
131
|
+
except UGOSError:
|
|
132
|
+
raise
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
raise CSRFError(f"failed to obtain a CSRF token: {exc}") from exc
|
|
135
|
+
self._raise_csrf_status(response)
|
|
136
|
+
self.note_response(response)
|
|
137
|
+
self._csrf = self._extract_csrf_token(response)
|
|
138
|
+
return self._csrf
|
|
139
|
+
|
|
140
|
+
def _reset_session(self) -> None:
|
|
141
|
+
self._transport.clear_cookies(("_console_session", "csrf_token"))
|
|
142
|
+
self._session = None
|
|
143
|
+
self._csrf = None
|
|
144
|
+
|
|
145
|
+
def _login_page_token(self) -> str:
|
|
146
|
+
page = self._transport.request(
|
|
147
|
+
"GET", self._login_path, headers=HTML_HEADERS, idempotent=True
|
|
148
|
+
)
|
|
149
|
+
self.note_response(page)
|
|
150
|
+
return self._extract_csrf_token(page)
|
|
151
|
+
|
|
152
|
+
def _post_login(self, token: str) -> httpx.Response:
|
|
153
|
+
body = encode_params(
|
|
154
|
+
{
|
|
155
|
+
"username": self._username,
|
|
156
|
+
"password": self._password,
|
|
157
|
+
"csrfmiddlewaretoken": token,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
response = self._transport.request(
|
|
161
|
+
"POST",
|
|
162
|
+
self._login_path,
|
|
163
|
+
data=body,
|
|
164
|
+
headers=FORM_HEADERS,
|
|
165
|
+
idempotent=False,
|
|
166
|
+
follow_redirects=False,
|
|
167
|
+
)
|
|
168
|
+
self.note_response(response)
|
|
169
|
+
return response
|
|
170
|
+
|
|
171
|
+
def _parse_login_response(self, response: httpx.Response) -> Mapping[str, Any]:
|
|
172
|
+
try:
|
|
173
|
+
payload = parse_response(response, expect="json")
|
|
174
|
+
except DecodeError as exc:
|
|
175
|
+
raise AuthenticationError(
|
|
176
|
+
"login returned a non-JSON response",
|
|
177
|
+
status_code=response.status_code,
|
|
178
|
+
) from exc
|
|
179
|
+
except (HTTPStatusError, ProtocolError) as exc:
|
|
180
|
+
raise AuthenticationError(
|
|
181
|
+
f"login failed: {exc}",
|
|
182
|
+
status_code=getattr(exc, "status_code", response.status_code),
|
|
183
|
+
payload=getattr(exc, "payload", None),
|
|
184
|
+
) from exc
|
|
185
|
+
if not isinstance(payload, Mapping) or not payload.get("auth_token"):
|
|
186
|
+
raise AuthenticationError("login was rejected by the appliance")
|
|
187
|
+
return payload
|
|
188
|
+
|
|
189
|
+
def _build_session(self, payload: Mapping[str, Any], token: str) -> SessionInfo:
|
|
190
|
+
session_cookie = self._transport.get_cookie("_console_session")
|
|
191
|
+
if not session_cookie:
|
|
192
|
+
raise ProtocolError("login succeeded but _console_session cookie is absent")
|
|
193
|
+
self._csrf = self._transport.get_cookie("csrf_token") or token
|
|
194
|
+
self._session = SessionInfo(
|
|
195
|
+
username=cast(str, self._username),
|
|
196
|
+
admin_id=_as_int(payload.get("admin_id")),
|
|
197
|
+
product=_as_str(payload.get("product")),
|
|
198
|
+
version=_as_str(payload.get("version")),
|
|
199
|
+
auth_token=_as_bool(payload.get("auth_token")),
|
|
200
|
+
fingerprint=hashlib.sha256(session_cookie.encode()).hexdigest()[:12],
|
|
201
|
+
raw=redact_mapping(payload),
|
|
202
|
+
)
|
|
203
|
+
self._generation += 1
|
|
204
|
+
return self._session
|
|
205
|
+
|
|
206
|
+
def _has_reusable_session(self, force: bool) -> bool:
|
|
207
|
+
return self._session is not None and not force
|
|
208
|
+
|
|
209
|
+
def _credentials_missing(self) -> bool:
|
|
210
|
+
return not self._username or self._password is None
|
|
211
|
+
|
|
212
|
+
def _should_reset_session(self, force: bool) -> bool:
|
|
213
|
+
return force or self._session is not None
|
|
214
|
+
|
|
215
|
+
def login(self, *, force: bool = False) -> SessionInfo:
|
|
216
|
+
if self._has_reusable_session(force):
|
|
217
|
+
return self._session # type: ignore[return-value]
|
|
218
|
+
if self._credentials_missing():
|
|
219
|
+
raise AuthenticationError("username and password are required for login")
|
|
220
|
+
if self._should_reset_session(force):
|
|
221
|
+
self._reset_session()
|
|
222
|
+
token = self._login_page_token()
|
|
223
|
+
response = self._post_login(token)
|
|
224
|
+
return self._build_session(self._parse_login_response(response), token)
|
|
225
|
+
|
|
226
|
+
def _logout_request(self) -> httpx.Response:
|
|
227
|
+
token = self._csrf or self.ensure_csrf()
|
|
228
|
+
response = self._transport.request(
|
|
229
|
+
"POST",
|
|
230
|
+
self._logout_path,
|
|
231
|
+
data=encode_params({"csrfmiddlewaretoken": token}),
|
|
232
|
+
headers=FORM_HEADERS,
|
|
233
|
+
idempotent=False,
|
|
234
|
+
)
|
|
235
|
+
self.note_response(response)
|
|
236
|
+
return response
|
|
237
|
+
|
|
238
|
+
def _logout_result(self, response: httpx.Response) -> bool:
|
|
239
|
+
try:
|
|
240
|
+
payload = parse_response(response, expect="auto")
|
|
241
|
+
except SessionExpiredError:
|
|
242
|
+
return True
|
|
243
|
+
return not is_error_payload(payload)
|
|
244
|
+
|
|
245
|
+
def logout(self, *, best_effort: bool = True) -> bool:
|
|
246
|
+
if self._session is None:
|
|
247
|
+
self._generation += 1
|
|
248
|
+
self._transport.clear_cookies(("_console_session", "csrf_token"))
|
|
249
|
+
return True
|
|
250
|
+
ok = False
|
|
251
|
+
try:
|
|
252
|
+
ok = self._logout_result(self._logout_request())
|
|
253
|
+
except UGOSError:
|
|
254
|
+
if not best_effort:
|
|
255
|
+
raise
|
|
256
|
+
finally:
|
|
257
|
+
self._generation += 1
|
|
258
|
+
self._reset_session()
|
|
259
|
+
return ok
|
|
260
|
+
|
|
261
|
+
def note_response(self, response: httpx.Response) -> None:
|
|
262
|
+
token = self._transport.get_cookie("csrf_token")
|
|
263
|
+
if token:
|
|
264
|
+
self._csrf = token
|
|
265
|
+
# Session state is invalidated by the guarded re-auth path, not here:
|
|
266
|
+
# a stale response from another thread must not wipe a fresh session.
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _extract_csrf(html: str) -> str | None:
|
|
270
|
+
for pattern in (_CSRF_RE, _CSRF_INPUT_RE, _CSRF_META_RE):
|
|
271
|
+
match = pattern.search(html)
|
|
272
|
+
if match:
|
|
273
|
+
return match.group(1)
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _as_int(value: Any) -> int | None:
|
|
278
|
+
try:
|
|
279
|
+
return int(value) if value is not None else None
|
|
280
|
+
except (TypeError, ValueError):
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _as_str(value: Any) -> str | None:
|
|
285
|
+
return str(value) if value is not None else None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _as_bool(value: Any) -> bool:
|
|
289
|
+
if isinstance(value, bool):
|
|
290
|
+
return value
|
|
291
|
+
if isinstance(value, str):
|
|
292
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
293
|
+
return bool(value)
|
ugos/_client_base.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared typing surface for the client mixins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from ._auth import AuthManager
|
|
9
|
+
from ._client_helpers import _RetryState
|
|
10
|
+
from ._config import ClientConfig
|
|
11
|
+
from ._dynamic import ControllerProxy
|
|
12
|
+
from ._transport import HttpTransport
|
|
13
|
+
from .catalog import Catalog
|
|
14
|
+
|
|
15
|
+
CONTROLLER_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_-]*$")
|
|
16
|
+
_T = TypeVar("_T", bound=ControllerProxy)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _ClientBase:
|
|
20
|
+
_config: ClientConfig
|
|
21
|
+
_transport: HttpTransport
|
|
22
|
+
_auth: AuthManager
|
|
23
|
+
_lock: Any
|
|
24
|
+
_catalog: Catalog
|
|
25
|
+
_resources: dict[str, ControllerProxy]
|
|
26
|
+
_auto_login: bool
|
|
27
|
+
_logout_on_exit: bool
|
|
28
|
+
_catalog_check: bool
|
|
29
|
+
|
|
30
|
+
def _prepare(self) -> None: # pragma: no cover - overridden by the client
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
def _refresh_session(self, expected_generation: int) -> None: # pragma: no cover
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
def _validate(
|
|
37
|
+
self, controller: str, action: str, *, strict: bool | None = None
|
|
38
|
+
) -> None: # pragma: no cover
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
def _retry_csrf(self, state: _RetryState) -> bool: # pragma: no cover
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
def _retry_session( # pragma: no cover
|
|
45
|
+
self,
|
|
46
|
+
state: _RetryState,
|
|
47
|
+
generation: int,
|
|
48
|
+
action: str,
|
|
49
|
+
idempotent: bool,
|
|
50
|
+
exc: Exception,
|
|
51
|
+
) -> bool:
|
|
52
|
+
raise NotImplementedError
|
ugos/_client_calls.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Generic action call implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Collection, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._client_base import _ClientBase
|
|
11
|
+
from ._client_helpers import _merge_params, _retry_allowed, _RetryState
|
|
12
|
+
from ._codec import parse_response
|
|
13
|
+
from ._encoding import encode_pairs, encode_params
|
|
14
|
+
from ._transport import FORM_HEADERS
|
|
15
|
+
from ._transport import UNSET as _UNSET
|
|
16
|
+
from .exceptions import AccessDeniedError, CSRFError, SessionExpiredError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _CallsMixin(_ClientBase):
|
|
20
|
+
def _resolve_method(self, contract: Any, method: str | None) -> str:
|
|
21
|
+
return (method or (contract.method if contract else "POST")).upper()
|
|
22
|
+
|
|
23
|
+
def _resolve_expect(self, contract: Any, expect: str | None) -> str:
|
|
24
|
+
return expect or (contract.response_kind if contract else "auto")
|
|
25
|
+
|
|
26
|
+
def _resolve_json_fields(
|
|
27
|
+
self,
|
|
28
|
+
contract: Any,
|
|
29
|
+
json_fields: Collection[str],
|
|
30
|
+
) -> tuple[str, ...]:
|
|
31
|
+
if contract and contract.json_fields:
|
|
32
|
+
json_fields = (*json_fields, *contract.json_fields)
|
|
33
|
+
return tuple(dict.fromkeys(json_fields))
|
|
34
|
+
|
|
35
|
+
def _resolve_idempotent(
|
|
36
|
+
self,
|
|
37
|
+
contract: Any,
|
|
38
|
+
method: str,
|
|
39
|
+
idempotent: bool | None,
|
|
40
|
+
) -> bool:
|
|
41
|
+
if idempotent is not None:
|
|
42
|
+
return idempotent
|
|
43
|
+
if contract is not None and contract.idempotent is not None:
|
|
44
|
+
return bool(contract.idempotent)
|
|
45
|
+
return method in {"GET", "HEAD", "OPTIONS"}
|
|
46
|
+
|
|
47
|
+
def _resolve_call_options(
|
|
48
|
+
self,
|
|
49
|
+
controller: str,
|
|
50
|
+
action: str,
|
|
51
|
+
method: str | None,
|
|
52
|
+
expect: str | None,
|
|
53
|
+
idempotent: bool | None,
|
|
54
|
+
json_fields: Collection[str],
|
|
55
|
+
contract: Any,
|
|
56
|
+
) -> tuple[str, str, tuple[str, ...], bool]:
|
|
57
|
+
resolved_method = self._resolve_method(contract, method)
|
|
58
|
+
return (
|
|
59
|
+
resolved_method,
|
|
60
|
+
self._resolve_expect(contract, expect),
|
|
61
|
+
self._resolve_json_fields(contract, json_fields),
|
|
62
|
+
self._resolve_idempotent(contract, resolved_method, idempotent),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _send_call(
|
|
66
|
+
self,
|
|
67
|
+
controller: str,
|
|
68
|
+
action: str,
|
|
69
|
+
method: str,
|
|
70
|
+
params: Mapping[str, Any],
|
|
71
|
+
json_fields: Collection[str],
|
|
72
|
+
timeout: Any,
|
|
73
|
+
idempotent: bool,
|
|
74
|
+
) -> tuple[httpx.Response, int]:
|
|
75
|
+
request_params = dict(params)
|
|
76
|
+
with self._lock:
|
|
77
|
+
generation = self._auth.generation
|
|
78
|
+
if method == "GET":
|
|
79
|
+
query = encode_pairs(request_params, json_fields=json_fields)
|
|
80
|
+
body = None
|
|
81
|
+
else:
|
|
82
|
+
with self._lock:
|
|
83
|
+
request_params["csrfmiddlewaretoken"] = self._auth.ensure_csrf()
|
|
84
|
+
body = encode_params(request_params, json_fields=json_fields)
|
|
85
|
+
query = None
|
|
86
|
+
response = self._transport.request(
|
|
87
|
+
method,
|
|
88
|
+
f"{controller}/{action}",
|
|
89
|
+
params=query,
|
|
90
|
+
data=body,
|
|
91
|
+
headers=FORM_HEADERS if body is not None else None,
|
|
92
|
+
idempotent=idempotent,
|
|
93
|
+
timeout=timeout,
|
|
94
|
+
)
|
|
95
|
+
with self._lock:
|
|
96
|
+
self._auth.note_response(response)
|
|
97
|
+
return response, generation
|
|
98
|
+
|
|
99
|
+
def _retry_csrf(self, state: _RetryState) -> bool:
|
|
100
|
+
if state.csrf >= 1:
|
|
101
|
+
return False
|
|
102
|
+
state.csrf += 1
|
|
103
|
+
with self._lock:
|
|
104
|
+
self._auth.refresh_csrf()
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def _retry_session(
|
|
108
|
+
self,
|
|
109
|
+
state: _RetryState,
|
|
110
|
+
generation: int,
|
|
111
|
+
action: str,
|
|
112
|
+
idempotent: bool,
|
|
113
|
+
exc: Exception,
|
|
114
|
+
) -> bool:
|
|
115
|
+
if state.reauth >= 1:
|
|
116
|
+
return False
|
|
117
|
+
self._refresh_session(generation)
|
|
118
|
+
if not self._auth.auto_reauth or not _retry_allowed(action, idempotent, exc):
|
|
119
|
+
return False
|
|
120
|
+
state.reauth += 1
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
def _retry_access_denied(
|
|
124
|
+
self,
|
|
125
|
+
state: _RetryState,
|
|
126
|
+
generation: int,
|
|
127
|
+
action: str,
|
|
128
|
+
idempotent: bool,
|
|
129
|
+
exc: Exception,
|
|
130
|
+
) -> bool:
|
|
131
|
+
if state.reauth >= 1 or self._transport.get_cookie("_console_session") is not None:
|
|
132
|
+
return False
|
|
133
|
+
return self._retry_session(state, generation, action, idempotent, exc)
|
|
134
|
+
|
|
135
|
+
def _handle_call_retry(
|
|
136
|
+
self,
|
|
137
|
+
exc: Exception,
|
|
138
|
+
state: _RetryState,
|
|
139
|
+
generation: int,
|
|
140
|
+
action: str,
|
|
141
|
+
idempotent: bool,
|
|
142
|
+
) -> bool:
|
|
143
|
+
if isinstance(exc, CSRFError):
|
|
144
|
+
return self._retry_csrf(state)
|
|
145
|
+
if isinstance(exc, SessionExpiredError):
|
|
146
|
+
return self._retry_session(state, generation, action, idempotent, exc)
|
|
147
|
+
return self._retry_access_denied(state, generation, action, idempotent, exc)
|
|
148
|
+
|
|
149
|
+
def call(
|
|
150
|
+
self,
|
|
151
|
+
controller: str,
|
|
152
|
+
action: str,
|
|
153
|
+
/,
|
|
154
|
+
*,
|
|
155
|
+
payload: Mapping[str, Any] | None = None,
|
|
156
|
+
json_fields: Collection[str] = (),
|
|
157
|
+
method: str | None = None,
|
|
158
|
+
expect: str | None = None,
|
|
159
|
+
idempotent: bool | None = None,
|
|
160
|
+
timeout: float | httpx.Timeout | object | None = _UNSET,
|
|
161
|
+
strict: bool | None = None,
|
|
162
|
+
**params: Any,
|
|
163
|
+
) -> Any:
|
|
164
|
+
"""Call one controller action and return the decoded response."""
|
|
165
|
+
|
|
166
|
+
self._validate(controller, action, strict=strict)
|
|
167
|
+
contract = self._catalog.action_contract(controller, action)
|
|
168
|
+
merged = _merge_params(payload, params)
|
|
169
|
+
method, expect, json_fields, idempotent = self._resolve_call_options(
|
|
170
|
+
controller,
|
|
171
|
+
action,
|
|
172
|
+
method,
|
|
173
|
+
expect,
|
|
174
|
+
idempotent,
|
|
175
|
+
json_fields,
|
|
176
|
+
contract,
|
|
177
|
+
)
|
|
178
|
+
with self._lock:
|
|
179
|
+
self._prepare()
|
|
180
|
+
state = _RetryState()
|
|
181
|
+
while True:
|
|
182
|
+
response, generation = self._send_call(
|
|
183
|
+
controller,
|
|
184
|
+
action,
|
|
185
|
+
method,
|
|
186
|
+
merged,
|
|
187
|
+
json_fields,
|
|
188
|
+
timeout,
|
|
189
|
+
idempotent,
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
return parse_response(
|
|
193
|
+
response,
|
|
194
|
+
expect=expect,
|
|
195
|
+
controller=controller,
|
|
196
|
+
action=action,
|
|
197
|
+
)
|
|
198
|
+
except (CSRFError, SessionExpiredError, AccessDeniedError) as exc:
|
|
199
|
+
if not self._handle_call_retry(exc, state, generation, action, idempotent):
|
|
200
|
+
raise
|