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/_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."""