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/_codec.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""Response decoding and API error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._redaction import redact_text
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
AccessDeniedError,
|
|
14
|
+
APIError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
CSRFError,
|
|
18
|
+
DecodeError,
|
|
19
|
+
DuplicateError,
|
|
20
|
+
HTTPStatusError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
ProtocolError,
|
|
23
|
+
ReadOnlyListError,
|
|
24
|
+
ServerError,
|
|
25
|
+
SessionExpiredError,
|
|
26
|
+
UGOSError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
SESSION_EXPIRED_CODES = frozenset({4, 104, 22000})
|
|
31
|
+
ACCESS_DENIED_CODES = frozenset({102, 10007})
|
|
32
|
+
AUTHENTICATION_CODES = frozenset({109, 10002, 7004, 7009, 10001, 10010, 20002})
|
|
33
|
+
VALIDATION_CODES = frozenset({10, 11, 14, 107, 110, 111, 601, 603, 618})
|
|
34
|
+
PROTOCOL_CODES = frozenset({3, 112, 616})
|
|
35
|
+
SERVER_CODES = frozenset({2, 500, 608})
|
|
36
|
+
NOT_FOUND_CODES = frozenset({105, 404, 3056, 3057, 5001, 5005})
|
|
37
|
+
DUPLICATE_CODES = frozenset({9, 409, 1019, 2001, 5002, 8001, 21003, 21004, 21005})
|
|
38
|
+
CONFLICT_CODES = frozenset({502, 3003, 4002, 4003, 7012, 7013})
|
|
39
|
+
READ_ONLY_LIST_CODES = frozenset({2010})
|
|
40
|
+
_SUCCESS_CODES = frozenset({200, 201, 202, 204})
|
|
41
|
+
|
|
42
|
+
_ERROR_CLASSES = (
|
|
43
|
+
(SESSION_EXPIRED_CODES, SessionExpiredError),
|
|
44
|
+
(ACCESS_DENIED_CODES, AccessDeniedError),
|
|
45
|
+
(AUTHENTICATION_CODES, AuthenticationError),
|
|
46
|
+
(VALIDATION_CODES, ValidationError),
|
|
47
|
+
(PROTOCOL_CODES, ProtocolError),
|
|
48
|
+
(SERVER_CODES, ServerError),
|
|
49
|
+
(READ_ONLY_LIST_CODES, ReadOnlyListError),
|
|
50
|
+
(NOT_FOUND_CODES, NotFoundError),
|
|
51
|
+
(DUPLICATE_CODES, DuplicateError),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_EXPECT_VALUES = frozenset({"auto", "json", "text", "bytes", "list", "object", "scalar"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _body_snippet(response: httpx.Response, limit: int = 2048) -> str:
|
|
58
|
+
# Redact before truncating: a secret longer than the limit would otherwise
|
|
59
|
+
# be cut in the middle and escape the redaction regexes.
|
|
60
|
+
return redact_text(response.text)[:limit]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_error_code_value(value: Any) -> bool:
|
|
64
|
+
return value not in (None, 0, "0") and _as_int(value) not in _SUCCESS_CODES
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _has_error_code(payload: Mapping[str, Any]) -> bool:
|
|
68
|
+
return any(_is_error_code_value(payload.get(key)) for key in ("errorCode", "error_code"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _has_error_message(payload: Mapping[str, Any]) -> bool:
|
|
72
|
+
code = _as_int(payload.get("code"))
|
|
73
|
+
if code in (None, 0, *_SUCCESS_CODES):
|
|
74
|
+
return False
|
|
75
|
+
return bool(payload.get("error") or payload.get("error_description") or payload.get("message"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_error_payload(payload: Any) -> bool:
|
|
79
|
+
if not isinstance(payload, Mapping):
|
|
80
|
+
return False
|
|
81
|
+
if payload.get("success") is False:
|
|
82
|
+
return True
|
|
83
|
+
if _has_error_code(payload):
|
|
84
|
+
return True
|
|
85
|
+
return _has_error_message(payload)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _as_int(value: Any) -> int | None:
|
|
89
|
+
try:
|
|
90
|
+
if value is None or value == "":
|
|
91
|
+
return None
|
|
92
|
+
return int(value)
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _conflict_exception(status_code: int | None) -> type[UGOSError]:
|
|
98
|
+
if status_code is not None and status_code >= 500:
|
|
99
|
+
return ServerError
|
|
100
|
+
return ConflictError
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _exception_for_code(
|
|
104
|
+
code: int | None,
|
|
105
|
+
status_code: int | None,
|
|
106
|
+
) -> type[UGOSError]:
|
|
107
|
+
if code in CONFLICT_CODES:
|
|
108
|
+
return _conflict_exception(status_code)
|
|
109
|
+
for codes, exception in _ERROR_CLASSES:
|
|
110
|
+
if code in codes:
|
|
111
|
+
return exception
|
|
112
|
+
return APIError
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def error_from_payload(
|
|
116
|
+
payload: Mapping[str, Any],
|
|
117
|
+
*,
|
|
118
|
+
status_code: int | None = None,
|
|
119
|
+
controller: str | None = None,
|
|
120
|
+
action: str | None = None,
|
|
121
|
+
) -> UGOSError:
|
|
122
|
+
code = _as_int(payload.get("errorCode") or payload.get("error_code") or payload.get("code"))
|
|
123
|
+
message = redact_text(str(payload.get("error") or payload.get("message") or "API error"))
|
|
124
|
+
exception = _exception_for_code(code, status_code)
|
|
125
|
+
return exception(
|
|
126
|
+
message,
|
|
127
|
+
code=code,
|
|
128
|
+
status_code=status_code,
|
|
129
|
+
payload=payload,
|
|
130
|
+
controller=controller,
|
|
131
|
+
action=action,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_TEXT_FALLBACK = object()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _raise_redirect(response: httpx.Response, controller: str | None, action: str | None) -> None:
|
|
139
|
+
location = response.headers.get("location", "")
|
|
140
|
+
if "utm/login" in location or location.rstrip("/").endswith("/login"):
|
|
141
|
+
raise SessionExpiredError(
|
|
142
|
+
"session expired or login required",
|
|
143
|
+
status_code=response.status_code,
|
|
144
|
+
controller=controller,
|
|
145
|
+
action=action,
|
|
146
|
+
)
|
|
147
|
+
raise HTTPStatusError(
|
|
148
|
+
f"unexpected redirect: HTTP {response.status_code}",
|
|
149
|
+
status_code=response.status_code,
|
|
150
|
+
payload=_body_snippet(response),
|
|
151
|
+
controller=controller,
|
|
152
|
+
action=action,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_csrf_failure(status_code: int, text: str) -> bool:
|
|
157
|
+
if status_code not in {400, 403}:
|
|
158
|
+
return False
|
|
159
|
+
lowered = text.lower()
|
|
160
|
+
return any(
|
|
161
|
+
phrase in lowered
|
|
162
|
+
for phrase in (
|
|
163
|
+
"csrf verification failed",
|
|
164
|
+
"csrf token missing",
|
|
165
|
+
"csrf token incorrect",
|
|
166
|
+
"csrf token invalid",
|
|
167
|
+
"csrf token not found",
|
|
168
|
+
"csrf cookie not set",
|
|
169
|
+
"csrf failed",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _raise_server_response(
|
|
175
|
+
response: httpx.Response,
|
|
176
|
+
text: str,
|
|
177
|
+
controller: str | None,
|
|
178
|
+
action: str | None,
|
|
179
|
+
) -> None:
|
|
180
|
+
payload = _try_json(text)
|
|
181
|
+
if is_error_payload(payload):
|
|
182
|
+
raise error_from_payload(
|
|
183
|
+
payload,
|
|
184
|
+
status_code=response.status_code,
|
|
185
|
+
controller=controller,
|
|
186
|
+
action=action,
|
|
187
|
+
)
|
|
188
|
+
raise ServerError(
|
|
189
|
+
f"HTTP {response.status_code}",
|
|
190
|
+
status_code=response.status_code,
|
|
191
|
+
payload=_body_snippet(response),
|
|
192
|
+
controller=controller,
|
|
193
|
+
action=action,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _raise_client_response(
|
|
198
|
+
response: httpx.Response,
|
|
199
|
+
text: str,
|
|
200
|
+
controller: str | None,
|
|
201
|
+
action: str | None,
|
|
202
|
+
) -> None:
|
|
203
|
+
payload = _try_json(text)
|
|
204
|
+
if is_error_payload(payload):
|
|
205
|
+
raise error_from_payload(
|
|
206
|
+
payload,
|
|
207
|
+
status_code=response.status_code,
|
|
208
|
+
controller=controller,
|
|
209
|
+
action=action,
|
|
210
|
+
)
|
|
211
|
+
raise HTTPStatusError(
|
|
212
|
+
f"HTTP {response.status_code}",
|
|
213
|
+
status_code=response.status_code,
|
|
214
|
+
payload=_body_snippet(response),
|
|
215
|
+
controller=controller,
|
|
216
|
+
action=action,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _raise_redirect_or_unauthorized(
|
|
221
|
+
response: httpx.Response,
|
|
222
|
+
controller: str | None,
|
|
223
|
+
action: str | None,
|
|
224
|
+
) -> None:
|
|
225
|
+
status = response.status_code
|
|
226
|
+
if 300 <= status < 400:
|
|
227
|
+
_raise_redirect(response, controller, action)
|
|
228
|
+
if status == 401:
|
|
229
|
+
raise SessionExpiredError(
|
|
230
|
+
"unauthorized",
|
|
231
|
+
status_code=status,
|
|
232
|
+
payload=_body_snippet(response),
|
|
233
|
+
controller=controller,
|
|
234
|
+
action=action,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _raise_csrf_or_status(
|
|
239
|
+
response: httpx.Response,
|
|
240
|
+
text: str,
|
|
241
|
+
controller: str | None,
|
|
242
|
+
action: str | None,
|
|
243
|
+
) -> None:
|
|
244
|
+
status = response.status_code
|
|
245
|
+
if _is_csrf_failure(status, text):
|
|
246
|
+
raise CSRFError(
|
|
247
|
+
"CSRF validation failed",
|
|
248
|
+
status_code=status,
|
|
249
|
+
payload=text,
|
|
250
|
+
controller=controller,
|
|
251
|
+
action=action,
|
|
252
|
+
)
|
|
253
|
+
if status >= 500:
|
|
254
|
+
_raise_server_response(response, text, controller, action)
|
|
255
|
+
if status >= 400:
|
|
256
|
+
_raise_client_response(response, text, controller, action)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _raise_for_http_status(
|
|
260
|
+
response: httpx.Response,
|
|
261
|
+
text: str,
|
|
262
|
+
controller: str | None,
|
|
263
|
+
action: str | None,
|
|
264
|
+
) -> None:
|
|
265
|
+
_raise_redirect_or_unauthorized(response, controller, action)
|
|
266
|
+
_raise_csrf_or_status(response, text, controller, action)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _empty_response(
|
|
270
|
+
response: httpx.Response,
|
|
271
|
+
expect: str,
|
|
272
|
+
controller: str | None,
|
|
273
|
+
action: str | None,
|
|
274
|
+
) -> Any:
|
|
275
|
+
if expect == "auto":
|
|
276
|
+
return None
|
|
277
|
+
raise DecodeError(
|
|
278
|
+
f"expected a {expect} response, got an empty body",
|
|
279
|
+
status_code=response.status_code,
|
|
280
|
+
payload=_body_snippet(response),
|
|
281
|
+
controller=controller,
|
|
282
|
+
action=action,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _decode_json_response(
|
|
287
|
+
response: httpx.Response,
|
|
288
|
+
text: str,
|
|
289
|
+
expect: str,
|
|
290
|
+
is_json_content: bool,
|
|
291
|
+
controller: str | None,
|
|
292
|
+
action: str | None,
|
|
293
|
+
) -> Any:
|
|
294
|
+
try:
|
|
295
|
+
payload = json.loads(text)
|
|
296
|
+
except json.JSONDecodeError as exc:
|
|
297
|
+
if expect == "json" or is_json_content:
|
|
298
|
+
raise DecodeError(
|
|
299
|
+
f"invalid JSON response: {exc}",
|
|
300
|
+
status_code=response.status_code,
|
|
301
|
+
payload=_body_snippet(response),
|
|
302
|
+
controller=controller,
|
|
303
|
+
action=action,
|
|
304
|
+
) from exc
|
|
305
|
+
return _TEXT_FALLBACK
|
|
306
|
+
if is_error_payload(payload):
|
|
307
|
+
raise error_from_payload(
|
|
308
|
+
payload,
|
|
309
|
+
status_code=response.status_code,
|
|
310
|
+
controller=controller,
|
|
311
|
+
action=action,
|
|
312
|
+
)
|
|
313
|
+
return _check_shape(
|
|
314
|
+
payload,
|
|
315
|
+
expect,
|
|
316
|
+
response=response,
|
|
317
|
+
controller=controller,
|
|
318
|
+
action=action,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _raise_or_return_text(
|
|
323
|
+
response: httpx.Response,
|
|
324
|
+
text: str,
|
|
325
|
+
expect: str,
|
|
326
|
+
controller: str | None,
|
|
327
|
+
action: str | None,
|
|
328
|
+
) -> Any:
|
|
329
|
+
if _looks_like_login_html(text):
|
|
330
|
+
raise SessionExpiredError(
|
|
331
|
+
"session expired or a login page was returned",
|
|
332
|
+
status_code=response.status_code,
|
|
333
|
+
payload=_body_snippet(response),
|
|
334
|
+
controller=controller,
|
|
335
|
+
action=action,
|
|
336
|
+
)
|
|
337
|
+
if _looks_like_html(response.headers.get("content-type", "").lower(), text):
|
|
338
|
+
raise ProtocolError(
|
|
339
|
+
"expected a JSON API response, got HTML",
|
|
340
|
+
status_code=response.status_code,
|
|
341
|
+
payload=_body_snippet(response),
|
|
342
|
+
controller=controller,
|
|
343
|
+
action=action,
|
|
344
|
+
)
|
|
345
|
+
if expect == "json":
|
|
346
|
+
raise DecodeError(
|
|
347
|
+
"expected JSON response",
|
|
348
|
+
status_code=response.status_code,
|
|
349
|
+
payload=_body_snippet(response),
|
|
350
|
+
controller=controller,
|
|
351
|
+
action=action,
|
|
352
|
+
)
|
|
353
|
+
if expect in {"list", "object", "scalar"}:
|
|
354
|
+
raise DecodeError(
|
|
355
|
+
f"expected a {expect} response",
|
|
356
|
+
status_code=response.status_code,
|
|
357
|
+
payload=_body_snippet(response),
|
|
358
|
+
controller=controller,
|
|
359
|
+
action=action,
|
|
360
|
+
)
|
|
361
|
+
return text
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _decode_body(
|
|
365
|
+
response: httpx.Response,
|
|
366
|
+
text: str,
|
|
367
|
+
expect: str,
|
|
368
|
+
controller: str | None,
|
|
369
|
+
action: str | None,
|
|
370
|
+
) -> Any:
|
|
371
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
372
|
+
is_json_content = "json" in content_type
|
|
373
|
+
if not is_json_content and _is_numeric_error_body(text):
|
|
374
|
+
raise ServerError(
|
|
375
|
+
f"upstream returned error code {text.strip()}",
|
|
376
|
+
status_code=response.status_code,
|
|
377
|
+
payload=_body_snippet(response),
|
|
378
|
+
controller=controller,
|
|
379
|
+
action=action,
|
|
380
|
+
)
|
|
381
|
+
if is_json_content or _looks_like_json(text):
|
|
382
|
+
return _decode_json_or_text(
|
|
383
|
+
response,
|
|
384
|
+
text,
|
|
385
|
+
expect,
|
|
386
|
+
is_json_content,
|
|
387
|
+
controller,
|
|
388
|
+
action,
|
|
389
|
+
)
|
|
390
|
+
return _raise_or_return_text(response, text, expect, controller, action)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _decode_json_or_text(
|
|
394
|
+
response: httpx.Response,
|
|
395
|
+
text: str,
|
|
396
|
+
expect: str,
|
|
397
|
+
is_json_content: bool,
|
|
398
|
+
controller: str | None,
|
|
399
|
+
action: str | None,
|
|
400
|
+
) -> Any:
|
|
401
|
+
result = _decode_json_response(
|
|
402
|
+
response,
|
|
403
|
+
text,
|
|
404
|
+
expect,
|
|
405
|
+
is_json_content,
|
|
406
|
+
controller,
|
|
407
|
+
action,
|
|
408
|
+
)
|
|
409
|
+
if result is _TEXT_FALLBACK:
|
|
410
|
+
return _raise_or_return_text(response, text, expect, controller, action)
|
|
411
|
+
return result
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _decode_success(
|
|
415
|
+
response: httpx.Response,
|
|
416
|
+
text: str,
|
|
417
|
+
expect: str,
|
|
418
|
+
controller: str | None,
|
|
419
|
+
action: str | None,
|
|
420
|
+
) -> Any:
|
|
421
|
+
if expect == "bytes":
|
|
422
|
+
return response.content
|
|
423
|
+
if expect == "text":
|
|
424
|
+
return text
|
|
425
|
+
if not response.content or response.status_code == 204:
|
|
426
|
+
return _empty_response(response, expect, controller, action)
|
|
427
|
+
return _decode_body(response, text, expect, controller, action)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def parse_response(
|
|
431
|
+
response: httpx.Response,
|
|
432
|
+
*,
|
|
433
|
+
expect: str = "auto",
|
|
434
|
+
controller: str | None = None,
|
|
435
|
+
action: str | None = None,
|
|
436
|
+
) -> Any:
|
|
437
|
+
"""Decode a response or raise a mapped exception."""
|
|
438
|
+
|
|
439
|
+
if expect not in _EXPECT_VALUES:
|
|
440
|
+
raise ValueError(f"unsupported expect value: {expect!r}")
|
|
441
|
+
text = response.text
|
|
442
|
+
_raise_for_http_status(response, text, controller, action)
|
|
443
|
+
return _decode_success(response, text, expect, controller, action)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _is_list_payload(payload: Any) -> bool:
|
|
447
|
+
if isinstance(payload, list):
|
|
448
|
+
return True
|
|
449
|
+
return isinstance(payload, Mapping) and isinstance(payload.get("items"), list)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _matches_expect(payload: Any, expect: str) -> bool:
|
|
453
|
+
if expect == "object":
|
|
454
|
+
return isinstance(payload, Mapping)
|
|
455
|
+
if expect == "list":
|
|
456
|
+
return _is_list_payload(payload)
|
|
457
|
+
if expect == "scalar":
|
|
458
|
+
return not isinstance(payload, (Mapping, list, tuple))
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _check_shape(
|
|
463
|
+
payload: Any,
|
|
464
|
+
expect: str,
|
|
465
|
+
*,
|
|
466
|
+
response: httpx.Response,
|
|
467
|
+
controller: str | None,
|
|
468
|
+
action: str | None,
|
|
469
|
+
) -> Any:
|
|
470
|
+
if expect in {"auto", "json"}:
|
|
471
|
+
return payload
|
|
472
|
+
if _matches_expect(payload, expect):
|
|
473
|
+
return payload
|
|
474
|
+
raise DecodeError(
|
|
475
|
+
f"expected a {expect} response",
|
|
476
|
+
status_code=response.status_code,
|
|
477
|
+
payload=_body_snippet(response),
|
|
478
|
+
controller=controller,
|
|
479
|
+
action=action,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _is_numeric_error_body(text: str) -> bool:
|
|
484
|
+
stripped = text.strip()
|
|
485
|
+
return stripped.isdigit() and len(stripped) == 3 and 400 <= int(stripped) <= 599
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _looks_like_html(content_type: str, text: str) -> bool:
|
|
489
|
+
if "html" in content_type:
|
|
490
|
+
return True
|
|
491
|
+
head = text.lstrip()[:128].lower()
|
|
492
|
+
return head.startswith("<!doctype html") or head.startswith("<html")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _looks_like_login_html(text: str) -> bool:
|
|
496
|
+
lowered = text.lower()
|
|
497
|
+
if not _looks_like_html("", text):
|
|
498
|
+
return False
|
|
499
|
+
if "utm/login" in lowered:
|
|
500
|
+
return True
|
|
501
|
+
return any(
|
|
502
|
+
marker in lowered
|
|
503
|
+
for marker in (
|
|
504
|
+
'name="csrfmiddlewaretoken"',
|
|
505
|
+
"name='csrfmiddlewaretoken'",
|
|
506
|
+
'action="/utm/login',
|
|
507
|
+
"action='/utm/login",
|
|
508
|
+
"<title>login",
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _try_json(text: str) -> Any:
|
|
514
|
+
try:
|
|
515
|
+
return json.loads(text)
|
|
516
|
+
except (json.JSONDecodeError, TypeError):
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _looks_like_json(text: str) -> bool:
|
|
521
|
+
stripped = text.lstrip()
|
|
522
|
+
if not stripped:
|
|
523
|
+
return False
|
|
524
|
+
if stripped[0] in '[{"':
|
|
525
|
+
return True
|
|
526
|
+
return (
|
|
527
|
+
stripped in {"true", "false", "null"} or stripped.replace(".", "", 1).lstrip("-").isdigit()
|
|
528
|
+
)
|
ugos/_config.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Client configuration and URL normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .__about__ import __version__
|
|
14
|
+
from .exceptions import ConfigurationError
|
|
15
|
+
|
|
16
|
+
DEFAULT_PORT = 8001
|
|
17
|
+
DEFAULT_TIMEOUT = 30.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate_base_args(base_url: str | None, host: str | None) -> None:
|
|
21
|
+
if base_url and host:
|
|
22
|
+
raise ConfigurationError("pass either base_url or host, not both")
|
|
23
|
+
if not base_url and not host:
|
|
24
|
+
raise ConfigurationError("base_url or host is required")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _host_base_url(host: str, port: int, scheme: str) -> str:
|
|
28
|
+
if "://" in host:
|
|
29
|
+
raise ConfigurationError("host must not contain a scheme")
|
|
30
|
+
return f"{scheme}://{host}:{port}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _with_scheme(raw: str) -> str:
|
|
34
|
+
if "://" not in raw:
|
|
35
|
+
return f"https://{raw}"
|
|
36
|
+
return raw
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _raw_base_url(
|
|
40
|
+
base_url: str | None,
|
|
41
|
+
host: str | None,
|
|
42
|
+
port: int,
|
|
43
|
+
scheme: str,
|
|
44
|
+
) -> str:
|
|
45
|
+
_validate_base_args(base_url, host)
|
|
46
|
+
if host:
|
|
47
|
+
return _host_base_url(host, port, scheme)
|
|
48
|
+
if base_url is None: # pragma: no cover - guarded by _validate_base_args
|
|
49
|
+
raise ConfigurationError("base_url is required")
|
|
50
|
+
return base_url.strip()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validate_credentials(parts: Any) -> None:
|
|
54
|
+
if parts.username or parts.password:
|
|
55
|
+
raise ConfigurationError("credentials in the URL are not allowed")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_scheme_host(parts: Any) -> None:
|
|
59
|
+
if parts.scheme not in {"http", "https"}:
|
|
60
|
+
raise ConfigurationError(f"unsupported URL scheme: {parts.scheme}")
|
|
61
|
+
if not parts.hostname:
|
|
62
|
+
raise ConfigurationError("base_url has no host")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _validate_query_fragment(parts: Any) -> None:
|
|
66
|
+
if parts.query or parts.fragment:
|
|
67
|
+
raise ConfigurationError("base_url must not contain query or fragment")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _validate_url_parts(parts: Any) -> None:
|
|
71
|
+
_validate_credentials(parts)
|
|
72
|
+
_validate_scheme_host(parts)
|
|
73
|
+
_validate_query_fragment(parts)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _default_netloc(hostname: str) -> str:
|
|
77
|
+
if ":" in hostname and not hostname.startswith("["):
|
|
78
|
+
return f"[{hostname}]:{DEFAULT_PORT}"
|
|
79
|
+
return f"{hostname}:{DEFAULT_PORT}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _netloc(parts: Any) -> str:
|
|
83
|
+
try:
|
|
84
|
+
port = parts.port
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise ConfigurationError(f"invalid port in base_url: {exc}") from exc
|
|
87
|
+
if port is None:
|
|
88
|
+
return _default_netloc(parts.hostname)
|
|
89
|
+
return parts.netloc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _path_with_slash(path: str) -> str:
|
|
93
|
+
if not path:
|
|
94
|
+
return "/"
|
|
95
|
+
if path.endswith("/"):
|
|
96
|
+
return path
|
|
97
|
+
return f"{path}/"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def normalize_base_url(
|
|
101
|
+
base_url: str | None = None,
|
|
102
|
+
*,
|
|
103
|
+
host: str | None = None,
|
|
104
|
+
port: int = DEFAULT_PORT,
|
|
105
|
+
scheme: str = "https",
|
|
106
|
+
) -> str:
|
|
107
|
+
"""Return a normalized ``https://host:port/`` base URL."""
|
|
108
|
+
|
|
109
|
+
raw = _with_scheme(_raw_base_url(base_url, host, port, scheme))
|
|
110
|
+
parts = urlsplit(raw)
|
|
111
|
+
_validate_url_parts(parts)
|
|
112
|
+
return urlunsplit((parts.scheme, _netloc(parts), _path_with_slash(parts.path), "", ""))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclasses.dataclass(frozen=True)
|
|
116
|
+
class ClientConfig:
|
|
117
|
+
base_url: str
|
|
118
|
+
verify: bool | str | Path = True
|
|
119
|
+
timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT
|
|
120
|
+
headers: Mapping[str, str] = dataclasses.field(default_factory=dict, repr=False)
|
|
121
|
+
user_agent: str = f"ugos/{__version__}"
|
|
122
|
+
trust_env: bool = False
|
|
123
|
+
follow_redirects: bool = False
|
|
124
|
+
max_retries: int = 2
|
|
125
|
+
retry_backoff: float = 0.25
|
|
126
|
+
thread_safe: bool = True
|
|
127
|
+
max_download_bytes: int | None = 64 * 1024 * 1024
|
|
128
|
+
max_response_bytes: int | None = 16 * 1024 * 1024
|
|
129
|
+
allow_insecure_http: bool = False
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _validate_transport(base_url: str, allow_insecure_http: bool) -> None:
|
|
133
|
+
if base_url.startswith("http://") and not allow_insecure_http:
|
|
134
|
+
raise ConfigurationError(
|
|
135
|
+
"plain HTTP is disabled; pass allow_insecure_http=True for a trusted test environment"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _validate_limits(max_download_bytes: int | None, max_response_bytes: int | None) -> None:
|
|
140
|
+
if max_download_bytes is not None and max_download_bytes < 0:
|
|
141
|
+
raise ConfigurationError("max_download_bytes must be non-negative")
|
|
142
|
+
if max_response_bytes is not None and max_response_bytes < 0:
|
|
143
|
+
raise ConfigurationError("max_response_bytes must be non-negative")
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def build(
|
|
147
|
+
cls,
|
|
148
|
+
base_url: str | None = None,
|
|
149
|
+
*,
|
|
150
|
+
host: str | None = None,
|
|
151
|
+
port: int = DEFAULT_PORT,
|
|
152
|
+
scheme: str = "https",
|
|
153
|
+
verify: bool | str | Path = True,
|
|
154
|
+
timeout: float | httpx.Timeout | None = DEFAULT_TIMEOUT,
|
|
155
|
+
headers: Mapping[str, str] | None = None,
|
|
156
|
+
user_agent: str | None = None,
|
|
157
|
+
trust_env: bool = False,
|
|
158
|
+
follow_redirects: bool = False,
|
|
159
|
+
max_retries: int = 2,
|
|
160
|
+
retry_backoff: float = 0.25,
|
|
161
|
+
thread_safe: bool = True,
|
|
162
|
+
max_download_bytes: int | None = 64 * 1024 * 1024,
|
|
163
|
+
max_response_bytes: int | None = 16 * 1024 * 1024,
|
|
164
|
+
allow_insecure_http: bool = False,
|
|
165
|
+
) -> ClientConfig:
|
|
166
|
+
normalized = normalize_base_url(base_url, host=host, port=port, scheme=scheme)
|
|
167
|
+
cls._validate_transport(normalized, allow_insecure_http)
|
|
168
|
+
cls._validate_limits(max_download_bytes, max_response_bytes)
|
|
169
|
+
return cls(
|
|
170
|
+
base_url=normalized,
|
|
171
|
+
verify=verify,
|
|
172
|
+
timeout=timeout,
|
|
173
|
+
headers=dict(headers or {}),
|
|
174
|
+
user_agent=user_agent or f"ugos/{__version__}",
|
|
175
|
+
trust_env=trust_env,
|
|
176
|
+
follow_redirects=follow_redirects,
|
|
177
|
+
max_retries=max(0, max_retries),
|
|
178
|
+
retry_backoff=max(0.0, retry_backoff),
|
|
179
|
+
thread_safe=thread_safe,
|
|
180
|
+
max_download_bytes=max_download_bytes,
|
|
181
|
+
max_response_bytes=max_response_bytes,
|
|
182
|
+
allow_insecure_http=allow_insecure_http,
|
|
183
|
+
)
|
ugos/_data/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generated package data."""
|