lars-kluijtmans-admin-sdk 0.0.3__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,8 @@
1
+ [flake8]
2
+ max-line-length = 120
3
+ # Match the rest of the repo: pyflakes (F*) on; ignore cosmetic pycodestyle checks an
4
+ # autoformatter would own (this project doesn't run one).
5
+ extend-ignore = E501,E203,W503,E302,E303,E305,W391,E127,E128,E131
6
+ exclude = .venv,__pycache__,.pytest_cache,.git,dist,build
7
+ per-file-ignores =
8
+ tests/*: E402, F811
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: lars-kluijtmans-admin-sdk
3
+ Version: 0.0.3
4
+ Summary: Python admin SDK for the Auth Platform Management API
5
+ Project-URL: Homepage, https://github.com/LarsKluijtmans/auth
6
+ Project-URL: Source, https://github.com/LarsKluijtmans/auth/tree/main/admin-sdk-python
7
+ Author: Auth Platform
8
+ License: MIT
9
+ Keywords: admin,auth,oauth,rbac,sdk
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx>=0.27
12
+ Provides-Extra: dev
13
+ Requires-Dist: build>=1; extra == 'dev'
14
+ Requires-Dist: flake8>=7; extra == 'dev'
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Requires-Dist: respx>=0.21; extra == 'dev'
17
+ Requires-Dist: twine>=5; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # lars-kluijtmans-admin-sdk
21
+
22
+ A Python admin SDK for the **Auth Platform** Management API — modelled on the Firebase
23
+ Authentication Admin SDK. From a server-side program: read projects, clients and providers,
24
+ and read/update users (active state, custom claims, password-reset/verification emails).
25
+
26
+ ```bash
27
+ pip install lars-kluijtmans-admin-sdk
28
+ ```
29
+
30
+ The import package stays `auth_platform_admin` (`from auth_platform_admin import AdminClient`).
31
+ Requires Python 3.10+.
32
+
33
+ ## Authenticating
34
+
35
+ Two credential modes.
36
+
37
+ ### M2M (recommended for servers)
38
+
39
+ A **service client** with the `client_credentials` grant. The SDK runs the grant against the
40
+ Login API, then caches and refreshes the token for you:
41
+
42
+ ```python
43
+ from auth_platform_admin import AdminClient
44
+
45
+ client = AdminClient.from_client_credentials(
46
+ login_api_url="https://login.example.com",
47
+ management_url="https://manage.example.com",
48
+ client_id="client_xxx",
49
+ client_secret="…",
50
+ )
51
+ ```
52
+
53
+ ### Bring-your-own token
54
+
55
+ Attach an admin access token you already hold (e.g. from a logged-in session):
56
+
57
+ ```python
58
+ client = AdminClient(management_url="https://manage.example.com", token=ACCESS_TOKEN)
59
+ ```
60
+
61
+ What a credential may do is governed by RBAC: a **user** token uses that user's role; a **service
62
+ client** uses the role assigned to it (see *Setting up a service client* below).
63
+
64
+ `AdminClient` is a context manager and closes its HTTP connection on exit:
65
+
66
+ ```python
67
+ with AdminClient.from_client_credentials(...) as client:
68
+ ...
69
+ ```
70
+
71
+ ## Using it
72
+
73
+ ```python
74
+ # Projects
75
+ project = client.projects.get(project_id)
76
+ projects = client.projects.list(company_id)
77
+
78
+ # Clients & providers
79
+ clients = client.clients.list(project_id)
80
+ provider = client.providers.catalog() # platform catalog
81
+ enabled = client.providers.list(project_id) # per-project, with enable state
82
+
83
+ # Users — read
84
+ users = client.users.list(project_id, search="ann", status="active") # one page
85
+ for user in client.users.iter(project_id): # all pages
86
+ print(user.email, user.is_active)
87
+ user = client.users.get(project_id, user_id)
88
+
89
+ # Users — update (active state, claims, and profile fields)
90
+ client.users.set_active(project_id, user_id, False)
91
+ client.users.set_claims(project_id, user_id, {"tier": "gold"})
92
+ client.users.set_profile(project_id, user_id, {"display_name": "Ann", "language": "nl"})
93
+ # Firebase-style: apply whichever fields you pass (profile fields accept None to clear).
94
+ client.users.update(project_id, user_id, active=True, claims={"tier": "silver"},
95
+ display_name="Ann", language="nl", profile_picture="https://cdn/x.png")
96
+
97
+ # Users — remediation
98
+ client.users.send_password_reset(project_id, user_id)
99
+ client.users.resend_verification(project_id, user_id)
100
+ client.users.force_logout(project_id, user_id)
101
+ ```
102
+
103
+ Every model exposes `.raw` (the full API payload) so new/unknown fields are always reachable.
104
+
105
+ > The admin can edit **display name, language, and profile picture** (`set_profile` / `update`),
106
+ > flip active state, set claims, and trigger the reset/verification emails. **Email and password
107
+ > changes remain self-service** on the Login API — they are not part of the admin surface.
108
+
109
+ ## Setting up a service client
110
+
111
+ 1. **Create a client** for the project (Management console → project → Clients, or the API) and
112
+ enable the **`client_credentials`** grant on it. Keep the `client_id` + secret.
113
+ 2. **Assign it a role** — the permissions an m2m token from this client gets:
114
+ ```http
115
+ PUT /api/v1/projects/{project_id}/clients/{client_id}/service-role
116
+ { "role_id": "<a management role id>" }
117
+ ```
118
+ (Needs `role:manage`.) A client with no service role can mint a token but is denied everything.
119
+ 3. Use `AdminClient.from_client_credentials(...)` with that client's id + secret.
120
+
121
+ The service account is always scoped to its own company/project and is never a platform admin.
122
+
123
+ ## Errors
124
+
125
+ Failures raise typed exceptions — `AuthError` (401), `Forbidden` (403), `NotFound` (404),
126
+ `Conflict` (409), `ValidationError` (422), `NetworkError` — all subclasses of `AdminError`
127
+ (carrying `.status` and `.detail`):
128
+
129
+ ```python
130
+ from auth_platform_admin import NotFound, Forbidden
131
+
132
+ try:
133
+ client.users.get(project_id, user_id)
134
+ except NotFound:
135
+ ...
136
+ except Forbidden as exc:
137
+ print(exc.status, exc.detail)
138
+ ```
139
+
140
+ See [`examples/`](examples/) for runnable scripts (M2M and bring-your-own-token).
@@ -0,0 +1,121 @@
1
+ # lars-kluijtmans-admin-sdk
2
+
3
+ A Python admin SDK for the **Auth Platform** Management API — modelled on the Firebase
4
+ Authentication Admin SDK. From a server-side program: read projects, clients and providers,
5
+ and read/update users (active state, custom claims, password-reset/verification emails).
6
+
7
+ ```bash
8
+ pip install lars-kluijtmans-admin-sdk
9
+ ```
10
+
11
+ The import package stays `auth_platform_admin` (`from auth_platform_admin import AdminClient`).
12
+ Requires Python 3.10+.
13
+
14
+ ## Authenticating
15
+
16
+ Two credential modes.
17
+
18
+ ### M2M (recommended for servers)
19
+
20
+ A **service client** with the `client_credentials` grant. The SDK runs the grant against the
21
+ Login API, then caches and refreshes the token for you:
22
+
23
+ ```python
24
+ from auth_platform_admin import AdminClient
25
+
26
+ client = AdminClient.from_client_credentials(
27
+ login_api_url="https://login.example.com",
28
+ management_url="https://manage.example.com",
29
+ client_id="client_xxx",
30
+ client_secret="…",
31
+ )
32
+ ```
33
+
34
+ ### Bring-your-own token
35
+
36
+ Attach an admin access token you already hold (e.g. from a logged-in session):
37
+
38
+ ```python
39
+ client = AdminClient(management_url="https://manage.example.com", token=ACCESS_TOKEN)
40
+ ```
41
+
42
+ What a credential may do is governed by RBAC: a **user** token uses that user's role; a **service
43
+ client** uses the role assigned to it (see *Setting up a service client* below).
44
+
45
+ `AdminClient` is a context manager and closes its HTTP connection on exit:
46
+
47
+ ```python
48
+ with AdminClient.from_client_credentials(...) as client:
49
+ ...
50
+ ```
51
+
52
+ ## Using it
53
+
54
+ ```python
55
+ # Projects
56
+ project = client.projects.get(project_id)
57
+ projects = client.projects.list(company_id)
58
+
59
+ # Clients & providers
60
+ clients = client.clients.list(project_id)
61
+ provider = client.providers.catalog() # platform catalog
62
+ enabled = client.providers.list(project_id) # per-project, with enable state
63
+
64
+ # Users — read
65
+ users = client.users.list(project_id, search="ann", status="active") # one page
66
+ for user in client.users.iter(project_id): # all pages
67
+ print(user.email, user.is_active)
68
+ user = client.users.get(project_id, user_id)
69
+
70
+ # Users — update (active state, claims, and profile fields)
71
+ client.users.set_active(project_id, user_id, False)
72
+ client.users.set_claims(project_id, user_id, {"tier": "gold"})
73
+ client.users.set_profile(project_id, user_id, {"display_name": "Ann", "language": "nl"})
74
+ # Firebase-style: apply whichever fields you pass (profile fields accept None to clear).
75
+ client.users.update(project_id, user_id, active=True, claims={"tier": "silver"},
76
+ display_name="Ann", language="nl", profile_picture="https://cdn/x.png")
77
+
78
+ # Users — remediation
79
+ client.users.send_password_reset(project_id, user_id)
80
+ client.users.resend_verification(project_id, user_id)
81
+ client.users.force_logout(project_id, user_id)
82
+ ```
83
+
84
+ Every model exposes `.raw` (the full API payload) so new/unknown fields are always reachable.
85
+
86
+ > The admin can edit **display name, language, and profile picture** (`set_profile` / `update`),
87
+ > flip active state, set claims, and trigger the reset/verification emails. **Email and password
88
+ > changes remain self-service** on the Login API — they are not part of the admin surface.
89
+
90
+ ## Setting up a service client
91
+
92
+ 1. **Create a client** for the project (Management console → project → Clients, or the API) and
93
+ enable the **`client_credentials`** grant on it. Keep the `client_id` + secret.
94
+ 2. **Assign it a role** — the permissions an m2m token from this client gets:
95
+ ```http
96
+ PUT /api/v1/projects/{project_id}/clients/{client_id}/service-role
97
+ { "role_id": "<a management role id>" }
98
+ ```
99
+ (Needs `role:manage`.) A client with no service role can mint a token but is denied everything.
100
+ 3. Use `AdminClient.from_client_credentials(...)` with that client's id + secret.
101
+
102
+ The service account is always scoped to its own company/project and is never a platform admin.
103
+
104
+ ## Errors
105
+
106
+ Failures raise typed exceptions — `AuthError` (401), `Forbidden` (403), `NotFound` (404),
107
+ `Conflict` (409), `ValidationError` (422), `NetworkError` — all subclasses of `AdminError`
108
+ (carrying `.status` and `.detail`):
109
+
110
+ ```python
111
+ from auth_platform_admin import NotFound, Forbidden
112
+
113
+ try:
114
+ client.users.get(project_id, user_id)
115
+ except NotFound:
116
+ ...
117
+ except Forbidden as exc:
118
+ print(exc.status, exc.detail)
119
+ ```
120
+
121
+ See [`examples/`](examples/) for runnable scripts (M2M and bring-your-own-token).
@@ -0,0 +1,28 @@
1
+ """Bring-your-own-token example: pass an admin access token you already hold.
2
+
3
+ export AUTH_MANAGE_URL=http://127.0.0.1:8000
4
+ export AUTH_TOKEN=<an admin access token>
5
+ export AUTH_PROJECT_ID=...
6
+ export AUTH_USER_ID=...
7
+ python examples/byo_token_example.py
8
+ """
9
+ import os
10
+
11
+ from auth_platform_admin import AdminClient
12
+
13
+
14
+ def main() -> None:
15
+ with AdminClient(management_url=os.environ["AUTH_MANAGE_URL"], token=os.environ["AUTH_TOKEN"]) as client:
16
+ project_id = os.environ["AUTH_PROJECT_ID"]
17
+ user_id = os.environ["AUTH_USER_ID"]
18
+
19
+ user = client.users.get(project_id, user_id)
20
+ print(f"{user.email} active={user.is_active} claims={client.users.get_claims(project_id, user_id)}")
21
+
22
+ # Tag the user with a custom claim, then read it back.
23
+ client.users.set_claims(project_id, user_id, {"reviewed": True})
24
+ print("claims now:", client.users.get_claims(project_id, user_id))
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
@@ -0,0 +1,34 @@
1
+ """M2M example: authenticate with client_credentials and walk projects -> users.
2
+
3
+ Run against a local stack (after setting up a service client + role, see the README):
4
+
5
+ export AUTH_LOGIN_URL=http://127.0.0.1:8010
6
+ export AUTH_MANAGE_URL=http://127.0.0.1:8000
7
+ export AUTH_CLIENT_ID=client_xxx
8
+ export AUTH_CLIENT_SECRET=...
9
+ export AUTH_COMPANY_ID=...
10
+ python examples/m2m_example.py
11
+ """
12
+ import os
13
+
14
+ from auth_platform_admin import AdminClient
15
+
16
+
17
+ def main() -> None:
18
+ with AdminClient.from_client_credentials(
19
+ login_api_url=os.environ["AUTH_LOGIN_URL"],
20
+ management_url=os.environ["AUTH_MANAGE_URL"],
21
+ client_id=os.environ["AUTH_CLIENT_ID"],
22
+ client_secret=os.environ["AUTH_CLIENT_SECRET"],
23
+ ) as client:
24
+ for project in client.projects.list(os.environ["AUTH_COMPANY_ID"]):
25
+ print(f"project {project.name} ({project.id})")
26
+ print(f" clients: {[c.client_id for c in client.clients.list(project.id)]}")
27
+ enabled = [p.provider_id for p in client.providers.list(project.id) if p.enabled]
28
+ print(f" providers: {enabled}")
29
+ for user in client.users.list(project.id, page_size=5):
30
+ print(f" user {user.email} active={user.is_active}")
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lars-kluijtmans-admin-sdk"
7
+ version = "0.0.3"
8
+ description = "Python admin SDK for the Auth Platform Management API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Auth Platform" }]
13
+ keywords = ["auth", "admin", "sdk", "oauth", "rbac"]
14
+ dependencies = ["httpx>=0.27"]
15
+
16
+ [project.optional-dependencies]
17
+ dev = ["pytest>=8", "respx>=0.21", "flake8>=7", "build>=1", "twine>=5"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/LarsKluijtmans/auth"
21
+ Source = "https://github.com/LarsKluijtmans/auth/tree/main/admin-sdk-python"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/auth_platform_admin"]
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ testpaths = tests
3
+ python_files = test_*.py
4
+ python_functions = test_*
@@ -0,0 +1,47 @@
1
+ """Set the package version across pyproject.toml and the package __init__.
2
+
3
+ Used by the "Deploy to PyPI" workflow so a release can be cut from the Actions tab
4
+ without a manual bump commit:
5
+
6
+ python set_version.py --version=0.2.0
7
+
8
+ Rewrites `version = "..."` in pyproject.toml and `__version__ = "..."` in
9
+ src/auth_platform_admin/__init__.py, then prints the new version.
10
+ """
11
+ import argparse
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ ROOT = Path(__file__).resolve().parent
17
+ PYPROJECT = ROOT / "pyproject.toml"
18
+ INIT = ROOT / "src" / "auth_platform_admin" / "__init__.py"
19
+
20
+ # A normal PEP 440 release like 1.2.3, optionally with a pre/post/dev suffix.
21
+ _VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[._-]?(?:a|b|rc|alpha|beta|post|dev)\d*)?$")
22
+
23
+
24
+ def _replace(path: Path, pattern: str, replacement: str) -> None:
25
+ text = path.read_text(encoding="utf-8")
26
+ new_text, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE)
27
+ if count != 1:
28
+ raise SystemExit(f"error: could not find a version line to update in {path}")
29
+ path.write_text(new_text, encoding="utf-8")
30
+
31
+
32
+ def main() -> None:
33
+ parser = argparse.ArgumentParser(description="Set the SDK version.")
34
+ parser.add_argument("--version", required=True, help="New version, e.g. 0.2.0")
35
+ args = parser.parse_args()
36
+
37
+ version = args.version.strip().lstrip("v")
38
+ if not _VERSION_RE.match(version):
39
+ raise SystemExit(f"error: '{args.version}' is not a valid version number")
40
+
41
+ _replace(PYPROJECT, r'^version = "[^"]*"', f'version = "{version}"')
42
+ _replace(INIT, r'^__version__ = "[^"]*"', f'__version__ = "{version}"')
43
+ print(f"version set to {version}")
44
+
45
+
46
+ if __name__ == "__main__":
47
+ sys.exit(main())
@@ -0,0 +1,31 @@
1
+ """auth-platform-admin — a Python admin SDK for the Auth Platform Management API."""
2
+ from .client import AdminClient
3
+ from .errors import (
4
+ AdminError,
5
+ AuthError,
6
+ Conflict,
7
+ Forbidden,
8
+ NetworkError,
9
+ NotFound,
10
+ ValidationError,
11
+ )
12
+ from .models import Client, Project, ProjectProvider, Provider, User
13
+
14
+ __version__ = "0.0.3"
15
+
16
+ __all__ = [
17
+ "AdminClient",
18
+ "AdminError",
19
+ "AuthError",
20
+ "Conflict",
21
+ "Forbidden",
22
+ "NetworkError",
23
+ "NotFound",
24
+ "ValidationError",
25
+ "Client",
26
+ "Project",
27
+ "Provider",
28
+ "ProjectProvider",
29
+ "User",
30
+ "__version__",
31
+ ]
@@ -0,0 +1,170 @@
1
+ """The Auth Platform admin client.
2
+
3
+ ``AdminClient`` is a thin, typed, synchronous wrapper over the Management API. It supports
4
+ two credential modes:
5
+
6
+ * **M2M** — ``AdminClient.from_client_credentials(...)`` runs the OAuth ``client_credentials``
7
+ grant against the Login API (the client must have that grant enabled), caches the resulting
8
+ ``m2m`` access token, and refreshes it on expiry or a 401.
9
+ * **Bring-your-own token** — ``AdminClient(management_url, token=...)`` just attaches a bearer
10
+ token you already hold (e.g. an admin's session token); its RBAC role governs what you can do.
11
+
12
+ Resource groups (``client.projects`` etc.) are attached by the resource modules.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+ import time
18
+ from typing import Any, Iterator
19
+
20
+ import httpx
21
+
22
+ from .errors import NetworkError, raise_for_response
23
+
24
+ _TOKEN_PATH = "/login/v1/token"
25
+
26
+
27
+ class _Token:
28
+ __slots__ = ("value", "expires_at")
29
+
30
+ def __init__(self, value: str, expires_at: float) -> None:
31
+ self.value = value
32
+ self.expires_at = expires_at
33
+
34
+ def is_expiring(self, skew_seconds: float = 30.0) -> bool:
35
+ return time.time() >= self.expires_at - skew_seconds
36
+
37
+
38
+ class _ClientCredentials:
39
+ """Mints m2m access tokens via the Login API's client_credentials grant."""
40
+
41
+ def __init__(self, login_api_url: str, client_id: str, client_secret: str) -> None:
42
+ self._url = login_api_url.rstrip("/")
43
+ self._client_id = client_id
44
+ self._client_secret = client_secret
45
+
46
+ def fetch(self, http: httpx.Client) -> _Token:
47
+ try:
48
+ response = http.post(
49
+ f"{self._url}{_TOKEN_PATH}",
50
+ data={
51
+ "grant_type": "client_credentials",
52
+ "client_id": self._client_id,
53
+ "client_secret": self._client_secret,
54
+ },
55
+ )
56
+ except httpx.HTTPError as exc: # pragma: no cover - exercised via NetworkError tests
57
+ raise NetworkError(f"Token request failed: {exc}") from exc
58
+ raise_for_response(response)
59
+ body = response.json()
60
+ return _Token(body["access_token"], time.time() + float(body.get("expires_in", 3600)))
61
+
62
+
63
+ class AdminClient:
64
+ def __init__(
65
+ self,
66
+ management_url: str,
67
+ *,
68
+ token: str | None = None,
69
+ timeout: float = 30.0,
70
+ http_client: httpx.Client | None = None,
71
+ _credentials: _ClientCredentials | None = None,
72
+ ) -> None:
73
+ if token is None and _credentials is None:
74
+ raise ValueError("Provide a `token=` or use AdminClient.from_client_credentials(...)")
75
+ self._base = management_url.rstrip("/")
76
+ self._static_token = token
77
+ self._credentials = _credentials
78
+ self._cached: _Token | None = None
79
+ self._lock = threading.Lock()
80
+ self._http = http_client or httpx.Client(timeout=timeout)
81
+ self._owns_http = http_client is None
82
+ self._attach_resources()
83
+
84
+ @classmethod
85
+ def from_client_credentials(
86
+ cls,
87
+ *,
88
+ login_api_url: str,
89
+ management_url: str,
90
+ client_id: str,
91
+ client_secret: str,
92
+ timeout: float = 30.0,
93
+ http_client: httpx.Client | None = None,
94
+ ) -> "AdminClient":
95
+ credentials = _ClientCredentials(login_api_url, client_id, client_secret)
96
+ return cls(management_url, timeout=timeout, http_client=http_client, _credentials=credentials)
97
+
98
+ def _attach_resources(self) -> None:
99
+ from .resources import Clients, Projects, Providers, Users
100
+
101
+ self.projects = Projects(self)
102
+ self.clients = Clients(self)
103
+ self.providers = Providers(self)
104
+ self.users = Users(self)
105
+
106
+ # --- auth ---------------------------------------------------------------
107
+
108
+ def _access_token(self) -> str:
109
+ if self._static_token is not None:
110
+ return self._static_token
111
+ assert self._credentials is not None
112
+ with self._lock:
113
+ if self._cached is None or self._cached.is_expiring():
114
+ self._cached = self._credentials.fetch(self._http)
115
+ return self._cached.value
116
+
117
+ def _invalidate_token(self) -> None:
118
+ with self._lock:
119
+ self._cached = None
120
+
121
+ # --- transport ----------------------------------------------------------
122
+
123
+ def request(
124
+ self, method: str, path: str, *, params: dict | None = None, json: Any = None, _retried: bool = False
125
+ ) -> Any:
126
+ """Issue an authenticated request; return the decoded JSON (or None for 204).
127
+ On a 401 with M2M credentials, refresh the token once and retry."""
128
+ token = self._access_token()
129
+ try:
130
+ response = self._http.request(
131
+ method,
132
+ f"{self._base}{path}",
133
+ params=params,
134
+ json=json,
135
+ headers={"Authorization": f"Bearer {token}"},
136
+ )
137
+ except httpx.HTTPError as exc:
138
+ raise NetworkError(f"Request to {path} failed: {exc}") from exc
139
+
140
+ if response.status_code == 401 and not _retried and self._credentials is not None:
141
+ self._invalidate_token()
142
+ return self.request(method, path, params=params, json=json, _retried=True)
143
+
144
+ raise_for_response(response)
145
+ if response.status_code == 204 or not response.content:
146
+ return None
147
+ return response.json()
148
+
149
+ def paginate(self, path: str, *, params: dict | None = None) -> Iterator[dict]:
150
+ """Iterate every item across the API's `{items,page,page_size,total,pages}` pages."""
151
+ page = int((params or {}).get("page", 1))
152
+ while True:
153
+ data = self.request("GET", path, params={**(params or {}), "page": page})
154
+ for item in data.get("items", []):
155
+ yield item
156
+ if page >= int(data.get("pages") or 0):
157
+ break
158
+ page += 1
159
+
160
+ # --- lifecycle ----------------------------------------------------------
161
+
162
+ def close(self) -> None:
163
+ if self._owns_http:
164
+ self._http.close()
165
+
166
+ def __enter__(self) -> "AdminClient":
167
+ return self
168
+
169
+ def __exit__(self, *_exc: object) -> None:
170
+ self.close()