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/_transport.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Synchronous HTTP transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import time
|
|
7
|
+
import warnings
|
|
8
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._config import ClientConfig
|
|
14
|
+
from ._encoding import encode_params
|
|
15
|
+
from .exceptions import ConfigurationError, ProtocolError, RequestTimeoutError, TransportError
|
|
16
|
+
|
|
17
|
+
FORM_HEADERS = {
|
|
18
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
19
|
+
"Accept": "application/json, text/plain, */*",
|
|
20
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
HTML_HEADERS = {
|
|
24
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
UNSET = object()
|
|
28
|
+
_UNSET = UNSET
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HttpTransport:
|
|
32
|
+
"""Thin wrapper around :class:`httpx.Client`."""
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _warn_insecure(verify: Any) -> None:
|
|
36
|
+
if verify is not False:
|
|
37
|
+
return
|
|
38
|
+
warnings.warn(
|
|
39
|
+
"TLS verification is disabled; use verify=False only in a trusted test environment",
|
|
40
|
+
RuntimeWarning,
|
|
41
|
+
stacklevel=3,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _coerce_verify(verify: Any) -> Any:
|
|
46
|
+
if hasattr(verify, "__fspath__"):
|
|
47
|
+
return str(verify)
|
|
48
|
+
return verify
|
|
49
|
+
|
|
50
|
+
def _use_custom_client(self, http_client: httpx.Client) -> None:
|
|
51
|
+
client_base = str(http_client.base_url).rstrip("/")
|
|
52
|
+
config_base = self._config.base_url.rstrip("/")
|
|
53
|
+
if client_base != config_base:
|
|
54
|
+
raise ConfigurationError("http_client.base_url does not match the configured base_url")
|
|
55
|
+
self._warn_insecure(self._config.verify)
|
|
56
|
+
if http_client.follow_redirects:
|
|
57
|
+
warnings.warn(
|
|
58
|
+
"custom http_client has follow_redirects=True; "
|
|
59
|
+
"configured headers may be forwarded on cross-origin redirects",
|
|
60
|
+
RuntimeWarning,
|
|
61
|
+
stacklevel=3,
|
|
62
|
+
)
|
|
63
|
+
self._client = http_client
|
|
64
|
+
self._owns_client = False
|
|
65
|
+
|
|
66
|
+
def _create_client(self) -> None:
|
|
67
|
+
self._warn_insecure(self._config.verify)
|
|
68
|
+
timeout: float | httpx.Timeout | None = self._config.timeout
|
|
69
|
+
self._client = httpx.Client(
|
|
70
|
+
base_url=self._config.base_url,
|
|
71
|
+
verify=self._coerce_verify(self._config.verify),
|
|
72
|
+
timeout=timeout if timeout is not None else httpx.Timeout(None),
|
|
73
|
+
follow_redirects=self._config.follow_redirects,
|
|
74
|
+
trust_env=self._config.trust_env,
|
|
75
|
+
headers={
|
|
76
|
+
"User-Agent": self._config.user_agent,
|
|
77
|
+
**dict(self._config.headers),
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
self._owns_client = True
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
config: ClientConfig,
|
|
85
|
+
*,
|
|
86
|
+
http_client: httpx.Client | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
self._config = config
|
|
89
|
+
if http_client is not None:
|
|
90
|
+
self._use_custom_client(http_client)
|
|
91
|
+
else:
|
|
92
|
+
self._create_client()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def client(self) -> httpx.Client:
|
|
96
|
+
return self._client
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def config(self) -> ClientConfig:
|
|
100
|
+
return self._config
|
|
101
|
+
|
|
102
|
+
def _request_once(
|
|
103
|
+
self,
|
|
104
|
+
method: str,
|
|
105
|
+
path: str,
|
|
106
|
+
*,
|
|
107
|
+
params: Any,
|
|
108
|
+
content: Any,
|
|
109
|
+
form_data: Any,
|
|
110
|
+
files: Any,
|
|
111
|
+
headers: Mapping[str, str] | None,
|
|
112
|
+
timeout: Any,
|
|
113
|
+
follow_redirects: bool | None,
|
|
114
|
+
) -> httpx.Response:
|
|
115
|
+
try:
|
|
116
|
+
return self._client.request(
|
|
117
|
+
method,
|
|
118
|
+
path,
|
|
119
|
+
params=params,
|
|
120
|
+
content=content,
|
|
121
|
+
data=form_data,
|
|
122
|
+
files=files,
|
|
123
|
+
headers={**dict(self._config.headers), **dict(headers or {})},
|
|
124
|
+
timeout=timeout,
|
|
125
|
+
follow_redirects=(
|
|
126
|
+
follow_redirects
|
|
127
|
+
if follow_redirects is not None
|
|
128
|
+
else self._config.follow_redirects
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
except httpx.TimeoutException as exc:
|
|
132
|
+
raise RequestTimeoutError(str(exc)) from exc
|
|
133
|
+
except (httpx.RequestError, httpx.InvalidURL) as exc:
|
|
134
|
+
raise TransportError(str(exc)) from exc
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _can_retry(idempotent: bool, attempt: int, attempts: int) -> bool:
|
|
138
|
+
return idempotent and attempt + 1 < attempts
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _is_retryable_status(status_code: int) -> bool:
|
|
142
|
+
return status_code in {429, 502, 503, 504}
|
|
143
|
+
|
|
144
|
+
def _check_response_size(self, response: httpx.Response) -> None:
|
|
145
|
+
limit = self._config.max_response_bytes
|
|
146
|
+
if limit is None:
|
|
147
|
+
return
|
|
148
|
+
if len(response.content) <= limit:
|
|
149
|
+
return
|
|
150
|
+
response.close()
|
|
151
|
+
raise ProtocolError(
|
|
152
|
+
f"response exceeds {limit} bytes",
|
|
153
|
+
status_code=response.status_code,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _handle_response(
|
|
157
|
+
self,
|
|
158
|
+
response: httpx.Response,
|
|
159
|
+
*,
|
|
160
|
+
idempotent: bool,
|
|
161
|
+
attempt: int,
|
|
162
|
+
attempts: int,
|
|
163
|
+
) -> httpx.Response | None:
|
|
164
|
+
self._check_response_size(response)
|
|
165
|
+
if not self._is_retryable_status(response.status_code):
|
|
166
|
+
return response
|
|
167
|
+
if not self._can_retry(idempotent, attempt, attempts):
|
|
168
|
+
return response
|
|
169
|
+
response.close()
|
|
170
|
+
self._sleep(attempt, response.headers.get("retry-after"))
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def _request_attempt(
|
|
174
|
+
self,
|
|
175
|
+
method: str,
|
|
176
|
+
path: str,
|
|
177
|
+
*,
|
|
178
|
+
params: Any,
|
|
179
|
+
content: Any,
|
|
180
|
+
form_data: Any,
|
|
181
|
+
files: Any,
|
|
182
|
+
headers: Mapping[str, str] | None,
|
|
183
|
+
idempotent: bool,
|
|
184
|
+
timeout: Any,
|
|
185
|
+
follow_redirects: bool | None,
|
|
186
|
+
attempt: int,
|
|
187
|
+
attempts: int,
|
|
188
|
+
) -> httpx.Response | None:
|
|
189
|
+
try:
|
|
190
|
+
response = self._request_once(
|
|
191
|
+
method,
|
|
192
|
+
path,
|
|
193
|
+
params=params,
|
|
194
|
+
content=content,
|
|
195
|
+
form_data=form_data,
|
|
196
|
+
files=files,
|
|
197
|
+
headers=headers,
|
|
198
|
+
timeout=timeout,
|
|
199
|
+
follow_redirects=follow_redirects,
|
|
200
|
+
)
|
|
201
|
+
except RuntimeError as exc:
|
|
202
|
+
raise TransportError(str(exc)) from exc
|
|
203
|
+
except (RequestTimeoutError, TransportError):
|
|
204
|
+
if not self._can_retry(idempotent, attempt, attempts):
|
|
205
|
+
raise
|
|
206
|
+
self._sleep(attempt)
|
|
207
|
+
return None
|
|
208
|
+
return self._handle_response(
|
|
209
|
+
response,
|
|
210
|
+
idempotent=idempotent,
|
|
211
|
+
attempt=attempt,
|
|
212
|
+
attempts=attempts,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _request_loop(
|
|
216
|
+
self,
|
|
217
|
+
method: str,
|
|
218
|
+
path: str,
|
|
219
|
+
*,
|
|
220
|
+
params: Any,
|
|
221
|
+
content: Any,
|
|
222
|
+
form_data: Any,
|
|
223
|
+
files: Any,
|
|
224
|
+
headers: Mapping[str, str] | None,
|
|
225
|
+
idempotent: bool,
|
|
226
|
+
timeout: Any,
|
|
227
|
+
follow_redirects: bool | None,
|
|
228
|
+
) -> httpx.Response:
|
|
229
|
+
attempts = self._config.max_retries + 1
|
|
230
|
+
for attempt in range(attempts):
|
|
231
|
+
response = self._request_attempt(
|
|
232
|
+
method,
|
|
233
|
+
path,
|
|
234
|
+
params=params,
|
|
235
|
+
content=content,
|
|
236
|
+
form_data=form_data,
|
|
237
|
+
files=files,
|
|
238
|
+
headers=headers,
|
|
239
|
+
idempotent=idempotent,
|
|
240
|
+
timeout=timeout,
|
|
241
|
+
follow_redirects=follow_redirects,
|
|
242
|
+
attempt=attempt,
|
|
243
|
+
attempts=attempts,
|
|
244
|
+
)
|
|
245
|
+
if response is not None:
|
|
246
|
+
return response
|
|
247
|
+
raise TransportError("transport failed") # pragma: no cover
|
|
248
|
+
|
|
249
|
+
def request(
|
|
250
|
+
self,
|
|
251
|
+
method: str,
|
|
252
|
+
path: str,
|
|
253
|
+
*,
|
|
254
|
+
params: Any = None,
|
|
255
|
+
data: Any = None,
|
|
256
|
+
files: Any = None,
|
|
257
|
+
headers: Mapping[str, str] | None = None,
|
|
258
|
+
idempotent: bool = False,
|
|
259
|
+
timeout: Any = _UNSET,
|
|
260
|
+
follow_redirects: bool | None = None,
|
|
261
|
+
) -> httpx.Response:
|
|
262
|
+
method = method.upper()
|
|
263
|
+
clean_path = _normalize_path(path)
|
|
264
|
+
request_timeout = self._config.timeout if timeout is _UNSET else timeout
|
|
265
|
+
content = data if isinstance(data, (str, bytes)) else None
|
|
266
|
+
form_data = None if content is not None else data
|
|
267
|
+
return self._request_loop(
|
|
268
|
+
method,
|
|
269
|
+
clean_path,
|
|
270
|
+
params=params,
|
|
271
|
+
content=content,
|
|
272
|
+
form_data=form_data,
|
|
273
|
+
files=files,
|
|
274
|
+
headers=headers,
|
|
275
|
+
idempotent=idempotent,
|
|
276
|
+
timeout=request_timeout,
|
|
277
|
+
follow_redirects=follow_redirects,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _raise_stream_timeout(
|
|
282
|
+
exc: httpx.TimeoutException,
|
|
283
|
+
started: bool,
|
|
284
|
+
attempt: int,
|
|
285
|
+
attempts: int,
|
|
286
|
+
) -> None:
|
|
287
|
+
if started or attempt + 1 >= attempts:
|
|
288
|
+
raise RequestTimeoutError(str(exc)) from exc
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _raise_stream_request_error(
|
|
292
|
+
exc: httpx.RequestError | httpx.InvalidURL,
|
|
293
|
+
started: bool,
|
|
294
|
+
attempt: int,
|
|
295
|
+
attempts: int,
|
|
296
|
+
) -> None:
|
|
297
|
+
if started or attempt + 1 >= attempts:
|
|
298
|
+
raise TransportError(str(exc)) from exc
|
|
299
|
+
|
|
300
|
+
def _stream_setup(
|
|
301
|
+
self,
|
|
302
|
+
method: str,
|
|
303
|
+
path: str,
|
|
304
|
+
data: Any,
|
|
305
|
+
timeout: Any,
|
|
306
|
+
idempotent: bool,
|
|
307
|
+
) -> tuple[str, str, Any, Any, Any, int]:
|
|
308
|
+
content = data if isinstance(data, (str, bytes)) else None
|
|
309
|
+
return (
|
|
310
|
+
method.upper(),
|
|
311
|
+
_normalize_path(path),
|
|
312
|
+
self._config.timeout if timeout is _UNSET else timeout,
|
|
313
|
+
content,
|
|
314
|
+
None if content is not None else data,
|
|
315
|
+
self._config.max_retries + 1 if idempotent else 1,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _handle_stream_exception(
|
|
320
|
+
exc: Exception,
|
|
321
|
+
started: bool,
|
|
322
|
+
attempt: int,
|
|
323
|
+
attempts: int,
|
|
324
|
+
) -> None:
|
|
325
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
326
|
+
HttpTransport._raise_stream_timeout(exc, started, attempt, attempts)
|
|
327
|
+
elif isinstance(exc, (httpx.RequestError, httpx.InvalidURL)):
|
|
328
|
+
HttpTransport._raise_stream_request_error(exc, started, attempt, attempts)
|
|
329
|
+
else:
|
|
330
|
+
raise TransportError(str(exc)) from exc
|
|
331
|
+
|
|
332
|
+
def _stream_loop(
|
|
333
|
+
self,
|
|
334
|
+
method: str,
|
|
335
|
+
path: str,
|
|
336
|
+
*,
|
|
337
|
+
params: Any,
|
|
338
|
+
content: Any,
|
|
339
|
+
form_data: Any,
|
|
340
|
+
headers: Mapping[str, str] | None,
|
|
341
|
+
timeout: Any,
|
|
342
|
+
attempts: int,
|
|
343
|
+
) -> Iterator[httpx.Response]:
|
|
344
|
+
started = False
|
|
345
|
+
for attempt in range(attempts):
|
|
346
|
+
try:
|
|
347
|
+
with self._client.stream(
|
|
348
|
+
method,
|
|
349
|
+
path,
|
|
350
|
+
params=params,
|
|
351
|
+
content=content,
|
|
352
|
+
data=form_data,
|
|
353
|
+
headers={**dict(self._config.headers), **dict(headers or {})},
|
|
354
|
+
timeout=timeout,
|
|
355
|
+
) as response:
|
|
356
|
+
started = True
|
|
357
|
+
yield response
|
|
358
|
+
return
|
|
359
|
+
except (
|
|
360
|
+
httpx.TimeoutException,
|
|
361
|
+
httpx.RequestError,
|
|
362
|
+
httpx.InvalidURL,
|
|
363
|
+
RuntimeError,
|
|
364
|
+
) as exc:
|
|
365
|
+
self._handle_stream_exception(exc, started, attempt, attempts)
|
|
366
|
+
self._sleep(attempt)
|
|
367
|
+
raise TransportError("stream failed") # pragma: no cover - loop always returns or raises
|
|
368
|
+
|
|
369
|
+
@contextlib.contextmanager
|
|
370
|
+
def stream(
|
|
371
|
+
self,
|
|
372
|
+
method: str,
|
|
373
|
+
path: str,
|
|
374
|
+
*,
|
|
375
|
+
params: Any = None,
|
|
376
|
+
data: Any = None,
|
|
377
|
+
headers: Mapping[str, str] | None = None,
|
|
378
|
+
timeout: Any = _UNSET,
|
|
379
|
+
idempotent: bool = True,
|
|
380
|
+
) -> Iterator[httpx.Response]:
|
|
381
|
+
method, clean_path, request_timeout, content, form_data, attempts = self._stream_setup(
|
|
382
|
+
method, path, data, timeout, idempotent
|
|
383
|
+
)
|
|
384
|
+
yield from self._stream_loop(
|
|
385
|
+
method,
|
|
386
|
+
clean_path,
|
|
387
|
+
params=params,
|
|
388
|
+
content=content,
|
|
389
|
+
form_data=form_data,
|
|
390
|
+
headers=headers,
|
|
391
|
+
timeout=request_timeout,
|
|
392
|
+
attempts=attempts,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def get_cookie(self, name: str) -> str | None:
|
|
396
|
+
return self._client.cookies.get(name)
|
|
397
|
+
|
|
398
|
+
def clear_cookies(self, names: Iterable[str] | None = None) -> None:
|
|
399
|
+
if names is None:
|
|
400
|
+
self._client.cookies.clear()
|
|
401
|
+
return
|
|
402
|
+
for name in names:
|
|
403
|
+
self._client.cookies.delete(name)
|
|
404
|
+
|
|
405
|
+
def close(self) -> None:
|
|
406
|
+
if self._owns_client:
|
|
407
|
+
self._client.close()
|
|
408
|
+
|
|
409
|
+
def _sleep(self, attempt: int, retry_after: str | None = None) -> None:
|
|
410
|
+
if retry_after:
|
|
411
|
+
try:
|
|
412
|
+
delay = min(float(retry_after), 30.0)
|
|
413
|
+
except ValueError:
|
|
414
|
+
delay = self._config.retry_backoff * (2**attempt)
|
|
415
|
+
else:
|
|
416
|
+
delay = self._config.retry_backoff * (2**attempt)
|
|
417
|
+
if delay > 0:
|
|
418
|
+
time.sleep(delay)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _validate_path_origin(clean: str) -> None:
|
|
422
|
+
if not clean:
|
|
423
|
+
raise ConfigurationError("empty request path")
|
|
424
|
+
if "://" in clean or clean.startswith("//"):
|
|
425
|
+
raise ConfigurationError("absolute URLs are not allowed")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _validate_path_segments(clean: str) -> None:
|
|
429
|
+
parts = clean.split("/")
|
|
430
|
+
if any(part in {"..", "."} for part in parts):
|
|
431
|
+
raise ConfigurationError("path traversal is not allowed")
|
|
432
|
+
if "?" in clean or "#" in clean:
|
|
433
|
+
raise ConfigurationError("put query parameters in params=")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _normalize_path(path: str) -> str:
|
|
437
|
+
clean = path.strip()
|
|
438
|
+
_validate_path_origin(clean)
|
|
439
|
+
_validate_path_segments(clean)
|
|
440
|
+
return clean.lstrip("/")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def build_form(
|
|
444
|
+
params: Mapping[str, Any] | Iterable[tuple[str, Any]] | None = None, **kwargs: Any
|
|
445
|
+
) -> str:
|
|
446
|
+
return encode_params(params, **kwargs)
|
ugos/catalog.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""API action catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .exceptions import CatalogError, UnknownActionError, UnknownControllerError
|
|
12
|
+
|
|
13
|
+
_CONTRACT_METHODS = frozenset({"GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"})
|
|
14
|
+
_CONTRACT_ID_LOCATIONS = frozenset({"param", "data", "data_list"})
|
|
15
|
+
_CONTRACT_RESPONSE_KINDS = frozenset({"auto", "json", "text", "bytes", "list", "object", "scalar"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclasses.dataclass(frozen=True)
|
|
19
|
+
class ActionContract:
|
|
20
|
+
"""Request/response contract for one controller action."""
|
|
21
|
+
|
|
22
|
+
method: str = "POST"
|
|
23
|
+
id_param: str = "id"
|
|
24
|
+
id_location: str = "data"
|
|
25
|
+
response_kind: str = "auto"
|
|
26
|
+
json_fields: tuple[str, ...] = ()
|
|
27
|
+
idempotent: bool | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _catalog_version(data: Mapping[str, Any]) -> str | None:
|
|
31
|
+
product = data.get("product", {})
|
|
32
|
+
if not isinstance(product, Mapping):
|
|
33
|
+
return None
|
|
34
|
+
return product.get("version")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_controllers(raw: Mapping[str, Any]) -> dict[str, frozenset[str]]:
|
|
38
|
+
return {
|
|
39
|
+
str(name): frozenset(str(action) for action in actions if str(action) != "_routes")
|
|
40
|
+
for name, actions in raw.items()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Catalog:
|
|
45
|
+
"""Read-only controller/action catalog."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
data: Mapping[str, Any],
|
|
50
|
+
*,
|
|
51
|
+
contracts: Mapping[str, Any] | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._data = dict(data)
|
|
54
|
+
self.version = _catalog_version(self._data)
|
|
55
|
+
self._controllers = _parse_controllers(self._data.get("controllers", {}))
|
|
56
|
+
self._contracts = _parse_contracts(contracts or {})
|
|
57
|
+
self._check_contracts()
|
|
58
|
+
|
|
59
|
+
def _check_contracts(self) -> None:
|
|
60
|
+
unknown = sorted(
|
|
61
|
+
f"{controller}/{action}"
|
|
62
|
+
for controller, action in self._contracts
|
|
63
|
+
if not self.has_action(controller, action)
|
|
64
|
+
)
|
|
65
|
+
if unknown:
|
|
66
|
+
raise CatalogError(f"contracts reference unknown actions: {', '.join(unknown)}")
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def controllers(self) -> frozenset[str]:
|
|
70
|
+
return frozenset(self._controllers)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def action_count(self) -> int:
|
|
74
|
+
return sum(len(actions) for actions in self._controllers.values())
|
|
75
|
+
|
|
76
|
+
def has_controller(self, controller: str) -> bool:
|
|
77
|
+
return controller in self._controllers
|
|
78
|
+
|
|
79
|
+
def has_action(self, controller: str, action: str) -> bool:
|
|
80
|
+
return action in self._controllers.get(controller, frozenset())
|
|
81
|
+
|
|
82
|
+
def actions(self, controller: str) -> frozenset[str]:
|
|
83
|
+
if controller not in self._controllers:
|
|
84
|
+
raise UnknownControllerError(f"unknown controller: {controller}")
|
|
85
|
+
return self._controllers[controller]
|
|
86
|
+
|
|
87
|
+
def check(self, controller: str, action: str) -> None:
|
|
88
|
+
if controller not in self._controllers:
|
|
89
|
+
raise UnknownControllerError(f"unknown controller: {controller}")
|
|
90
|
+
if action not in self._controllers[controller]:
|
|
91
|
+
raise UnknownActionError(f"unknown action: {controller}/{action}")
|
|
92
|
+
|
|
93
|
+
def action_contract(self, controller: str, action: str) -> ActionContract | None:
|
|
94
|
+
return self._contracts.get((controller, action))
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def contract_count(self) -> int:
|
|
98
|
+
return len(self._contracts)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validate_contract_schema(data: Mapping[str, Any]) -> None:
|
|
102
|
+
if data.get("schema_version") not in (None, 1):
|
|
103
|
+
raise CatalogError(f"unsupported action contract schema: {data.get('schema_version')!r}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _contracts_mapping(data: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
107
|
+
raw = data.get("contracts", data)
|
|
108
|
+
if not isinstance(raw, Mapping):
|
|
109
|
+
raise CatalogError("action contracts must be a mapping")
|
|
110
|
+
return raw
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _validate_contract_fields(
|
|
114
|
+
controller: str,
|
|
115
|
+
action: str,
|
|
116
|
+
method: str,
|
|
117
|
+
id_location: str,
|
|
118
|
+
response_kind: str,
|
|
119
|
+
) -> None:
|
|
120
|
+
if method not in _CONTRACT_METHODS:
|
|
121
|
+
raise CatalogError(f"invalid method in contract {controller}/{action}: {method!r}")
|
|
122
|
+
if id_location not in _CONTRACT_ID_LOCATIONS:
|
|
123
|
+
raise CatalogError(
|
|
124
|
+
f"invalid id_location in contract {controller}/{action}: {id_location!r}"
|
|
125
|
+
)
|
|
126
|
+
if response_kind not in _CONTRACT_RESPONSE_KINDS:
|
|
127
|
+
raise CatalogError(
|
|
128
|
+
f"invalid response_kind in contract {controller}/{action}: {response_kind!r}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_contract(controller: str, action: str, spec: Mapping[str, Any]) -> ActionContract:
|
|
133
|
+
method = str(spec.get("method", "POST")).upper()
|
|
134
|
+
id_location = str(spec.get("id_location", "data"))
|
|
135
|
+
response_kind = str(spec.get("response_kind", "auto"))
|
|
136
|
+
_validate_contract_fields(controller, action, method, id_location, response_kind)
|
|
137
|
+
idempotent = spec.get("idempotent")
|
|
138
|
+
return ActionContract(
|
|
139
|
+
method=method,
|
|
140
|
+
id_param=str(spec.get("id_param", "id")),
|
|
141
|
+
id_location=id_location,
|
|
142
|
+
response_kind=response_kind,
|
|
143
|
+
json_fields=tuple(str(field) for field in spec.get("json_fields", ())),
|
|
144
|
+
idempotent=idempotent if isinstance(idempotent, bool) else None,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_controller_contracts(
|
|
149
|
+
controller: str,
|
|
150
|
+
actions: Mapping[str, Any],
|
|
151
|
+
) -> dict[tuple[str, str], ActionContract]:
|
|
152
|
+
result: dict[tuple[str, str], ActionContract] = {}
|
|
153
|
+
for action, spec in actions.items():
|
|
154
|
+
if not isinstance(spec, Mapping):
|
|
155
|
+
raise CatalogError(f"contract {controller}/{action} must be a mapping")
|
|
156
|
+
result[(str(controller), str(action))] = _parse_contract(str(controller), str(action), spec)
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_contracts(data: Mapping[str, Any]) -> dict[tuple[str, str], ActionContract]:
|
|
161
|
+
_validate_contract_schema(data)
|
|
162
|
+
raw = _contracts_mapping(data)
|
|
163
|
+
result: dict[tuple[str, str], ActionContract] = {}
|
|
164
|
+
for controller, actions in raw.items():
|
|
165
|
+
if controller == "schema_version":
|
|
166
|
+
continue
|
|
167
|
+
if not isinstance(actions, Mapping):
|
|
168
|
+
raise CatalogError(f"contracts for {controller!r} must be a mapping")
|
|
169
|
+
result.update(_parse_controller_contracts(str(controller), actions))
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def load_catalog() -> Catalog:
|
|
174
|
+
root = resources.files("ugos._data")
|
|
175
|
+
data_file = root.joinpath("catalog.json")
|
|
176
|
+
with data_file.open("r", encoding="utf-8") as handle:
|
|
177
|
+
data = json.load(handle)
|
|
178
|
+
contracts_file = root.joinpath("action_contracts.json")
|
|
179
|
+
try:
|
|
180
|
+
with contracts_file.open("r", encoding="utf-8") as handle:
|
|
181
|
+
contracts = json.load(handle)
|
|
182
|
+
except FileNotFoundError as exc:
|
|
183
|
+
raise CatalogError("action_contracts.json is missing from the package") from exc
|
|
184
|
+
return Catalog(data, contracts=contracts)
|