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
@@ -0,0 +1,501 @@
1
+ """Download, upload and pagination implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import tempfile
8
+ from collections.abc import Collection, Iterator, Mapping
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from ._client_base import _ClientBase
15
+ from ._client_helpers import (
16
+ _capture_file_positions,
17
+ _DownloadSpec,
18
+ _error_body_limit,
19
+ _filename_from_response,
20
+ _limited_chunks,
21
+ _merge_params,
22
+ _multipart_fields,
23
+ _resolve_destination,
24
+ _restore_file_positions,
25
+ _RetryState,
26
+ _sanitize_filename,
27
+ )
28
+ from ._codec import _looks_like_login_html, parse_response
29
+ from ._encoding import encode_pairs, encode_params
30
+ from ._pagination import paginate
31
+ from ._redaction import redact_headers, redact_text
32
+ from ._transport import FORM_HEADERS
33
+ from ._transport import UNSET as _UNSET
34
+ from .exceptions import (
35
+ ConfigurationError,
36
+ CSRFError,
37
+ DownloadError,
38
+ HTTPStatusError,
39
+ SessionExpiredError,
40
+ )
41
+ from .models.common import DownloadedFile
42
+
43
+
44
+ class _TransferMixin(_ClientBase):
45
+ def _build_download_request(
46
+ self,
47
+ spec: _DownloadSpec,
48
+ ) -> tuple[bytes | str | None, list[tuple[str, str]] | None]:
49
+ request_params = dict(spec.params)
50
+ if spec.method == "GET":
51
+ return None, encode_pairs(request_params, json_fields=spec.json_fields)
52
+ with self._lock:
53
+ request_params["csrfmiddlewaretoken"] = self._auth.ensure_csrf()
54
+ body = encode_params(request_params, json_fields=spec.json_fields)
55
+ return body, None
56
+
57
+ def _raise_download_status(self, response: httpx.Response, spec: _DownloadSpec) -> None:
58
+ status = response.status_code
59
+ if 300 <= status < 400:
60
+ location = response.headers.get("location", "")
61
+ if "utm/login" in location or location.rstrip("/").endswith("/login"):
62
+ raise SessionExpiredError(
63
+ "session expired or login required",
64
+ status_code=status,
65
+ controller=spec.controller,
66
+ action=spec.action,
67
+ )
68
+ raise HTTPStatusError(
69
+ f"unexpected redirect: HTTP {status}",
70
+ status_code=status,
71
+ controller=spec.controller,
72
+ action=spec.action,
73
+ )
74
+ if status == 401:
75
+ raise SessionExpiredError(
76
+ "unauthorized",
77
+ status_code=status,
78
+ controller=spec.controller,
79
+ action=spec.action,
80
+ )
81
+
82
+ def _raise_download_html(
83
+ self,
84
+ response: httpx.Response,
85
+ first: bytes,
86
+ raw_chunks: Iterator[bytes],
87
+ spec: _DownloadSpec,
88
+ ) -> None:
89
+ payload_bytes = b"".join(
90
+ _limited_chunks(
91
+ first,
92
+ raw_chunks,
93
+ limit=_error_body_limit(spec.limit),
94
+ controller=spec.controller,
95
+ action=spec.action,
96
+ )
97
+ )
98
+ text = payload_bytes.decode(response.encoding or "utf-8", errors="replace")
99
+ if _looks_like_login_html(text):
100
+ raise SessionExpiredError(
101
+ "session expired or a login page was returned",
102
+ status_code=response.status_code,
103
+ controller=spec.controller,
104
+ action=spec.action,
105
+ )
106
+ raise DownloadError(
107
+ "expected a file, got HTML",
108
+ status_code=response.status_code,
109
+ controller=spec.controller,
110
+ action=spec.action,
111
+ )
112
+
113
+ def _is_json_like(self, content_type: str, head: bytes) -> bool:
114
+ if "json" in content_type.lower():
115
+ return True
116
+ return head.lstrip().startswith((b"{", b"[")) and any(
117
+ marker in head for marker in (b'"success"', b'"errorCode"', b'"error_code"', b'"error"')
118
+ )
119
+
120
+ def _raise_download_payload(
121
+ self,
122
+ response: httpx.Response,
123
+ content_type: str,
124
+ first: bytes,
125
+ raw_chunks: Iterator[bytes],
126
+ spec: _DownloadSpec,
127
+ ) -> None:
128
+ payload_bytes = b"".join(
129
+ _limited_chunks(
130
+ first,
131
+ raw_chunks,
132
+ limit=_error_body_limit(spec.limit),
133
+ controller=spec.controller,
134
+ action=spec.action,
135
+ )
136
+ )
137
+ with self._lock:
138
+ self._auth.note_response(response)
139
+ synthetic = httpx.Response(
140
+ response.status_code,
141
+ headers={"content-type": content_type},
142
+ content=payload_bytes,
143
+ )
144
+ data = parse_response(
145
+ synthetic,
146
+ expect="auto",
147
+ controller=spec.controller,
148
+ action=spec.action,
149
+ )
150
+ raise DownloadError(
151
+ f"expected a file, got JSON: {redact_text(str(data))[:500]}",
152
+ controller=spec.controller,
153
+ action=spec.action,
154
+ )
155
+
156
+ def _download_to_memory(
157
+ self,
158
+ first: bytes,
159
+ raw_chunks: Iterator[bytes],
160
+ spec: _DownloadSpec,
161
+ content_type: str,
162
+ filename: str,
163
+ headers: Mapping[str, str],
164
+ ) -> DownloadedFile:
165
+ content = b"".join(
166
+ _limited_chunks(
167
+ first,
168
+ raw_chunks,
169
+ limit=spec.limit,
170
+ controller=spec.controller,
171
+ action=spec.action,
172
+ )
173
+ )
174
+ return DownloadedFile(
175
+ content=content,
176
+ filename=filename,
177
+ content_type=content_type or None,
178
+ headers=headers,
179
+ )
180
+
181
+ def _write_download(
182
+ self,
183
+ target: Path,
184
+ first: bytes,
185
+ raw_chunks: Iterator[bytes],
186
+ spec: _DownloadSpec,
187
+ ) -> None:
188
+ target.parent.mkdir(parents=True, exist_ok=True)
189
+ fd, temp_name = tempfile.mkstemp(
190
+ dir=target.parent,
191
+ prefix=f".{target.name}.",
192
+ suffix=".part",
193
+ )
194
+ temp = Path(temp_name)
195
+ try:
196
+ with os.fdopen(fd, "wb") as handle:
197
+ for chunk in _limited_chunks(
198
+ first,
199
+ raw_chunks,
200
+ limit=spec.limit,
201
+ controller=spec.controller,
202
+ action=spec.action,
203
+ ):
204
+ handle.write(chunk)
205
+ os.chmod(temp, 0o600)
206
+ os.replace(temp, target)
207
+ except Exception:
208
+ with contextlib.suppress(OSError):
209
+ temp.unlink()
210
+ raise
211
+
212
+ def _download_to_path(
213
+ self,
214
+ target: Path,
215
+ first: bytes,
216
+ raw_chunks: Iterator[bytes],
217
+ spec: _DownloadSpec,
218
+ content_type: str,
219
+ filename: str,
220
+ headers: Mapping[str, str],
221
+ ) -> DownloadedFile:
222
+ if target.exists() and not spec.overwrite:
223
+ raise FileExistsError(target)
224
+ self._write_download(target, first, raw_chunks, spec)
225
+ return DownloadedFile(
226
+ content=None,
227
+ filename=target.name,
228
+ content_type=content_type or None,
229
+ headers=headers,
230
+ path=target,
231
+ )
232
+
233
+ def _is_download_error(
234
+ self,
235
+ response: httpx.Response,
236
+ content_type: str,
237
+ first: bytes,
238
+ ) -> bool:
239
+ if response.status_code >= 400:
240
+ return True
241
+ disposition = response.headers.get("content-disposition", "").lower()
242
+ if "attachment" in disposition:
243
+ return False
244
+ return self._is_json_like(content_type, first.lstrip()[:512])
245
+
246
+ def _finish_download(
247
+ self,
248
+ response: httpx.Response,
249
+ spec: _DownloadSpec,
250
+ first: bytes,
251
+ raw_chunks: Iterator[bytes],
252
+ content_type: str,
253
+ ) -> DownloadedFile:
254
+ with self._lock:
255
+ self._auth.note_response(response)
256
+ name = _sanitize_filename(spec.filename or _filename_from_response(response, spec.path))
257
+ headers = redact_headers(dict(response.headers))
258
+ if spec.dest is None:
259
+ return self._download_to_memory(first, raw_chunks, spec, content_type, name, headers)
260
+ target = _resolve_destination(spec.dest, name)
261
+ return self._download_to_path(target, first, raw_chunks, spec, content_type, name, headers)
262
+
263
+ def _handle_download_response(
264
+ self,
265
+ response: httpx.Response,
266
+ spec: _DownloadSpec,
267
+ ) -> DownloadedFile:
268
+ self._raise_download_status(response, spec)
269
+ content_type = response.headers.get("content-type", "")
270
+ raw_chunks = response.iter_bytes(chunk_size=spec.chunk_size)
271
+ first = next(raw_chunks, b"")
272
+ if "html" in content_type.lower():
273
+ self._raise_download_html(response, first, raw_chunks, spec)
274
+ if self._is_download_error(response, content_type, first):
275
+ self._raise_download_payload(response, content_type, first, raw_chunks, spec)
276
+ return self._finish_download(response, spec, first, raw_chunks, content_type)
277
+
278
+ def _retry_download(
279
+ self,
280
+ exc: Exception,
281
+ state: _RetryState,
282
+ generation: int,
283
+ spec: _DownloadSpec,
284
+ ) -> bool:
285
+ if isinstance(exc, CSRFError):
286
+ return self._retry_csrf(state)
287
+ return self._retry_session(
288
+ state,
289
+ generation,
290
+ spec.action,
291
+ spec.method in {"GET", "HEAD", "OPTIONS"},
292
+ exc,
293
+ )
294
+
295
+ def download(
296
+ self,
297
+ controller: str,
298
+ action: str,
299
+ /,
300
+ *,
301
+ dest: str | Path | None = None,
302
+ filename: str | None = None,
303
+ overwrite: bool = False,
304
+ method: str = "GET",
305
+ payload: Mapping[str, Any] | None = None,
306
+ json_fields: Collection[str] = (),
307
+ timeout: float | httpx.Timeout | object | None = _UNSET,
308
+ strict: bool | None = None,
309
+ chunk_size: int = 64 * 1024,
310
+ max_bytes: int | None = None,
311
+ **params: Any,
312
+ ) -> DownloadedFile:
313
+ """Download a file and optionally save it to ``dest``."""
314
+
315
+ self._validate(controller, action, strict=strict)
316
+ limit = max_bytes if max_bytes is not None else self._config.max_download_bytes
317
+ if limit is not None and limit < 0:
318
+ raise ValueError("max_bytes must be non-negative")
319
+ spec = _DownloadSpec(
320
+ controller=controller,
321
+ action=action,
322
+ path=f"{controller}/{action}",
323
+ method=method.upper(),
324
+ params=_merge_params(payload, params),
325
+ json_fields=json_fields,
326
+ timeout=timeout,
327
+ chunk_size=chunk_size,
328
+ limit=limit,
329
+ dest=dest,
330
+ filename=filename,
331
+ overwrite=overwrite,
332
+ )
333
+ with self._lock:
334
+ self._prepare()
335
+ return self._download_loop(spec)
336
+
337
+ def _download_loop(self, spec: _DownloadSpec) -> DownloadedFile:
338
+ state = _RetryState()
339
+ while True:
340
+ with self._lock:
341
+ generation = self._auth.generation
342
+ body, query = self._build_download_request(spec)
343
+ try:
344
+ with self._transport.stream(
345
+ spec.method,
346
+ spec.path,
347
+ params=query,
348
+ data=body,
349
+ headers={**(FORM_HEADERS if body else {}), "Accept": "*/*"},
350
+ timeout=spec.timeout,
351
+ idempotent=spec.method in {"GET", "HEAD", "OPTIONS"},
352
+ ) as response:
353
+ return self._handle_download_response(response, spec)
354
+ except (CSRFError, SessionExpiredError) as exc:
355
+ if not self._retry_download(exc, state, generation, spec):
356
+ raise
357
+
358
+ @staticmethod
359
+ def _restore_upload_positions(positions: list[tuple[Any, int]] | None) -> None:
360
+ if positions is None:
361
+ raise ConfigurationError("cannot retry upload with non-seekable files")
362
+ _restore_file_positions(positions)
363
+
364
+ def _upload_once(
365
+ self,
366
+ controller: str,
367
+ action: str,
368
+ files: Mapping[str, Any],
369
+ merged: Mapping[str, Any],
370
+ json_fields: Collection[str],
371
+ query: Mapping[str, Any] | None,
372
+ timeout: Any,
373
+ ) -> Any:
374
+ with self._lock:
375
+ token = self._auth.ensure_csrf()
376
+ data = _multipart_fields(
377
+ encode_pairs(
378
+ {**merged, "csrfmiddlewaretoken": token},
379
+ json_fields=json_fields,
380
+ )
381
+ )
382
+ query_params = encode_pairs(query, json_fields=json_fields) if query else None
383
+ with self._lock:
384
+ generation = self._auth.generation
385
+ response = self._transport.request(
386
+ "POST",
387
+ f"{controller}/{action}",
388
+ params=query_params,
389
+ data=data,
390
+ files=files,
391
+ headers={"Accept": "application/json, text/plain, */*"},
392
+ idempotent=False,
393
+ timeout=timeout,
394
+ )
395
+ with self._lock:
396
+ self._auth.note_response(response)
397
+ try:
398
+ return parse_response(
399
+ response,
400
+ expect="auto",
401
+ controller=controller,
402
+ action=action,
403
+ )
404
+ except SessionExpiredError:
405
+ self._refresh_session(generation)
406
+ raise
407
+
408
+ def _upload_loop(
409
+ self,
410
+ controller: str,
411
+ action: str,
412
+ files: Mapping[str, Any],
413
+ merged: Mapping[str, Any],
414
+ json_fields: Collection[str],
415
+ query: Mapping[str, Any] | None,
416
+ timeout: Any,
417
+ positions: list[tuple[Any, int]] | None,
418
+ ) -> Any:
419
+ csrf_retries = 0
420
+ attempt = 0
421
+ while True:
422
+ if attempt > 0:
423
+ self._restore_upload_positions(positions)
424
+ attempt += 1
425
+ try:
426
+ return self._upload_once(
427
+ controller,
428
+ action,
429
+ files,
430
+ merged,
431
+ json_fields,
432
+ query,
433
+ timeout,
434
+ )
435
+ except CSRFError:
436
+ if csrf_retries >= 1:
437
+ raise
438
+ csrf_retries += 1
439
+ with self._lock:
440
+ self._auth.refresh_csrf()
441
+
442
+ def upload(
443
+ self,
444
+ controller: str,
445
+ action: str,
446
+ /,
447
+ *,
448
+ files: Mapping[str, Any],
449
+ payload: Mapping[str, Any] | None = None,
450
+ json_fields: Collection[str] = (),
451
+ query: Mapping[str, Any] | None = None,
452
+ timeout: float | httpx.Timeout | object | None = _UNSET,
453
+ strict: bool | None = None,
454
+ **params: Any,
455
+ ) -> Any:
456
+ """Upload files using ``multipart/form-data``."""
457
+
458
+ self._validate(controller, action, strict=strict)
459
+ merged = _merge_params(payload, params)
460
+ with self._lock:
461
+ self._prepare()
462
+ return self._upload_loop(
463
+ controller,
464
+ action,
465
+ files,
466
+ merged,
467
+ json_fields,
468
+ query,
469
+ timeout,
470
+ _capture_file_positions(files),
471
+ )
472
+
473
+ def iter_all(
474
+ self,
475
+ controller: str,
476
+ action: str,
477
+ /,
478
+ *,
479
+ page_size: int = 100,
480
+ start: int = 0,
481
+ max_items: int | None = None,
482
+ max_pages: int = 1000,
483
+ payload: Mapping[str, Any] | None = None,
484
+ strict: bool | None = None,
485
+ **params: Any,
486
+ ) -> Iterator[Any]:
487
+ """Iterate over all items of a paginated list action."""
488
+
489
+ self._validate(controller, action, strict=strict)
490
+ merged = _merge_params(payload, params)
491
+ return paginate(
492
+ self,
493
+ controller,
494
+ action,
495
+ page_size=page_size,
496
+ start=start,
497
+ max_items=max_items,
498
+ max_pages=max_pages,
499
+ strict=strict,
500
+ **merged,
501
+ )