konklav-control-plane 1.4.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Albin Skott
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: konklav-control-plane
3
+ Version: 1.4.0
4
+ Summary: Manage Konklav from Python: tenants, subjects, credentials, roles and the rest of the control plane
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Topic :: Security
9
+ Classifier: Typing :: Typed
10
+ Requires-Dist: httpx>=0.28
11
+ Requires-Python: >=3.12
12
+ Project-URL: Changelog, https://github.com/cstruct/konklav/releases
13
+ Project-URL: Documentation, https://github.com/cstruct/konklav/tree/main/items/py-control-plane
14
+ Project-URL: Issues, https://github.com/cstruct/konklav/issues
15
+ Project-URL: Repository, https://github.com/cstruct/konklav
16
+ Description-Content-Type: text/markdown
17
+
18
+ # konklav-control-plane
19
+
20
+ Manage [Konklav](https://github.com/cstruct/konklav) from Python.
21
+
22
+ Konklav is a standalone identity and access service. This package is
23
+ the Python client for its control plane, the API operators and automation
24
+ use to manage tenants, subjects, credentials, roles, shares and the rest.
25
+
26
+ ```python
27
+ from konklav_control_plane import ControlPlaneClient, SecretKey
28
+
29
+ auth = SecretKey(subject_url="https://konklav:8080", key_id=key_id, secret=secret)
30
+ async with ControlPlaneClient("https://konklav:8082", auth=auth) as client:
31
+ written = await client.tenants.create(tenant_id, {"facts": {}})
32
+ ```
33
+
34
+ Every control-plane operation has a method, with request and response
35
+ types generated from Konklav's API document. `client.batch()` applies
36
+ several writes in one request, all or nothing, through the same methods.
37
+ `konklav_control_plane.sync` has the same client for synchronous code.
38
+ Failed requests are retried where that's safe, and errors raise
39
+ `KonklavError` subclasses such as `NotFound` and `Forbidden`.
40
+
41
+ To decide authorization in a relying system, use
42
+ [`konklav-relying`](https://pypi.org/project/konklav-relying/) instead. The
43
+ two packages don't depend on each other.
44
+
45
+ ## Versions
46
+
47
+ The package has the same version as the Konklav server it was released
48
+ with, and only that server version is supported. Pre-releases, such as
49
+ `1.3.0rc1`, are installed only with `pip install --pre`.
50
+
51
+ ## Links
52
+
53
+ - [Konklav](https://github.com/cstruct/konklav)
54
+ - [This package's README](https://github.com/cstruct/konklav/tree/main/items/py-control-plane)
55
+ - [Releases and changelog](https://github.com/cstruct/konklav/releases)
56
+ - [Issues](https://github.com/cstruct/konklav/issues)
57
+
58
+ MIT licensed.
@@ -0,0 +1,41 @@
1
+ # konklav-control-plane
2
+
3
+ Manage [Konklav](https://github.com/cstruct/konklav) from Python.
4
+
5
+ Konklav is a standalone identity and access service. This package is
6
+ the Python client for its control plane, the API operators and automation
7
+ use to manage tenants, subjects, credentials, roles, shares and the rest.
8
+
9
+ ```python
10
+ from konklav_control_plane import ControlPlaneClient, SecretKey
11
+
12
+ auth = SecretKey(subject_url="https://konklav:8080", key_id=key_id, secret=secret)
13
+ async with ControlPlaneClient("https://konklav:8082", auth=auth) as client:
14
+ written = await client.tenants.create(tenant_id, {"facts": {}})
15
+ ```
16
+
17
+ Every control-plane operation has a method, with request and response
18
+ types generated from Konklav's API document. `client.batch()` applies
19
+ several writes in one request, all or nothing, through the same methods.
20
+ `konklav_control_plane.sync` has the same client for synchronous code.
21
+ Failed requests are retried where that's safe, and errors raise
22
+ `KonklavError` subclasses such as `NotFound` and `Forbidden`.
23
+
24
+ To decide authorization in a relying system, use
25
+ [`konklav-relying`](https://pypi.org/project/konklav-relying/) instead. The
26
+ two packages don't depend on each other.
27
+
28
+ ## Versions
29
+
30
+ The package has the same version as the Konklav server it was released
31
+ with, and only that server version is supported. Pre-releases, such as
32
+ `1.3.0rc1`, are installed only with `pip install --pre`.
33
+
34
+ ## Links
35
+
36
+ - [Konklav](https://github.com/cstruct/konklav)
37
+ - [This package's README](https://github.com/cstruct/konklav/tree/main/items/py-control-plane)
38
+ - [Releases and changelog](https://github.com/cstruct/konklav/releases)
39
+ - [Issues](https://github.com/cstruct/konklav/issues)
40
+
41
+ MIT licensed.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.8.24,<0.9"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "konklav-control-plane"
7
+ # Set by `mise run release:bump`, with the rest of the release.
8
+ version = "1.4.0"
9
+ description = "Manage Konklav from Python: tenants, subjects, credentials, roles and the rest of the control plane"
10
+ readme = "PYPI.md"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.12"
14
+ dependencies = ["httpx>=0.28"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Security",
18
+ "Typing :: Typed",
19
+ ]
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/cstruct/konklav"
23
+ Documentation = "https://github.com/cstruct/konklav/tree/main/items/py-control-plane"
24
+ Changelog = "https://github.com/cstruct/konklav/releases"
25
+ Issues = "https://github.com/cstruct/konklav/issues"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "datamodel-code-generator==0.88.0",
30
+ "mypy==1.20.2",
31
+ "pytest>=8",
32
+ "unasync==0.6.0",
33
+ ]
34
+
35
+ [tool.mypy]
36
+ files = ["src", "tools", "tests/typing"]
37
+ strict = true
38
+ python_version = "3.12"
39
+
40
+ [[tool.mypy.overrides]]
41
+ module = "unasync"
42
+ ignore_missing_imports = true
@@ -0,0 +1,46 @@
1
+ """A client for Konklav's control-plane surface.
2
+
3
+ `ControlPlaneClient` is asynchronous; `konklav_control_plane.sync` has the
4
+ same client for synchronous code. Request and response types are in
5
+ `konklav_control_plane.types`, and errors in `konklav_control_plane.errors`.
6
+ """
7
+
8
+ # r[impl item.py-control-plane.pure-python]
9
+ # r[impl item.py-control-plane.independent]
10
+ from importlib.metadata import version
11
+
12
+ from ._async.auth import BearerToken, SecretKey
13
+ from ._async.batch import Batch
14
+ # r[impl item.py-control-plane.async-default]
15
+ from ._async.client import ControlPlaneClient
16
+ from ._common import Mode, Pending, Written
17
+ from .errors import (
18
+ Conflict,
19
+ Forbidden,
20
+ InvalidRequest,
21
+ KonklavError,
22
+ NotFound,
23
+ RateLimited,
24
+ Unauthenticated,
25
+ Unavailable,
26
+ )
27
+
28
+ __version__ = version("konklav-control-plane")
29
+
30
+ __all__ = [
31
+ "Batch",
32
+ "BearerToken",
33
+ "Conflict",
34
+ "ControlPlaneClient",
35
+ "Forbidden",
36
+ "InvalidRequest",
37
+ "KonklavError",
38
+ "Mode",
39
+ "NotFound",
40
+ "Pending",
41
+ "RateLimited",
42
+ "SecretKey",
43
+ "Unauthenticated",
44
+ "Unavailable",
45
+ "Written",
46
+ ]
@@ -0,0 +1,95 @@
1
+ """How the client authenticates to the control plane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
8
+
9
+ from .. import _backend
10
+ from .._backend import AsyncLock, AsyncToken
11
+ from .._common import base
12
+
13
+ if TYPE_CHECKING:
14
+ from .client import ControlPlaneClient
15
+
16
+
17
+ @runtime_checkable
18
+ class Auth(Protocol):
19
+ async def token(self, client: ControlPlaneClient) -> str:
20
+ """The bearer token to send."""
21
+ ...
22
+
23
+ def invalidate(self) -> bool:
24
+ """Forgets a rejected token; `True` if the next one will differ."""
25
+ ...
26
+
27
+
28
+ # r[impl item.py-control-plane.auth]
29
+ class BearerToken:
30
+ """A fixed token: the bootstrap token, or an operator's access token."""
31
+
32
+ def __init__(self, token: str) -> None:
33
+ self._token = token
34
+
35
+ async def token(self, client: ControlPlaneClient) -> str:
36
+ return self._token
37
+
38
+ def invalidate(self) -> bool:
39
+ return False
40
+
41
+
42
+ class SecretKey:
43
+ """A service principal's secret key, logged in at the subject-facing
44
+ surface and logged in again once 80% of the token's lifetime has
45
+ passed."""
46
+
47
+ def __init__(self, *, subject_url: str, key_id: str, secret: str) -> None:
48
+ self._login_url = f"{base(subject_url)}/v1/login/secret-key"
49
+ self._key_id = key_id
50
+ self._secret = secret
51
+ self._cached: tuple[str, float] | None = None
52
+ self._lock = AsyncLock()
53
+
54
+ async def token(self, client: ControlPlaneClient) -> str:
55
+ async with self._lock:
56
+ if self._cached is not None and self._cached[1] > _backend.now():
57
+ return self._cached[0]
58
+ response = await client._send(
59
+ "POST",
60
+ self._login_url,
61
+ json={"key_id": self._key_id, "secret": self._secret},
62
+ authenticate=False,
63
+ )
64
+ body = response.json()
65
+ renew_at = _backend.now() + body["expires_in"] * 0.8
66
+ self._cached = (body["access_token"], renew_at)
67
+ return self._cached[0]
68
+
69
+ def invalidate(self) -> bool:
70
+ self._cached = None
71
+ return True
72
+
73
+
74
+ class TokenFunction:
75
+ """A caller's function that returns the token to send."""
76
+
77
+ def __init__(self, function: Callable[[], AsyncToken]) -> None:
78
+ self._function = function
79
+
80
+ async def token(self, client: ControlPlaneClient) -> str:
81
+ token = self._function()
82
+ if inspect.isawaitable(token):
83
+ token = await token
84
+ if not isinstance(token, str):
85
+ raise TypeError(f"the token function returned {type(token).__name__}, not str")
86
+ return token
87
+
88
+ def invalidate(self) -> bool:
89
+ return False
90
+
91
+
92
+ def resolve(auth: Auth | Callable[[], AsyncToken]) -> Auth:
93
+ if isinstance(auth, Auth):
94
+ return auth
95
+ return TokenFunction(auth)
@@ -0,0 +1,119 @@
1
+ """Batches: writes collected through the client's namespaces and applied in
2
+ one request, all or nothing."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from types import TracebackType
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from .._common import Call, Pending
10
+ from . import resources
11
+ from .resources import Batched
12
+
13
+ if TYPE_CHECKING:
14
+ from .client import ControlPlaneClient
15
+
16
+
17
+ # r[impl item.py-control-plane.batch]
18
+ class Batch:
19
+ """Writes to apply in one request, all or nothing; make one with
20
+ `client.batch()`.
21
+
22
+ Its namespaces mirror the client's, writes only. Each call is recorded
23
+ and returns a `Pending`, whose `result` is what the same call on the
24
+ client returns, once the batch has been sent: on leaving `async with`
25
+ without an error, or by `send()`. Later operations name what earlier ones
26
+ create by the ids the caller chose for them. If an operation fails,
27
+ nothing is applied and its error is raised, with its position as
28
+ `operation`. `cursor` is then the change-feed cursor of the last event
29
+ the batch produced, if any.
30
+ """
31
+
32
+ tenants: resources.Tenants[Batched]
33
+ memberships: resources.Memberships[Batched]
34
+ subjects: resources.Subjects[Batched]
35
+ credentials: resources.Credentials[Batched]
36
+ sessions: resources.Sessions[Batched]
37
+ signing_keys: resources.SigningKeys[Batched]
38
+ catalog: resources.Catalog[Batched]
39
+ templates: resources.Templates[Batched]
40
+ roles: resources.Roles[Batched]
41
+ assignments: resources.Assignments[Batched]
42
+ shares: resources.Shares[Batched]
43
+ registered_scopes: resources.RegisteredScopes[Batched]
44
+ invites: resources.Invites[Batched]
45
+ oidc_clients: resources.OidcClients[Batched]
46
+ imports: resources.Imports[Batched]
47
+
48
+ def __init__(
49
+ self,
50
+ client: ControlPlaneClient,
51
+ *,
52
+ idempotency_key: str | None = None,
53
+ idempotency_seconds: int | None = None,
54
+ ) -> None:
55
+ self._client = client
56
+ self._key = idempotency_key
57
+ self._seconds = idempotency_seconds
58
+ self._calls: list[tuple[Call, Pending[Any]]] = []
59
+ self._sent = False
60
+ self.cursor: int | None = None
61
+ self.tenants = resources.Tenants(client, self)
62
+ self.memberships = resources.Memberships(client, self)
63
+ self.subjects = resources.Subjects(client, self)
64
+ self.credentials = resources.Credentials(client, self)
65
+ self.sessions = resources.Sessions(client, self)
66
+ self.signing_keys = resources.SigningKeys(client, self)
67
+ self.catalog = resources.Catalog(client, self)
68
+ self.templates = resources.Templates(client, self)
69
+ self.roles = resources.Roles(client, self)
70
+ self.assignments = resources.Assignments(client, self)
71
+ self.shares = resources.Shares(client, self)
72
+ self.registered_scopes = resources.RegisteredScopes(client, self)
73
+ self.invites = resources.Invites(client, self)
74
+ self.oidc_clients = resources.OidcClients(client, self)
75
+ self.imports = resources.Imports(client, self)
76
+
77
+ def _add(self, call: Call) -> Pending[Any]:
78
+ if self._sent:
79
+ raise RuntimeError("the batch was sent already")
80
+ pending: Pending[Any] = Pending()
81
+ self._calls.append((call, pending))
82
+ return pending
83
+
84
+ async def send(self) -> None:
85
+ """Sends the recorded writes. A batch is sent once; one with nothing
86
+ recorded sends nothing."""
87
+ if self._sent:
88
+ raise RuntimeError("the batch was sent already")
89
+ self._sent = True
90
+ if not self._calls:
91
+ return
92
+ body: dict[str, Any] = {"operations": [call.operation() for call, _ in self._calls]}
93
+ if self._key is not None:
94
+ body["idempotency_key"] = self._key
95
+ if self._seconds is not None:
96
+ body["idempotency_seconds"] = self._seconds
97
+ response = await self._client._send(
98
+ "POST",
99
+ f"{self._client._url}/batch",
100
+ json=body,
101
+ # r[impl item.py-control-plane.retries]
102
+ retry_as="PUT" if self._key is not None else None,
103
+ )
104
+ answer = response.json()
105
+ self.cursor = answer.get("cursor")
106
+ for (call, pending), result in zip(self._calls, answer["results"], strict=True):
107
+ pending._resolve(call.result(result["status"], result["body"]))
108
+
109
+ async def __aenter__(self) -> Batch:
110
+ return self
111
+
112
+ async def __aexit__(
113
+ self,
114
+ kind: type[BaseException] | None,
115
+ error: BaseException | None,
116
+ traceback: TracebackType | None,
117
+ ) -> None:
118
+ if error is None:
119
+ await self.send()
@@ -0,0 +1,220 @@
1
+ """The control-plane client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Callable, Mapping
6
+ from types import TracebackType
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .. import _backend
12
+ from .._backend import AsyncToken
13
+ from .._common import Call, Written, base, delay, retryable, written
14
+ from ..errors import KonklavError, from_response
15
+ from . import resources
16
+ from .auth import Auth, resolve
17
+ from .batch import Batch
18
+ from .resources import Direct
19
+
20
+ DEFAULT_TIMEOUT = 30.0
21
+ DEFAULT_RETRIES = 3
22
+
23
+
24
+ class ControlPlaneClient:
25
+ """A client for Konklav's control-plane surface.
26
+
27
+ `url` is the surface's base URL, without `/v1`; a path prefix it has,
28
+ such as a proxy's, is kept. `auth` is a `BearerToken`, a `SecretKey`
29
+ or a function returning the token to send. Pass `http` to send through
30
+ your own `httpx` client; otherwise the client makes one with `timeout`
31
+ and closes it when the client is closed. `retries` is how many times a failed request
32
+ is sent again, where it is safe to; `0` turns retries off.
33
+
34
+ The operations are grouped as the API document tags them, such as
35
+ `client.tenants.create(...)` and `client.roles.put(...)`. `batch()`
36
+ collects writes made through the same namespaces and applies them in
37
+ one request, all or nothing.
38
+ """
39
+
40
+ tenants: resources.Tenants[Direct]
41
+ memberships: resources.Memberships[Direct]
42
+ subjects: resources.Subjects[Direct]
43
+ credentials: resources.Credentials[Direct]
44
+ sessions: resources.Sessions[Direct]
45
+ signing_keys: resources.SigningKeys[Direct]
46
+ catalog: resources.Catalog[Direct]
47
+ templates: resources.Templates[Direct]
48
+ roles: resources.Roles[Direct]
49
+ assignments: resources.Assignments[Direct]
50
+ entitlements: resources.Entitlements[Direct]
51
+ explain: resources.Explain[Direct]
52
+ shares: resources.Shares[Direct]
53
+ registered_scopes: resources.RegisteredScopes[Direct]
54
+ invites: resources.Invites[Direct]
55
+ oidc_clients: resources.OidcClients[Direct]
56
+ imports: resources.Imports[Direct]
57
+ batches: resources.Batches[Direct]
58
+
59
+ # r[impl item.py-control-plane.injectable-http]
60
+ # r[impl item.py-control-plane.explicit-config]
61
+ def __init__(
62
+ self,
63
+ url: str,
64
+ *,
65
+ auth: Auth | Callable[[], AsyncToken],
66
+ http: httpx.AsyncClient | None = None,
67
+ timeout: float = DEFAULT_TIMEOUT,
68
+ retries: int = DEFAULT_RETRIES,
69
+ ) -> None:
70
+ if retries < 0:
71
+ raise ValueError("retries can't be negative")
72
+ self._url = f"{base(url)}/v1"
73
+ self._auth = resolve(auth)
74
+ self._owns_http = http is None
75
+ # The environment holds no configuration for this client.
76
+ self._http = http if http is not None else httpx.AsyncClient(timeout=timeout, trust_env=False)
77
+ self._retries = retries
78
+ self.tenants = resources.Tenants(self)
79
+ self.memberships = resources.Memberships(self)
80
+ self.subjects = resources.Subjects(self)
81
+ self.credentials = resources.Credentials(self)
82
+ self.sessions = resources.Sessions(self)
83
+ self.signing_keys = resources.SigningKeys(self)
84
+ self.catalog = resources.Catalog(self)
85
+ self.templates = resources.Templates(self)
86
+ self.roles = resources.Roles(self)
87
+ self.assignments = resources.Assignments(self)
88
+ self.entitlements = resources.Entitlements(self)
89
+ self.explain = resources.Explain(self)
90
+ self.shares = resources.Shares(self)
91
+ self.registered_scopes = resources.RegisteredScopes(self)
92
+ self.invites = resources.Invites(self)
93
+ self.oidc_clients = resources.OidcClients(self)
94
+ self.imports = resources.Imports(self)
95
+ self.batches = resources.Batches(self)
96
+
97
+ def batch(self, *, idempotency_key: str | None = None, idempotency_seconds: int | None = None) -> Batch:
98
+ """A batch: `async with client.batch() as batch:` collects the writes
99
+ made through `batch`'s namespaces, which mirror the client's, and
100
+ sends them in one request on leaving the block without an error.
101
+
102
+ With an `idempotency_key`, sending the same batch again answers what
103
+ it first answered without applying it again, so it is retried like a
104
+ `PUT`. The server remembers the key for `idempotency_seconds`, or its
105
+ default (ten minutes) when `None`.
106
+ """
107
+ return Batch(self, idempotency_key=idempotency_key, idempotency_seconds=idempotency_seconds)
108
+
109
+ async def __aenter__(self) -> ControlPlaneClient:
110
+ return self
111
+
112
+ async def __aexit__(
113
+ self,
114
+ kind: type[BaseException] | None,
115
+ error: BaseException | None,
116
+ traceback: TracebackType | None,
117
+ ) -> None:
118
+ await self.aclose()
119
+
120
+ async def aclose(self) -> None:
121
+ """Closes the `httpx` client, unless it was passed in."""
122
+ if self._owns_http:
123
+ await self._http.aclose()
124
+
125
+ async def request(
126
+ self,
127
+ method: str,
128
+ path: str,
129
+ *,
130
+ json: Any = None,
131
+ params: Mapping[str, Any] | None = None,
132
+ ) -> Any:
133
+ """Sends a request to `path` under `/v1` and returns its decoded
134
+ JSON body, or `None` when it has none."""
135
+ response = await self._send(method, f"{self._url}{path}", json=json, params=params)
136
+ return response.json() if response.content else None
137
+
138
+ async def _get(self, path: str, params: Mapping[str, Any] | None = None) -> Any:
139
+ """A `GET`, leaving out query parameters that are `None`."""
140
+ query = {name: value for name, value in (params or {}).items() if value is not None}
141
+ return await self.request("GET", path, params=query)
142
+
143
+ async def _post(self, path: str, body: Any = None) -> Any:
144
+ return await self.request("POST", path, json=body)
145
+
146
+ async def _write(
147
+ self,
148
+ method: str,
149
+ path: str,
150
+ *,
151
+ json: Any = None,
152
+ params: Mapping[str, Any] | None = None,
153
+ create: bool = False,
154
+ ) -> Written[Any]:
155
+ """Sends a write and splits the cursor off its result."""
156
+ response = await self._send(method, f"{self._url}{path}", json=json, params=params)
157
+ return written(response.status_code, response.json(), create=create)
158
+
159
+ async def _call(self, call: Call) -> Any:
160
+ """Sends `call` and returns what its method returns."""
161
+ response = await self._send(call.method, f"{self._url}{call.path}", json=call.json, params=call.query())
162
+ return call.result(response.status_code, response.json() if response.content else None)
163
+
164
+ # r[impl item.py-control-plane.paging]
165
+ async def _pages(
166
+ self, path: str, key: str, params: Mapping[str, Any] | None = None
167
+ ) -> AsyncIterator[Any]:
168
+ """Every item of a paged list, page after page."""
169
+ query = {name: value for name, value in (params or {}).items() if value is not None}
170
+ while True:
171
+ page = await self._get(path, query)
172
+ for item in page[key]:
173
+ yield item
174
+ if page.get("next") is None:
175
+ return
176
+ query["before"] = page["next"]
177
+
178
+ async def _send(
179
+ self,
180
+ method: str,
181
+ url: str,
182
+ *,
183
+ json: Any = None,
184
+ params: Mapping[str, Any] | None = None,
185
+ authenticate: bool = True,
186
+ retry_as: str | None = None,
187
+ ) -> httpx.Response:
188
+ """Sends a request, logging in again once and retrying as allowed,
189
+ as for `retry_as` when given instead of `method`."""
190
+ attempt = 0
191
+ relogged = False
192
+ while True:
193
+ headers = {}
194
+ if authenticate:
195
+ headers["authorization"] = f"Bearer {await self._auth.token(self)}"
196
+ cause: Exception | None = None
197
+ try:
198
+ response = await self._http.request(
199
+ method, url, json=json, params=params, headers=headers
200
+ )
201
+ except httpx.TransportError as error:
202
+ failure = KonklavError(f"Konklav couldn't be reached: {error}")
203
+ cause = error
204
+ else:
205
+ if response.is_success:
206
+ return response
207
+ failure = from_response(response)
208
+ # r[impl item.py-control-plane.auth-relogin]
209
+ if (
210
+ authenticate
211
+ and failure.code == "UNAUTHENTICATED"
212
+ and not relogged
213
+ and self._auth.invalidate()
214
+ ):
215
+ relogged = True
216
+ continue
217
+ if attempt >= self._retries or not retryable(retry_as or method, failure):
218
+ raise failure from cause
219
+ await _backend.async_sleep(delay(attempt, failure))
220
+ attempt += 1