veltro-sdk 0.15.7__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.
veltro_sdk/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``veltro-sdk`` — the Python client for the Veltro security operations suite.
3
+
4
+ Generated from the products' own OpenAPI documents (CHAD, Warden) and
5
+ VectorFlow's REST v1 specification; see ``scripts/generate_sdk.py`` and
6
+ ``sdk/specs/*-operations.json`` in the ``veltrosecurity/veltro`` repository.
7
+
8
+ Quick start (suite deployment)::
9
+
10
+ from veltro_sdk import VeltroClient
11
+
12
+ suite = VeltroClient.login("https://soc.example.com", email, password,
13
+ vectorflow_key="vf_live_…")
14
+ data_sources = suite.chad.data_sources.list()
15
+ cases = suite.warden.cases.list(params={"limit": 25})
16
+ pipelines = suite.vectorflow.pipelines.list()
17
+
18
+ Quick start (one product, personal access token through the gateway)::
19
+
20
+ from veltro_sdk import BearerToken, ChadClient
21
+
22
+ chad = ChadClient("https://soc.example.com/chad", auth=BearerToken(pat))
23
+ chad.rules.list()
24
+
25
+ Stdlib only: no runtime dependencies.
26
+ """
27
+
28
+ from ._auth import Auth, BearerToken, NoAuth, SuiteSession
29
+ from ._http import (
30
+ Transport,
31
+ VeltroAPIError,
32
+ VeltroError,
33
+ VeltroResponseTooLarge,
34
+ VeltroTransportError,
35
+ )
36
+ from .client import (
37
+ SUITE_PREFIXES,
38
+ ChadClient,
39
+ MissingCredential,
40
+ VectorFlowClient,
41
+ VeltroClient,
42
+ WardenClient,
43
+ )
44
+
45
+ __version__ = "0.15.7"
46
+
47
+ __all__ = [
48
+ "__version__",
49
+ # clients
50
+ "VeltroClient",
51
+ "ChadClient",
52
+ "WardenClient",
53
+ "VectorFlowClient",
54
+ "SUITE_PREFIXES",
55
+ # credentials
56
+ "Auth",
57
+ "NoAuth",
58
+ "BearerToken",
59
+ "SuiteSession",
60
+ # transport and errors
61
+ "Transport",
62
+ "VeltroError",
63
+ "VeltroAPIError",
64
+ "VeltroTransportError",
65
+ "VeltroResponseTooLarge",
66
+ "MissingCredential",
67
+ ]
veltro_sdk/_auth.py ADDED
@@ -0,0 +1,263 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Credentials the Veltro suite actually accepts, and nothing else.
3
+
4
+ Three credential shapes exist across the suite, and they are not
5
+ interchangeable — an SDK that guessed would fail closed in confusing ways:
6
+
7
+ :class:`BearerToken`
8
+ ``Authorization: Bearer …``. A Veltro personal access token (``vmcp_…``)
9
+ for any product through the suite gateway (``/chad/api/*``,
10
+ ``/warden/api/*``, ``/vectorflow/api/*``), where identity turns it into
11
+ the request assertion the product verifies; VectorFlow's service-account
12
+ key (``vf_live_…``, environment-scoped, for ``/api/v1``); CHAD's SCIM
13
+ deployment token; Warden's session token.
14
+ :class:`SuiteSession`
15
+ A suite deployment's own session, minted at the identity origin with
16
+ ``GET /api/auth/csrf`` + ``POST /api/auth/login`` and carried as the
17
+ session cookie. The gateway strips that cookie toward a product and
18
+ exchanges it for a short-lived request assertion, which is why one suite
19
+ login reaches all three products and why no product-local credential is
20
+ needed in suite mode.
21
+ :class:`NoAuth`
22
+ Explicit anonymity, for the unauthenticated probes.
23
+
24
+ Every implementation masks its material in ``repr`` and never writes it to a
25
+ log; :class:`SuiteSession` additionally refuses to send a password over plain
26
+ HTTP unless the caller opts in for a loopback deployment.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import http.cookiejar
32
+ import json as _json
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+ from typing import MutableMapping
37
+
38
+ from ._http import (
39
+ USER_AGENT,
40
+ VeltroAPIError,
41
+ VeltroTransportError,
42
+ _NoRedirect,
43
+ _read_capped,
44
+ )
45
+
46
+ __all__ = [
47
+ "Auth",
48
+ "NoAuth",
49
+ "BearerToken",
50
+ "SuiteSession",
51
+ ]
52
+
53
+ SESSION_COOKIE_PREFIX = "authjs.session-token"
54
+ LOGIN_MAX_RESPONSE_BYTES = 1024 * 1024
55
+
56
+
57
+ class Auth:
58
+ """Applies one credential to an outgoing request."""
59
+
60
+ def apply(
61
+ self, method: str, url: str, headers: MutableMapping[str, str]
62
+ ) -> None: # pragma: no cover - interface
63
+ raise NotImplementedError
64
+
65
+
66
+ class NoAuth(Auth):
67
+ """Send no credential."""
68
+
69
+ def apply(
70
+ self, method: str, url: str, headers: MutableMapping[str, str]
71
+ ) -> None:
72
+ return None
73
+
74
+ def __repr__(self) -> str:
75
+ return "NoAuth()"
76
+
77
+
78
+ class BearerToken(Auth):
79
+ """``Authorization: Bearer <token>``."""
80
+
81
+ def __init__(self, token: str) -> None:
82
+ if not token or not token.strip():
83
+ raise ValueError("token is required")
84
+ self._token = token.strip()
85
+
86
+ def apply(
87
+ self, method: str, url: str, headers: MutableMapping[str, str]
88
+ ) -> None:
89
+ headers["Authorization"] = f"Bearer {self._token}"
90
+
91
+ def __repr__(self) -> str:
92
+ return f"BearerToken({_mask(self._token)})"
93
+
94
+
95
+ class SuiteSession(Auth):
96
+ """A suite session held as the identity-minted cookie.
97
+
98
+ ``identity_url`` is the suite origin (the identity authority owns the
99
+ origin root). The login is performed once, lazily, and re-performed if a
100
+ product answers 401 and :meth:`refresh` is called by the caller.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ identity_url: str,
106
+ email: str,
107
+ password: str,
108
+ *,
109
+ timeout: float = 30.0,
110
+ allow_insecure_transport: bool = False,
111
+ ) -> None:
112
+ if not identity_url:
113
+ raise ValueError("identity_url is required")
114
+ parsed = urllib.parse.urlsplit(identity_url)
115
+ if parsed.scheme not in ("http", "https"):
116
+ raise ValueError("identity_url must be http(s)")
117
+ if parsed.scheme == "http" and not allow_insecure_transport:
118
+ raise ValueError(
119
+ "refusing to send suite credentials over plain HTTP; pass "
120
+ "allow_insecure_transport=True only for a loopback deployment"
121
+ )
122
+ self.identity_url = identity_url.rstrip("/")
123
+ self._email = email
124
+ self._password = password
125
+ self._timeout = timeout
126
+ self._jar = http.cookiejar.CookieJar()
127
+ self._opener = urllib.request.build_opener(
128
+ _NoRedirect(), urllib.request.HTTPCookieProcessor(self._jar)
129
+ )
130
+ self._logged_in = False
131
+
132
+ def __repr__(self) -> str:
133
+ return (
134
+ f"SuiteSession(identity_url={self.identity_url!r}, "
135
+ f"email={self._email!r}, password=***, "
136
+ f"logged_in={self._logged_in})"
137
+ )
138
+
139
+ # -- credential application -------------------------------------------
140
+ def apply(
141
+ self, method: str, url: str, headers: MutableMapping[str, str]
142
+ ) -> None:
143
+ if not self._logged_in:
144
+ self.login()
145
+ cookie = self._cookie_header(url)
146
+ if cookie:
147
+ headers["Cookie"] = cookie
148
+
149
+ def _cookie_header(self, url: str) -> str:
150
+ request = urllib.request.Request(url)
151
+ self._jar.add_cookie_header(request)
152
+ return request.get_header("Cookie", "")
153
+
154
+ # -- login -------------------------------------------------------------
155
+ def login(self) -> None:
156
+ """Mint a suite session. Raises :class:`VeltroAPIError` on refusal."""
157
+ csrf = self._csrf()
158
+ body = _json.dumps({"email": self._email, "password": self._password})
159
+ payload = self._call(
160
+ "POST",
161
+ "/api/auth/login",
162
+ body.encode("utf-8"),
163
+ {
164
+ "Content-Type": "application/json",
165
+ "x-csrf-token": csrf,
166
+ },
167
+ )
168
+ if not self._session_cookie_present():
169
+ raise VeltroAPIError(
170
+ 200,
171
+ "POST",
172
+ f"{self.identity_url}/api/auth/login",
173
+ code="suite_login_no_session_cookie",
174
+ message=(
175
+ "the identity authority accepted the login but set no "
176
+ "session cookie"
177
+ ),
178
+ body=payload,
179
+ )
180
+ self._logged_in = True
181
+
182
+ def refresh(self) -> None:
183
+ """Drop the current session and log in again."""
184
+ self._jar.clear()
185
+ self._logged_in = False
186
+ self.login()
187
+
188
+ def _session_cookie_present(self) -> bool:
189
+ return any(
190
+ cookie.name.startswith(SESSION_COOKIE_PREFIX) for cookie in self._jar
191
+ )
192
+
193
+ def _csrf(self) -> str:
194
+ payload = self._call("GET", "/api/auth/csrf", None, {})
195
+ token = ""
196
+ if isinstance(payload, dict):
197
+ token = str(payload.get("csrfToken") or payload.get("csrf_token") or "")
198
+ if not token:
199
+ raise VeltroAPIError(
200
+ 200,
201
+ "GET",
202
+ f"{self.identity_url}/api/auth/csrf",
203
+ code="suite_login_no_csrf_token",
204
+ message="the identity authority returned no CSRF token",
205
+ body=payload,
206
+ )
207
+ return token
208
+
209
+ def _call(
210
+ self,
211
+ method: str,
212
+ path: str,
213
+ body: bytes | None,
214
+ headers: dict[str, str],
215
+ ):
216
+ url = f"{self.identity_url}{path}"
217
+ request = urllib.request.Request(
218
+ url,
219
+ data=body,
220
+ method=method,
221
+ headers={"Accept": "application/json", "User-Agent": USER_AGENT, **headers},
222
+ )
223
+ try:
224
+ with self._opener.open(request, timeout=self._timeout) as response:
225
+ raw = _read_capped(
226
+ response, LOGIN_MAX_RESPONSE_BYTES, method, url
227
+ )
228
+ except urllib.error.HTTPError as exc:
229
+ try:
230
+ detail = _read_capped(
231
+ exc, LOGIN_MAX_RESPONSE_BYTES, method, url
232
+ )
233
+ message = ""
234
+ try:
235
+ decoded = _json.loads(detail.decode("utf-8"))
236
+ if isinstance(decoded, dict):
237
+ message = str(decoded.get("error") or decoded.get("detail") or "")
238
+ except (UnicodeDecodeError, ValueError):
239
+ message = ""
240
+ raise VeltroAPIError(
241
+ exc.code,
242
+ method,
243
+ url,
244
+ code="suite_login_refused",
245
+ message=message,
246
+ ) from None
247
+ finally:
248
+ exc.close()
249
+ except urllib.error.URLError as exc:
250
+ raise VeltroTransportError(f"{method} {url}: {exc.reason}") from None
251
+ if not raw:
252
+ return None
253
+ try:
254
+ return _json.loads(raw.decode("utf-8"))
255
+ except (UnicodeDecodeError, ValueError):
256
+ return None
257
+
258
+
259
+ def _mask(secret: str) -> str:
260
+ """Show enough to identify a credential, never enough to use it."""
261
+ if len(secret) <= 8:
262
+ return "'***'"
263
+ return f"'{secret[:4]}…***' (len {len(secret)})"
veltro_sdk/_http.py ADDED
@@ -0,0 +1,348 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """HTTP transport for the Veltro SDK: stdlib only, fail-closed, capped.
3
+
4
+ Design constraints, each of them load-bearing:
5
+
6
+ * **No dependencies.** An SDK that pulls a request stack is an SDK an operator
7
+ cannot drop into a hardened jump host. ``urllib.request`` is enough.
8
+ * **Never log a credential.** Authorisation material lives behind
9
+ :class:`veltro_sdk.Auth` implementations whose ``repr`` masks the value, and
10
+ the transport never echoes request headers into an exception message.
11
+ * **Cap the response.** A client that reads an unbounded body is a client one
12
+ hostile or broken deployment can OOM. ``max_response_bytes`` is enforced
13
+ while streaming, not after.
14
+ * **Typed refusals.** Every non-2xx becomes a :class:`VeltroAPIError` carrying
15
+ the status, the product's own error code when it sends one, and the request
16
+ line — so a caller can branch on it instead of matching prose.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json as _json
22
+ import time
23
+ import urllib.error
24
+ import urllib.parse
25
+ import urllib.request
26
+ from typing import Any, Iterable, Mapping
27
+
28
+ __all__ = [
29
+ "VeltroError",
30
+ "VeltroAPIError",
31
+ "VeltroTransportError",
32
+ "VeltroResponseTooLarge",
33
+ "Transport",
34
+ "Resource",
35
+ "quote_segment",
36
+ ]
37
+
38
+ DEFAULT_TIMEOUT = 30.0
39
+ DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
40
+ DEFAULT_RETRIES = 2
41
+ RETRY_STATUSES = frozenset({429, 502, 503, 504})
42
+ RETRY_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
43
+ USER_AGENT = "veltro-sdk/0.15.7 (+https://github.com/veltrosecurity/veltro)"
44
+
45
+
46
+ class VeltroError(Exception):
47
+ """Base class for every SDK failure."""
48
+
49
+
50
+ class VeltroTransportError(VeltroError):
51
+ """The request never produced an HTTP response (DNS, TLS, connect, timeout)."""
52
+
53
+
54
+ class VeltroResponseTooLarge(VeltroError):
55
+ """The response body exceeded ``max_response_bytes``."""
56
+
57
+
58
+ class VeltroAPIError(VeltroError):
59
+ """A product answered with a non-2xx status.
60
+
61
+ ``code`` is the product's own machine-readable refusal name when it sends
62
+ one (CHAD and Warden answer ``{"detail": ...}``; VectorFlow answers
63
+ ``{"error": {"code": ...}}``), otherwise ``None``.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ status: int,
69
+ method: str,
70
+ url: str,
71
+ *,
72
+ code: str | None = None,
73
+ message: str = "",
74
+ body: Any = None,
75
+ ) -> None:
76
+ self.status = status
77
+ self.method = method
78
+ self.url = url
79
+ self.code = code
80
+ self.message = message
81
+ self.body = body
82
+ detail = f" {code}" if code else ""
83
+ text = f": {message}" if message else ""
84
+ super().__init__(f"{method} {url} -> HTTP {status}{detail}{text}")
85
+
86
+
87
+ def quote_segment(value: object) -> str:
88
+ """Percent-encode one path segment.
89
+
90
+ Path parameters are interpolated into a URL, so an unencoded ``/`` or ``?``
91
+ in an identifier would silently retarget the request at another endpoint.
92
+ """
93
+ return urllib.parse.quote(str(value), safe="")
94
+
95
+
96
+ def _decode(body: bytes, content_type: str) -> Any:
97
+ if not body:
98
+ return None
99
+ if "json" in content_type:
100
+ try:
101
+ return _json.loads(body.decode("utf-8"))
102
+ except (UnicodeDecodeError, ValueError):
103
+ return body
104
+ if content_type.startswith("text/"):
105
+ try:
106
+ return body.decode("utf-8")
107
+ except UnicodeDecodeError:
108
+ return body
109
+ return body
110
+
111
+
112
+ def _error_fields(payload: Any) -> tuple[str | None, str]:
113
+ """Pull (code, message) out of the three refusal shapes the suite emits."""
114
+ if isinstance(payload, Mapping):
115
+ error = payload.get("error")
116
+ if isinstance(error, Mapping):
117
+ code = error.get("code")
118
+ message = error.get("message") or ""
119
+ return (str(code) if code else None, str(message))
120
+ detail = payload.get("detail")
121
+ if isinstance(detail, Mapping):
122
+ code = detail.get("code") or detail.get("error")
123
+ message = detail.get("message") or detail.get("detail") or ""
124
+ return (str(code) if code else None, str(message))
125
+ if isinstance(detail, str):
126
+ return (None, detail)
127
+ if isinstance(detail, list):
128
+ return (None, _json.dumps(detail)[:500])
129
+ if isinstance(error, str):
130
+ return (None, error)
131
+ message = payload.get("message")
132
+ if isinstance(message, str):
133
+ return (None, message)
134
+ if isinstance(payload, str):
135
+ return (None, payload[:500])
136
+ return (None, "")
137
+
138
+
139
+ def _read_capped(response, limit: int, method: str, url: str) -> bytes:
140
+ chunks: list[bytes] = []
141
+ read = 0
142
+ while True:
143
+ chunk = response.read(min(65536, limit - read + 1))
144
+ if not chunk:
145
+ break
146
+ read += len(chunk)
147
+ if read > limit:
148
+ raise VeltroResponseTooLarge(
149
+ f"{method} {url} -> response exceeded max_response_bytes={limit}"
150
+ )
151
+ chunks.append(chunk)
152
+ return b"".join(chunks)
153
+
154
+
155
+ def _can_retry(method: str, headers: Mapping[str, str]) -> bool:
156
+ if method.upper() in RETRY_METHODS:
157
+ return True
158
+ return any(
159
+ key.lower() == "idempotency-key" and bool(value.strip())
160
+ for key, value in headers.items()
161
+ )
162
+
163
+
164
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
165
+ """Surface redirects instead of forwarding credentials to another origin."""
166
+
167
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
168
+ return None
169
+
170
+
171
+ class Transport:
172
+ """One product's HTTP surface: base URL, auth, retries, and caps.
173
+
174
+ ``base_url`` is the product's API origin *including* any suite gateway
175
+ prefix — ``https://soc.example.com/chad`` in a suite deployment,
176
+ ``https://chad.internal:8080`` standalone. Operation paths from the
177
+ generated bindings are appended to it verbatim.
178
+ """
179
+
180
+ def __init__(
181
+ self,
182
+ base_url: str,
183
+ *,
184
+ auth: "Auth | None" = None,
185
+ timeout: float = DEFAULT_TIMEOUT,
186
+ retries: int = DEFAULT_RETRIES,
187
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
188
+ headers: Mapping[str, str] | None = None,
189
+ opener: urllib.request.OpenerDirector | None = None,
190
+ sleep=time.sleep,
191
+ ) -> None:
192
+ if not base_url:
193
+ raise ValueError("base_url is required")
194
+ self.base_url = base_url.rstrip("/")
195
+ self.auth = auth
196
+ self.timeout = timeout
197
+ self.retries = max(0, int(retries))
198
+ self.max_response_bytes = int(max_response_bytes)
199
+ self.headers = dict(headers or {})
200
+ # No global opener: a process-wide opener would let one client's
201
+ # cookie policy leak into another's.
202
+ # API redirects are surfaced as typed 3xx responses. Automatically
203
+ # following one risks forwarding Authorization or API-key headers to
204
+ # an attacker-controlled Location.
205
+ self._opener = opener or urllib.request.build_opener(_NoRedirect())
206
+ self._sleep = sleep
207
+
208
+ def __repr__(self) -> str: # pragma: no cover - trivial
209
+ return f"Transport(base_url={self.base_url!r}, auth={self.auth!r})"
210
+
211
+ def url_for(self, path: str, params: Mapping[str, Any] | None = None) -> str:
212
+ if not path.startswith("/"):
213
+ path = "/" + path
214
+ url = f"{self.base_url}{path}"
215
+ query = _encode_params(params)
216
+ return f"{url}?{query}" if query else url
217
+
218
+ def request(
219
+ self,
220
+ method: str,
221
+ path: str,
222
+ *,
223
+ params: Mapping[str, Any] | None = None,
224
+ json: Any = None,
225
+ data: bytes | None = None,
226
+ headers: Mapping[str, str] | None = None,
227
+ timeout: float | None = None,
228
+ raw: bool = False,
229
+ ) -> Any:
230
+ """Issue one request and return the decoded body (or the raw bytes)."""
231
+ if json is not None and data is not None:
232
+ raise ValueError("pass either json= or data=, not both")
233
+
234
+ url = self.url_for(path, params)
235
+ body = data
236
+ request_headers = {
237
+ "Accept": "application/json",
238
+ "User-Agent": USER_AGENT,
239
+ **self.headers,
240
+ }
241
+ if json is not None:
242
+ body = _json.dumps(json).encode("utf-8")
243
+ request_headers["Content-Type"] = "application/json"
244
+ if headers:
245
+ request_headers.update(headers)
246
+ if self.auth is not None:
247
+ self.auth.apply(method, url, request_headers)
248
+
249
+ attempts = self.retries + 1
250
+ last_error: VeltroError | None = None
251
+ for attempt in range(attempts):
252
+ request = urllib.request.Request(
253
+ url, data=body, method=method.upper(), headers=request_headers
254
+ )
255
+ try:
256
+ with self._opener.open(
257
+ request, timeout=self.timeout if timeout is None else timeout
258
+ ) as response:
259
+ payload = _read_capped(
260
+ response, self.max_response_bytes, method.upper(), url
261
+ )
262
+ if raw:
263
+ return payload
264
+ return _decode(
265
+ payload, response.headers.get("Content-Type", "") or ""
266
+ )
267
+ except urllib.error.HTTPError as exc: # noqa: PERF203 - retry loop
268
+ try:
269
+ payload = _read_capped(
270
+ exc, self.max_response_bytes, method.upper(), url
271
+ )
272
+ finally:
273
+ exc.close()
274
+ decoded = _decode(payload, exc.headers.get("Content-Type", "") or "")
275
+ code, message = _error_fields(decoded)
276
+ error = VeltroAPIError(
277
+ exc.code,
278
+ method.upper(),
279
+ url,
280
+ code=code,
281
+ message=message,
282
+ body=decoded,
283
+ )
284
+ if (
285
+ exc.code in RETRY_STATUSES
286
+ and attempt < attempts - 1
287
+ and _can_retry(method, request_headers)
288
+ ):
289
+ last_error = error
290
+ self._sleep(_backoff(attempt, exc.headers.get("Retry-After")))
291
+ continue
292
+ raise error from None
293
+ except VeltroResponseTooLarge:
294
+ raise
295
+ except urllib.error.URLError as exc:
296
+ error = VeltroTransportError(f"{method.upper()} {url}: {exc.reason}")
297
+ if attempt < attempts - 1 and _can_retry(method, request_headers):
298
+ last_error = error
299
+ self._sleep(_backoff(attempt, None))
300
+ continue
301
+ raise error from None
302
+ assert last_error is not None # unreachable: the loop returns or raises
303
+ raise last_error
304
+
305
+
306
+ def _backoff(attempt: int, retry_after: str | None) -> float:
307
+ if retry_after:
308
+ try:
309
+ return min(30.0, max(0.0, float(retry_after)))
310
+ except ValueError:
311
+ pass
312
+ return min(8.0, 0.25 * (2**attempt))
313
+
314
+
315
+ def _encode_params(params: Mapping[str, Any] | None) -> str:
316
+ if not params:
317
+ return ""
318
+ pairs: list[tuple[str, str]] = []
319
+ for key, value in params.items():
320
+ if value is None:
321
+ continue
322
+ if isinstance(value, bool):
323
+ pairs.append((key, "true" if value else "false"))
324
+ elif isinstance(value, (list, tuple, set, frozenset)):
325
+ pairs.extend((key, str(item)) for item in _sorted(value))
326
+ else:
327
+ pairs.append((key, str(value)))
328
+ return urllib.parse.urlencode(pairs)
329
+
330
+
331
+ def _sorted(value: Iterable[Any]) -> list[Any]:
332
+ items = list(value)
333
+ if isinstance(value, (set, frozenset)):
334
+ return sorted(items, key=str)
335
+ return items
336
+
337
+
338
+ class Resource:
339
+ """Base class for the generated resource bindings."""
340
+
341
+ def __init__(self, transport: Transport) -> None:
342
+ self._transport = transport
343
+
344
+ def __repr__(self) -> str: # pragma: no cover - trivial
345
+ return f"{type(self).__name__}(base_url={self._transport.base_url!r})"
346
+
347
+ def _request(self, method: str, path: str, **options: Any) -> Any:
348
+ return self._transport.request(method, path, **options)