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/_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)