kscale 0.3.16__py3-none-any.whl → 0.3.18__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.
@@ -1,33 +1,19 @@
1
1
  """Defines a base client for the K-Scale WWW API client."""
2
2
 
3
- import asyncio
4
- import json
5
3
  import logging
6
4
  import os
7
- import secrets
8
5
  import sys
9
- import time
10
- import webbrowser
11
6
  from types import TracebackType
12
7
  from typing import Any, Mapping, Self, Type
13
8
  from urllib.parse import urljoin
14
9
 
15
- import aiohttp
16
10
  import httpx
17
- from aiohttp import web
18
- from async_lru import alru_cache
19
- from jwt import ExpiredSignatureError, PyJWKClient, decode as jwt_decode
20
11
  from pydantic import BaseModel
21
- from yarl import URL
22
12
 
23
- from kscale.web.gen.api import OICDInfo
24
- from kscale.web.utils import DEFAULT_UPLOAD_TIMEOUT, get_api_root, get_auth_dir
13
+ from kscale.web.utils import DEFAULT_UPLOAD_TIMEOUT, get_api_root
25
14
 
26
15
  logger = logging.getLogger(__name__)
27
16
 
28
- # This port matches the available port for the OAuth callback.
29
- OAUTH_PORT = 16821
30
-
31
17
  # This is the name of the API key header for the K-Scale WWW API.
32
18
  HEADER_NAME = "x-kscale-api-key"
33
19
 
@@ -36,126 +22,6 @@ def verbose_error() -> bool:
36
22
  return os.environ.get("KSCALE_VERBOSE_ERROR", "0") == "1"
37
23
 
38
24
 
39
- class OAuthCallback:
40
- def __init__(self) -> None:
41
- self.token_type: str | None = None
42
- self.access_token: str | None = None
43
- self.id_token: str | None = None
44
- self.state: str | None = None
45
- self.expires_in: str | None = None
46
- self.app = web.Application()
47
- self.app.router.add_get("/token", self.handle_token)
48
- self.app.router.add_get("/callback", self.handle_callback)
49
-
50
- async def handle_token(self, request: web.Request) -> web.Response:
51
- """Handle the token extraction."""
52
- self.token_type = request.query.get("token_type")
53
- self.access_token = request.query.get("access_token")
54
- self.id_token = request.query.get("id_token")
55
- self.state = request.query.get("state")
56
- self.expires_in = request.query.get("expires_in")
57
- return web.Response(text="OK")
58
-
59
- async def handle_callback(self, request: web.Request) -> web.Response:
60
- """Handle the OAuth callback with token in URL fragment."""
61
- return web.Response(
62
- text="""
63
- <!DOCTYPE html>
64
- <html lang="en">
65
- <head>
66
- <meta charset="UTF-8">
67
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
68
- <title>Authentication successful</title>
69
- <style>
70
- body {
71
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
72
- display: flex;
73
- justify-content: center;
74
- align-items: center;
75
- min-height: 100vh;
76
- margin: 0;
77
- background: #f5f5f5;
78
- color: #333;
79
- }
80
- .container {
81
- background: white;
82
- padding: 2rem;
83
- border-radius: 8px;
84
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
85
- max-width: 600px;
86
- width: 90%;
87
- }
88
- h1 {
89
- color: #2c3e50;
90
- margin-bottom: 1rem;
91
- }
92
- .token-info {
93
- background: #f8f9fa;
94
- border: 1px solid #dee2e6;
95
- border-radius: 4px;
96
- padding: 1rem;
97
- margin: 1rem 0;
98
- word-break: break-all;
99
- }
100
- .token-label {
101
- font-weight: bold;
102
- color: #6c757d;
103
- margin-bottom: 0.5rem;
104
- }
105
- .success-icon {
106
- color: #28a745;
107
- font-size: 48px;
108
- margin-bottom: 1rem;
109
- }
110
- </style>
111
- </head>
112
- <body>
113
- <div class="container">
114
- <div class="success-icon">✓</div>
115
- <h1>Authentication successful!</h1>
116
- <p>Your authentication tokens are shown below. You can now close this window.</p>
117
-
118
- <div class="token-info">
119
- <div class="token-label">Access Token:</div>
120
- <div id="accessTokenDisplay"></div>
121
- </div>
122
-
123
- <div class="token-info">
124
- <div class="token-label">ID Token:</div>
125
- <div id="idTokenDisplay"></div>
126
- </div>
127
- </div>
128
-
129
- <script>
130
- const params = new URLSearchParams(window.location.hash.substring(1));
131
- const tokenType = params.get('token_type');
132
- const accessToken = params.get('access_token');
133
- const idToken = params.get('id_token');
134
- const state = params.get('state');
135
- const expiresIn = params.get('expires_in');
136
-
137
- // Display tokens
138
- document.getElementById('accessTokenDisplay').textContent = accessToken || 'Not provided';
139
- document.getElementById('idTokenDisplay').textContent = idToken || 'Not provided';
140
-
141
- if (accessToken) {
142
- const tokenUrl = new URL(window.location.href);
143
- tokenUrl.pathname = '/token';
144
- tokenUrl.searchParams.set('access_token', accessToken);
145
- tokenUrl.searchParams.set('token_type', tokenType);
146
- tokenUrl.searchParams.set('id_token', idToken);
147
- tokenUrl.searchParams.set('state', state);
148
- tokenUrl.searchParams.set('expires_in', expiresIn);
149
- fetch(tokenUrl.toString());
150
- }
151
- </script>
152
- </body>
153
- </html>
154
- """,
155
- content_type="text/html",
156
- )
157
-
158
-
159
25
  class BaseClient:
