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.
Files changed (67) hide show
  1. ugos/__about__.py +6 -0
  2. ugos/__init__.py +92 -0
  3. ugos/_auth.py +293 -0
  4. ugos/_client_base.py +52 -0
  5. ugos/_client_calls.py +200 -0
  6. ugos/_client_helpers.py +217 -0
  7. ugos/_client_lifecycle.py +144 -0
  8. ugos/_client_resources.py +192 -0
  9. ugos/_client_transfer.py +501 -0
  10. ugos/_codec.py +528 -0
  11. ugos/_config.py +183 -0
  12. ugos/_data/__init__.py +1 -0
  13. ugos/_data/action_contracts.json +325 -0
  14. ugos/_data/catalog.json +1256 -0
  15. ugos/_dynamic.py +221 -0
  16. ugos/_encoding.py +159 -0
  17. ugos/_pagination.py +158 -0
  18. ugos/_redaction.py +158 -0
  19. ugos/_transport.py +446 -0
  20. ugos/catalog.py +184 -0
  21. ugos/client.py +113 -0
  22. ugos/exceptions.py +137 -0
  23. ugos/models/__init__.py +37 -0
  24. ugos/models/common.py +87 -0
  25. ugos/models/entities.py +124 -0
  26. ugos/models/users.py +43 -0
  27. ugos/py.typed +0 -0
  28. ugos/resources/__init__.py +55 -0
  29. ugos/resources/administrators.py +71 -0
  30. ugos/resources/application.py +107 -0
  31. ugos/resources/base.py +38 -0
  32. ugos/resources/certificates.py +62 -0
  33. ugos/resources/contentrules.py +98 -0
  34. ugos/resources/contenttypes.py +9 -0
  35. ugos/resources/fwrules.py +103 -0
  36. ugos/resources/generic.py +283 -0
  37. ugos/resources/generic_controllers.py +169 -0
  38. ugos/resources/groups.py +94 -0
  39. ugos/resources/icap.py +132 -0
  40. ugos/resources/idps.py +125 -0
  41. ugos/resources/interfaces.py +40 -0
  42. ugos/resources/l7.py +110 -0
  43. ugos/resources/licensing.py +39 -0
  44. ugos/resources/mailsecurity.py +101 -0
  45. ugos/resources/networks.py +82 -0
  46. ugos/resources/netzones.py +78 -0
  47. ugos/resources/nlist.py +72 -0
  48. ugos/resources/overridedomains.py +47 -0
  49. ugos/resources/overrides.py +109 -0
  50. ugos/resources/overrides_content.py +254 -0
  51. ugos/resources/overrides_content_a.py +187 -0
  52. ugos/resources/overrides_lists.py +294 -0
  53. ugos/resources/overrides_misc.py +342 -0
  54. ugos/resources/overrides_network.py +291 -0
  55. ugos/resources/pcap.py +64 -0
  56. ugos/resources/registry.py +55 -0
  57. ugos/resources/settings.py +72 -0
  58. ugos/resources/sysinfo.py +39 -0
  59. ugos/resources/twofa.py +62 -0
  60. ugos/resources/urlcategory.py +69 -0
  61. ugos/resources/urlfiltering.py +23 -0
  62. ugos/resources/urllists.py +9 -0
  63. ugos/resources/users.py +74 -0
  64. ugos-1.0.0.dist-info/METADATA +139 -0
  65. ugos-1.0.0.dist-info/RECORD +67 -0
  66. ugos-1.0.0.dist-info/WHEEL +4 -0
  67. ugos-1.0.0.dist-info/licenses/LICENSE +16 -0
ugos/__about__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Package metadata."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ SUPPORTED_MAJOR_VERSION = 7
6
+ SUPPORTED_NGFW = f"{SUPPORTED_MAJOR_VERSION}.x"
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