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.
- kscale/__init__.py +1 -1
- kscale/cli.py +6 -0
- kscale/web/cli/clip.py +132 -0
- kscale/web/cli/group.py +241 -0
- kscale/web/cli/permission.py +110 -0
- kscale/web/cli/robot_class.py +4 -4
- kscale/web/cli/user.py +0 -11
- kscale/web/clients/base.py +5 -304
- kscale/web/clients/client.py +6 -0
- kscale/web/clients/clip.py +118 -0
- kscale/web/clients/group.py +85 -0
- kscale/web/clients/permission.py +35 -0
- kscale/web/clients/user.py +0 -4
- kscale/web/gen/api.py +173 -4
- {kscale-0.3.16.dist-info → kscale-0.3.18.dist-info}/METADATA +1 -1
- {kscale-0.3.16.dist-info → kscale-0.3.18.dist-info}/RECORD +20 -14
- {kscale-0.3.16.dist-info → kscale-0.3.18.dist-info}/WHEEL +1 -1
- {kscale-0.3.16.dist-info → kscale-0.3.18.dist-info}/entry_points.txt +0 -0
- {kscale-0.3.16.dist-info → kscale-0.3.18.dist-info}/licenses/LICENSE +0 -0
- {kscale-0.3.16.dist-info → kscale-0.3.18.dist-info}/top_level.txt +0 -0
kscale/web/clients/base.py
CHANGED
@@ -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.
|
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
|
-
|
343
|
-
|
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)
|
kscale/web/clients/client.py
CHANGED
@@ -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)
|
kscale/web/clients/user.py
CHANGED
@@ -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"]
|