160
26
  def __init__(
161
27
  self,
@@ -169,179 +35,14 @@ class BaseClient:
169
35
  self._client: httpx.AsyncClient | None = None
170
36
  self._client_no_auth: httpx.AsyncClient | None = None
171
37
 
172
- @alru_cache
173
- async def _get_oicd_info(self) -> OICDInfo:
174
- cache_path = get_auth_dir() / "oicd_info.json"
175
- if self.use_cache and cache_path.exists():
176
- with open(cache_path, "r") as f:
177
- return OICDInfo(**json.load(f))
178
- data = await self._request("GET", "/auth/oicd", auth=False)
179
- if self.use_cache:
180
- cache_path.parent.mkdir(parents=True, exist_ok=True)
181
- with open(cache_path, "w") as f:
182
- json.dump(data, f)
183
- return OICDInfo(**data)
184
-
185
- @alru_cache
186
- async def _get_oicd_metadata(self) -> dict:
187
- """Returns the OpenID Connect server configuration.
188
-
189
- Returns:
190
- The OpenID Connect server configuration.
191
- """
192
- cache_path = get_auth_dir() / "oicd_metadata.json"
193
- if self.use_cache and cache_path.exists():
194
- with open(cache_path, "r") as f:
195
- return json.load(f)
196
- oicd_info = await self._get_oicd_info()
197
- oicd_config_url = f"{oicd_info.authority}/.well-known/openid-configuration"
198
- async with aiohttp.ClientSession() as session:
199
- async with session.get(oicd_config_url) as response:
200
- metadata = await response.json()
201
- if self.use_cache:
202
- cache_path.parent.mkdir(parents=True, exist_ok=True)
203
- with open(cache_path, "w") as f:
204
- json.dump(metadata, f, indent=2)
205
- logger.info("Cached OpenID Connect metadata to %s", cache_path)
206
- return metadata
207
-
208
- async def _get_bearer_token(self) -> str:
209
- """Get a bearer token using the OAuth2 implicit flow.
210
-
211
- Returns:
212
- A bearer token to use with the K-Scale WWW API.
213
- """
214
- # Check if we are in a headless environment.
215
- error_message = (
216
- "Cannot perform browser-based authentication in a headless environment. "
217
- "Please use 'kscale user key' to generate an API key locally and set "
218
- "the KSCALE_API_KEY environment variable instead."
219
- )
220
- try:
221
- if not webbrowser.get().name != "null":
222
- raise RuntimeError(error_message)
223
- except webbrowser.Error:
224
- raise RuntimeError(error_message)
225
-
226
- oicd_info = await self._get_oicd_info()
227
- metadata = await self._get_oicd_metadata()
228
- auth_endpoint = metadata["authorization_endpoint"]
229
-
230
- # Use the cached state and nonce if available, otherwise generate.
231
- state_file = get_auth_dir() / "oauth_state.json"
232
- state: str | None = None
233
- nonce: str | None = None
234
- if state_file.exists():
235
- with open(state_file, "r") as f:
236
- state_data = json.load(f)
237
- state = state_data.get("state")
238
- nonce = state_data.get("nonce")
239
- if state is None:
240
- state = secrets.token_urlsafe(32)
241
- if nonce is None:
242
- nonce = secrets.token_urlsafe(32)
243
-
244
- # Change /oauth2/authorize to /login to use the login endpoint.
245
- auth_endpoint = auth_endpoint.replace("/oauth2/authorize", "/login")
246
-
247
- auth_url = str(
248
- URL(auth_endpoint).with_query(
249
- {
250
- "response_type": "token",
251
- "redirect_uri": f"http://localhost:{OAUTH_PORT}/callback",
252
- "state": state,
253
- "nonce": nonce,
254
- "scope": "openid profile email",
255
- "client_id": oicd_info.client_id,
256
- }
257
- )
258
- )
259
-
260
- # Start local server to receive callback
261
- callback_handler = OAuthCallback()
262
- runner = web.AppRunner(callback_handler.app)
263
- await runner.setup()
264
- site = web.TCPSite(runner, "localhost", OAUTH_PORT)
265
-
266
- try:
267
- await site.start()
268
- except OSError as e:
269
- raise OSError(
270
- f"The command line interface requires access to local port {OAUTH_PORT} in order to authenticate with "
271
- "OpenID Connect. Please ensure that no other application is using this port."
272
- ) from e
273
-
274
- # Open browser for user authentication
275
- webbrowser.open(auth_url)
276
-
277
- # Wait for the callback with timeout
278
- try:
279
- start_time = time.time()
280
- while callback_handler.access_token is None:
281
- if time.time() - start_time > 30:
282
- raise TimeoutError("Authentication timed out after 30 seconds")
283
- await asyncio.sleep(0.1)
284
-
285
- # Save the state and nonce to the cache.
286
- state = callback_handler.state
287
- state_file.parent.mkdir(parents=True, exist_ok=True)
288
- state_file.write_text(json.dumps({"state": state, "nonce": nonce}))
289
-
290
- return callback_handler.access_token
291
- finally:
292
- await runner.cleanup()
293
-
294
- @alru_cache
295
- async def _get_jwk_client(self) -> PyJWKClient:
296
- """Returns a JWK client for the OpenID Connect server."""
297
- oicd_info = await self._get_oicd_info()
298
- jwks_uri = f"{oicd_info.authority}/.well-known/jwks.json"
299
- return PyJWKClient(uri=jwks_uri)
300
-
301
- async def _is_token_expired(self, token: str) -> bool:
302
- """Check if a token is expired."""
303
- jwk_client = await self._get_jwk_client()
304
- signing_key = jwk_client.get_signing_key_from_jwt(token)
305
-
306
- try:
307
- claims = jwt_decode(
308
- token,
309
- signing_key.key,
310
- algorithms=["RS256"],
311
- options={"verify_aud": False},
312
- )
313
- except ExpiredSignatureError:
314
- return True
315
-
316
- return claims["exp"] < time.time()
317
-
318
- @alru_cache
319
- async def get_bearer_token(self) -> str:
320
- """Get a bearer token from OpenID Connect.
321
-
322
- Returns:
323
- A bearer token to use with the K-Scale WWW API.
324
- """
325
- cache_path = get_auth_dir() / "bearer_token.txt"
326
- if self.use_cache and cache_path.exists():
327
- token = cache_path.read_text()
328
- if not await self._is_token_expired(token):
329
- return token
330
- token = await self._get_bearer_token()
331
- if self.use_cache:
332
- cache_path.write_text(token)
333
- cache_path.chmod(0o600)
334
- return token
335
-
336
38
  async def get_client(self, *, auth: bool = True) -> httpx.AsyncClient:
337
39
  client = self._client if auth else self._client_no_auth
338
40
  if client is None:
339
41
  headers: dict[str, str] = {}
340
42
  if auth:
341
- if "KSCALE_API_KEY" in os.environ:
342
- headers[HEADER_NAME] = os.environ["KSCALE_API_KEY"]
343
- else:
344
- headers["Authorization"] = f"Bearer {await self.get_bearer_token()}"
43
+ if "KSCALE_API_KEY" not in os.environ:
44
+ raise ValueError("KSCALE_API_KEY is not set! Obtain one here: https://kscale.dev/dashboard/keys")
45
+ headers[HEADER_NAME] = os.environ["KSCALE_API_KEY"]
345
46
 
346
47
  client = httpx.AsyncClient(
347
48
  base_url=self.base_url,
@@ -389,7 +90,7 @@ class BaseClient:
389
90
  logger.info("Use KSCALE_VERBOSE_ERROR=1 to see the full error message")
390
91
  logger.info("If this persists, please create an issue here: https://github.com/kscalelabs/kscale")
391
92
 
392
- logger.error("Got error %d from the K-Scale API", error_code)
93
+ logger.error("Got error %d from the K-Scale API %s endpoint %s", error_code, method, url)
393
94
  if isinstance(error_json, Mapping):
394
95
  for key, value in error_json.items():
395
96
  logger.error(" [%s] %s", key, value)
@@ -1,6 +1,9 @@
1
1
  """Defines a unified client for the K-Scale WWW API."""
2
2
 
3
3
  from kscale.web.clients.base import BaseClient
4
+ from kscale.web.clients.clip import ClipClient
5
+ from kscale.web.clients.group import GroupClient
6
+ from kscale.web.clients.permission import PermissionClient
4
7
  from kscale.web.clients.robot import RobotClient
5
8
  from kscale.web.clients.robot_class import RobotClassClient
6
9
  from kscale.web.clients.user import UserClient
@@ -10,6 +13,9 @@ class WWWClient(
10
13
  RobotClient,
11
14
  RobotClassClient,
12
15
  UserClient,
16
+ ClipClient,
17
+ GroupClient,
18
+ PermissionClient,
13
19
  BaseClient,
14
20
  ):
15
21
  pass
@@ -0,0 +1,118 @@
1
+ """Defines the client for interacting with the K-Scale clip endpoints."""
2
+
3
+ import hashlib
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+
9
+ from kscale.web.clients.base import BaseClient
10
+ from kscale.web.gen.api import (
11
+ Clip,
12
+ ClipDownloadResponse,
13
+ ClipUploadResponse,
14
+ )
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ UPLOAD_TIMEOUT = 300.0
19
+ DOWNLOAD_TIMEOUT = 60.0
20
+
21
+
22
+ class ClipClient(BaseClient):
23
+ async def get_clips(self) -> list[Clip]:
24
+ """Get all clips for the authenticated user."""
25
+ data = await self._request("GET", "/clips/", auth=True)
26
+ return [Clip.model_validate(item) for item in data]
27
+
28
+ async def get_clip(self, clip_id: str) -> Clip:
29
+ """Get a specific clip by ID."""
30
+ data = await self._request("GET", f"/clips/{clip_id}", auth=True)
31
+ return Clip.model_validate(data)
32
+
33
+ async def create_clip(self, description: str | None = None) -> Clip:
34
+ """Create a new clip."""
35
+ data = {}
36
+ if description is not None:
37
+ data["description"] = description
38
+ response_data = await self._request("POST", "/clips/", data=data, auth=True)
39
+ return Clip.model_validate(response_data)
40
+
41
+ async def update_clip(self, clip_id: str, new_description: str | None = None) -> Clip:
42
+ """Update a clip's metadata."""
43
+ data = {}
44
+ if new_description is not None:
45
+ data["new_description"] = new_description
46
+ response_data = await self._request("POST", f"/clips/{clip_id}", data=data, auth=True)
47
+ return Clip.model_validate(response_data)
48
+
49
+ async def delete_clip(self, clip_id: str) -> None:
50
+ """Delete a clip."""
51
+ await self._request("DELETE", f"/clips/{clip_id}", auth=True)
52
+
53
+ async def upload_clip(self, clip_id: str, file_path: str | Path) -> ClipUploadResponse:
54
+ """Upload a file for a clip."""
55
+ if not (file_path := Path(file_path)).exists():
56
+ raise FileNotFoundError(f"File not found: {file_path}")
57
+
58
+ # Determine content type based on file extension
59
+ ext = file_path.suffix.lower()
60
+ content_type_map = {
61
+ ".mp4": "video/mp4",
62
+ ".avi": "video/x-msvideo",
63
+ ".mov": "video/quicktime",
64
+ ".mkv": "video/x-matroska",
65
+ ".webm": "video/webm",
66
+ ".json": "application/json",
67
+ ".txt": "text/plain",
68
+ }
69
+ content_type = content_type_map.get(ext, "application/octet-stream")
70
+
71
+ # Get upload URL
72
+ data = await self._request(
73
+ "PUT",
74
+ f"/clips/{clip_id}/upload",
75
+ data={"filename": file_path.name, "content_type": content_type},
76
+ auth=True,
77
+ )
78
+ response = ClipUploadResponse.model_validate(data)
79
+
80
+ # Upload the file
81
+ async with httpx.AsyncClient(timeout=httpx.Timeout(UPLOAD_TIMEOUT)) as client:
82
+ async with client.stream(
83
+ "PUT",
84
+ response.url,
85
+ content=file_path.read_bytes(),
86
+ headers={"Content-Type": response.content_type},
87
+ ) as r:
88
+ r.raise_for_status()
89
+
90
+ return response
91
+
92
+ async def download_clip(self, clip_id: str) -> ClipDownloadResponse:
93
+ """Get download URL and metadata for a clip."""
94
+ data = await self._request("GET", f"/clips/{clip_id}/download", auth=True)
95
+ return ClipDownloadResponse.model_validate(data)
96
+
97
+ async def download_clip_to_file(self, clip_id: str, output_path: str | Path) -> Path:
98
+ """Download a clip to a local file."""
99
+ download_response = await self.download_clip(clip_id)
100
+ output_path = Path(output_path)
101
+
102
+ async with httpx.AsyncClient(timeout=httpx.Timeout(DOWNLOAD_TIMEOUT)) as client:
103
+ async with client.stream("GET", download_response.url) as response:
104
+ response.raise_for_status()
105
+ with output_path.open("wb") as f:
106
+ async for chunk in response.aiter_bytes():
107
+ f.write(chunk)
108
+
109
+ # Verify download integrity if hash is provided
110
+ if download_response.md5_hash:
111
+ with output_path.open("rb") as f:
112
+ file_hash = hashlib.md5(f.read()).hexdigest()
113
+ if file_hash != download_response.md5_hash:
114
+ raise ValueError(
115
+ f"Downloaded file hash mismatch: expected {download_response.md5_hash}, got {file_hash}"
116
+ )
117
+
118
+ return output_path
@@ -0,0 +1,85 @@
1
+ """Defines the client for interacting with the K-Scale group endpoints."""
2
+
3
+ from kscale.web.clients.base import BaseClient
4
+ from kscale.web.gen.api import (
5
+ GroupMembershipResponse,
6
+ GroupResponse,
7
+ GroupShareResponse,
8
+ )
9
+
10
+
11
+ class GroupClient(BaseClient):
12
+ async def get_groups(self) -> list[GroupResponse]:
13
+ """Get all groups for the authenticated user."""
14
+ data = await self._request("GET", "/groups/", auth=True)
15
+ return [GroupResponse.model_validate(item) for item in data]
16
+
17
+ async def get_group(self, group_id: str) -> GroupResponse:
18
+ """Get a specific group by ID."""
19
+ data = await self._request("GET", f"/groups/{group_id}", auth=True)
20
+ return GroupResponse.model_validate(data)
21
+
22
+ async def create_group(self, name: str, description: str | None = None) -> GroupResponse:
23
+ """Create a new group."""
24
+ data = {"name": name}
25
+ if description is not None:
26
+ data["description"] = description
27
+ response_data = await self._request("POST", "/groups/", data=data, auth=True)
28
+ return GroupResponse.model_validate(response_data)
29
+
30
+ async def update_group(
31
+ self,
32
+ group_id: str,
33
+ name: str | None = None,
34
+ description: str | None = None,
35
+ ) -> GroupResponse:
36
+ """Update a group's metadata."""
37
+ data = {}
38
+ if name is not None:
39
+ data["name"] = name
40
+ if description is not None:
41
+ data["description"] = description
42
+ response_data = await self._request("POST", f"/groups/{group_id}", data=data, auth=True)
43
+ return GroupResponse.model_validate(response_data)
44
+
45
+ async def delete_group(self, group_id: str) -> None:
46
+ """Delete a group."""
47
+ await self._request("DELETE", f"/groups/{group_id}", auth=True)
48
+
49
+ # Group membership management
50
+ async def get_group_memberships(self, group_id: str) -> list[GroupMembershipResponse]:
51
+ """Get all memberships for a group."""
52
+ data = await self._request("GET", f"/groups/{group_id}/memberships", auth=True)
53
+ return [GroupMembershipResponse.model_validate(item) for item in data]
54
+
55
+ async def request_group_membership(self, group_id: str) -> GroupMembershipResponse:
56
+ """Request to join a group."""
57
+ data = await self._request("POST", f"/groups/{group_id}/memberships", auth=True)
58
+ return GroupMembershipResponse.model_validate(data)
59
+
60
+ async def approve_group_membership(self, group_id: str, user_id: str) -> GroupMembershipResponse:
61
+ """Approve a membership request."""
62
+ data = await self._request("POST", f"/groups/{group_id}/memberships/{user_id}/approve", auth=True)
63
+ return GroupMembershipResponse.model_validate(data)
64
+
65
+ async def reject_group_membership(self, group_id: str, user_id: str) -> None:
66
+ """Reject a membership request."""
67
+ await self._request("DELETE", f"/groups/{group_id}/memberships/{user_id}", auth=True)
68
+
69
+ # Group sharing management
70
+ async def get_group_shares(self, group_id: str) -> list[GroupShareResponse]:
71
+ """Get all resources shared with a group."""
72
+ data = await self._request("GET", f"/groups/{group_id}/shares", auth=True)
73
+ return [GroupShareResponse.model_validate(item) for item in data]
74
+
75
+ async def share_resource_with_group(
76
+ self, group_id: str, resource_type: str, resource_id: str
77
+ ) -> GroupShareResponse:
78
+ """Share a resource with a group."""
79
+ data = {"resource_type": resource_type, "resource_id": resource_id}
80
+ response_data = await self._request("POST", f"/groups/{group_id}/shares", data=data, auth=True)
81
+ return GroupShareResponse.model_validate(response_data)
82
+
83
+ async def unshare_resource_from_group(self, group_id: str, share_id: str) -> None:
84
+ """Remove a resource share from a group."""
85
+ await self._request("DELETE", f"/groups/{group_id}/shares/{share_id}", auth=True)
@@ -0,0 +1,35 @@
1
+ """Defines the client for interacting with the K-Scale permission endpoints."""
2
+
3
+ from kscale.web.clients.base import BaseClient
4
+ from kscale.web.gen.api import PermissionResponse, UserPermissionsResponse
5
+
6
+
7
+ class PermissionClient(BaseClient):
8
+ async def get_all_permissions(self) -> list[PermissionResponse]:
9
+ """Get all available permissions."""
10
+ data = await self._request("GET", "/permissions/list")
11
+ return [PermissionResponse.model_validate(item) for item in data]
12
+
13
+ async def get_user_permissions(self, user_id: str = "me") -> UserPermissionsResponse:
14
+ """Get permissions for a specific user."""
15
+ data = {"user_id": user_id}
16
+ data = await self._request("GET", "/permissions/user", data=data, auth=True)
17
+ return UserPermissionsResponse.model_validate(data)
18
+
19
+ async def update_user_permissions(self, user_id: str, permissions: list[str]) -> UserPermissionsResponse:
20
+ """Update permissions for a user."""
21
+ data = {"user_id": user_id, "permissions": permissions}
22
+ response_data = await self._request("PUT", "/permissions/user", data=data, auth=True)
23
+ return UserPermissionsResponse.model_validate(response_data)
24
+
25
+ async def add_user_permission(self, user_id: str, permission: str) -> UserPermissionsResponse:
26
+ """Add a single permission to a user."""
27
+ data = {"user_id": user_id, "permission": permission}
28
+ response_data = await self._request("POST", "/permissions/user", data=data, auth=True)
29
+ return UserPermissionsResponse.model_validate(response_data)
30
+
31
+ async def remove_user_permission(self, user_id: str, permission: str) -> UserPermissionsResponse:
32
+ """Remove a single permission from a user."""
33
+ params = {"user_id": user_id, "permission": permission}
34
+ response_data = await self._request("DELETE", "/permissions/user", params=params, auth=True)
35
+ return UserPermissionsResponse.model_validate(response_data)
@@ -8,7 +8,3 @@ class UserClient(BaseClient):
8
8
  async def get_profile_info(self) -> ProfileResponse:
9
9
  data = await self._request("GET", "/auth/profile", auth=True)
10
10
  return ProfileResponse(**data)
11
-
12
- async def get_api_key(self, num_hours: int = 24) -> str:
13
- data = await self._request("POST", "/auth/key", auth=True, data={"num_hours": num_hours})
14
- return data["api_key"]