kscale 0.1.3__py3-none-any.whl → 0.1.5__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/conf.py +1 -1
- kscale/web/api.py +4 -0
- kscale/web/cli/user.py +26 -6
- kscale/web/clients/base.py +29 -6
- kscale/web/clients/robot_class.py +7 -3
- kscale/web/clients/user.py +4 -0
- kscale/web/utils.py +12 -10
- {kscale-0.1.3.dist-info → kscale-0.1.5.dist-info}/METADATA +1 -1
- {kscale-0.1.3.dist-info → kscale-0.1.5.dist-info}/RECORD +14 -14
- {kscale-0.1.3.dist-info → kscale-0.1.5.dist-info}/LICENSE +0 -0
- {kscale-0.1.3.dist-info → kscale-0.1.5.dist-info}/WHEEL +0 -0
- {kscale-0.1.3.dist-info → kscale-0.1.5.dist-info}/entry_points.txt +0 -0
- {kscale-0.1.3.dist-info → kscale-0.1.5.dist-info}/top_level.txt +0 -0
kscale/__init__.py
CHANGED
kscale/conf.py
CHANGED
@@ -21,7 +21,7 @@ def get_path() -> Path:
|
|
21
21
|
@dataclass
|
22
22
|
class WWWSettings:
|
23
23
|
api_root: str = field(default=DEFAULT_API_ROOT)
|
24
|
-
|
24
|
+
base_dir: str = field(default=II("oc.env:KSCALE_DIR,'~/.kscale/'"))
|
25
25
|
refresh_interval_minutes: int = field(default=60 * 24)
|
26
26
|
|
27
27
|
|
kscale/web/api.py
CHANGED
@@ -12,3 +12,7 @@ class WebAPI(APIBase):
|
|
12
12
|
async def get_profile_info(self) -> ProfileResponse:
|
13
13
|
client = await self.www_client()
|
14
14
|
return await client.get_profile_info()
|
15
|
+
|
16
|
+
async def get_api_key(self, num_hours: int = 24) -> str:
|
17
|
+
client = await self.www_client()
|
18
|
+
return await client.get_api_key(num_hours)
|
kscale/web/cli/user.py
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
import logging
|
4
4
|
|
5
5
|
import click
|
6
|
+
from tabulate import tabulate
|
6
7
|
|
7
8
|
from kscale.utils.cli import coro
|
8
9
|
from kscale.web.clients.user import UserClient
|
@@ -19,14 +20,33 @@ def cli() -> None:
|
|
19
20
|
@cli.command()
|
20
21
|
@coro
|
21
22
|
async def me() -> None:
|
23
|
+
"""Get information about the currently-authenticated user."""
|
22
24
|
client = UserClient()
|
23
25
|
profile = await client.get_profile_info()
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
26
|
+
click.echo(
|
27
|
+
tabulate(
|
28
|
+
[
|
29
|
+
["Email", profile.email],
|
30
|
+
["Email verified", profile.email_verified],
|
31
|
+
["User ID", profile.user.user_id],
|
32
|
+
["Is admin", profile.user.is_admin],
|
33
|
+
["Can upload", profile.user.can_upload],
|
34
|
+
["Can test", profile.user.can_test],
|
35
|
+
],
|
36
|
+
headers=["Key", "Value"],
|
37
|
+
tablefmt="simple",
|
38
|
+
)
|
39
|
+
)
|
40
|
+
|
41
|
+
|
42
|
+
@cli.command()
|
43
|
+
@coro
|
44
|
+
async def key() -> None:
|
45
|
+
"""Get an API key for the currently-authenticated user."""
|
46
|
+
client = UserClient()
|
47
|
+
api_key = await client.get_api_key()
|
48
|
+
click.echo("API key:")
|
49
|
+
click.echo(click.style(api_key, fg="green"))
|
30
50
|
|
31
51
|
|
32
52
|
if __name__ == "__main__":
|
kscale/web/clients/base.py
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
import asyncio
|
4
4
|
import json
|
5
5
|
import logging
|
6
|
+
import os
|
6
7
|
import secrets
|
7
8
|
import time
|
8
9
|
import webbrowser
|
@@ -19,13 +20,16 @@ from pydantic import BaseModel
|
|
19
20
|
from yarl import URL
|
20
21
|
|
21
22
|
from kscale.web.gen.api import OICDInfo
|
22
|
-
from kscale.web.utils import DEFAULT_UPLOAD_TIMEOUT, get_api_root,
|
23
|
+
from kscale.web.utils import DEFAULT_UPLOAD_TIMEOUT, get_api_root, get_auth_dir
|
23
24
|
|
24
25
|
logger = logging.getLogger(__name__)
|
25
26
|
|
26
27
|
# This port matches the available port for the OAuth callback.
|
27
28
|
OAUTH_PORT = 16821
|
28
29
|
|
30
|
+
# This is the name of the API key header for the K-Scale WWW API.
|
31
|
+
HEADER_NAME = "x-kscale-api-key"
|
32
|
+
|
29
33
|
|
30
34
|
class OAuthCallback:
|
31
35
|
def __init__(self) -> None:
|
@@ -162,7 +166,7 @@ class BaseClient:
|
|
162
166
|
|
163
167
|
@alru_cache
|
164
168
|
async def _get_oicd_info(self) -> OICDInfo:
|
165
|
-
cache_path =
|
169
|
+
cache_path = get_auth_dir() / "oicd_info.json"
|
166
170
|
if self.use_cache and cache_path.exists():
|
167
171
|
with open(cache_path, "r") as f:
|
168
172
|
return OICDInfo(**json.load(f))
|
@@ -180,7 +184,7 @@ class BaseClient:
|
|
180
184
|
Returns:
|
181
185
|
The OpenID Connect server configuration.
|
182
186
|
"""
|
183
|
-
cache_path =
|
187
|
+
cache_path = get_auth_dir() / "oicd_metadata.json"
|
184
188
|
if self.use_cache and cache_path.exists():
|
185
189
|
with open(cache_path, "r") as f:
|
186
190
|
return json.load(f)
|
@@ -202,12 +206,24 @@ class BaseClient:
|
|
202
206
|
Returns:
|
203
207
|
A bearer token to use with the K-Scale WWW API.
|
204
208
|
"""
|
209
|
+
# Check if we are in a headless environment.
|
210
|
+
error_message = (
|
211
|
+
"Cannot perform browser-based authentication in a headless environment. "
|
212
|
+
"Please use 'kscale user key' to generate an API key locally and set "
|
213
|
+
"the KSCALE_API_KEY environment variable instead."
|
214
|
+
)
|
215
|
+
try:
|
216
|
+
if not webbrowser.get().name != "null":
|
217
|
+
raise RuntimeError(error_message)
|
218
|
+
except webbrowser.Error:
|
219
|
+
raise RuntimeError(error_message)
|
220
|
+
|
205
221
|
oicd_info = await self._get_oicd_info()
|
206
222
|
metadata = await self._get_oicd_metadata()
|
207
223
|
auth_endpoint = metadata["authorization_endpoint"]
|
208
224
|
|
209
225
|
# Use the cached state and nonce if available, otherwise generate.
|
210
|
-
state_file =
|
226
|
+
state_file = get_auth_dir() / "oauth_state.json"
|
211
227
|
state: str | None = None
|
212
228
|
nonce: str | None = None
|
213
229
|
if state_file.exists():
|
@@ -301,7 +317,7 @@ class BaseClient:
|
|
301
317
|
Returns:
|
302
318
|
A bearer token to use with the K-Scale WWW API.
|
303
319
|
"""
|
304
|
-
cache_path =
|
320
|
+
cache_path = get_auth_dir() / "bearer_token.txt"
|
305
321
|
if self.use_cache and cache_path.exists():
|
306
322
|
token = cache_path.read_text()
|
307
323
|
if not await self._is_token_expired(token):
|
@@ -315,9 +331,16 @@ class BaseClient:
|
|
315
331
|
async def get_client(self, *, auth: bool = True) -> httpx.AsyncClient:
|
316
332
|
client = self._client if auth else self._client_no_auth
|
317
333
|
if client is None:
|
334
|
+
headers: dict[str, str] = {}
|
335
|
+
if auth:
|
336
|
+
if "KSCALE_API_KEY" in os.environ:
|
337
|
+
headers[HEADER_NAME] = os.environ["KSCALE_API_KEY"]
|
338
|
+
else:
|
339
|
+
headers["Authorization"] = f"Bearer {await self.get_bearer_token()}"
|
340
|
+
|
318
341
|
client = httpx.AsyncClient(
|
319
342
|
base_url=self.base_url,
|
320
|
-
headers=
|
343
|
+
headers=headers,
|
321
344
|
timeout=httpx.Timeout(30.0),
|
322
345
|
)
|
323
346
|
if auth:
|
@@ -3,6 +3,7 @@
|
|
3
3
|
import hashlib
|
4
4
|
import json
|
5
5
|
import logging
|
6
|
+
import tarfile
|
6
7
|
from pathlib import Path
|
7
8
|
|
8
9
|
import httpx
|
@@ -13,7 +14,7 @@ from kscale.web.gen.api import (
|
|
13
14
|
RobotDownloadURDFResponse,
|
14
15
|
RobotUploadURDFResponse,
|
15
16
|
)
|
16
|
-
from kscale.web.utils import
|
17
|
+
from kscale.web.utils import get_robots_dir, should_refresh_file
|
17
18
|
|
18
19
|
logger = logging.getLogger(__name__)
|
19
20
|
|
@@ -98,7 +99,7 @@ class RobotClassClient(BaseClient):
|
|
98
99
|
return response
|
99
100
|
|
100
101
|
async def download_robot_class_urdf(self, class_name: str, *, cache: bool = True) -> Path:
|
101
|
-
cache_path =
|
102
|
+
cache_path = get_robots_dir() / class_name / "robot.tgz"
|
102
103
|
if cache and cache_path.exists() and not should_refresh_file(cache_path):
|
103
104
|
return cache_path
|
104
105
|
data = await self._request("GET", f"/robots/urdf/{class_name}", auth=True)
|
@@ -132,7 +133,10 @@ class RobotClassClient(BaseClient):
|
|
132
133
|
if hash_value_hex != expected_hash:
|
133
134
|
raise ValueError(f"MD5 hash mismatch: {hash_value_hex} != {expected_hash}")
|
134
135
|
|
135
|
-
|
136
|
+
logger.info("Unpacking downloaded file")
|
137
|
+
with tarfile.open(cache_path, "r:gz") as tar:
|
138
|
+
tar.extractall(path=cache_path.parent)
|
139
|
+
|
136
140
|
logger.info("Updating downloaded file information")
|
137
141
|
info = {"md5_hash": hash_value_hex}
|
138
142
|
with open(cache_path_info, "w") as f:
|
kscale/web/clients/user.py
CHANGED
@@ -8,3 +8,7 @@ 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"]
|
kscale/web/utils.py
CHANGED
@@ -13,9 +13,19 @@ DEFAULT_UPLOAD_TIMEOUT = 300.0 # 5 minutes
|
|
13
13
|
|
14
14
|
|
15
15
|
@functools.lru_cache
|
16
|
-
def
|
16
|
+
def get_kscale_dir() -> Path:
|
17
17
|
"""Returns the cache directory for artifacts."""
|
18
|
-
return Path(Settings.load().www.
|
18
|
+
return Path(Settings.load().www.base_dir).expanduser().resolve()
|
19
|
+
|
20
|
+
|
21
|
+
def get_auth_dir() -> Path:
|
22
|
+
"""Returns the directory for authentication artifacts."""
|
23
|
+
return get_kscale_dir() / "auth"
|
24
|
+
|
25
|
+
|
26
|
+
def get_robots_dir() -> Path:
|
27
|
+
"""Returns the directory for robot artifacts."""
|
28
|
+
return get_kscale_dir() / "robots"
|
19
29
|
|
20
30
|
|
21
31
|
def should_refresh_file(file: Path) -> bool:
|
@@ -23,14 +33,6 @@ def should_refresh_file(file: Path) -> bool:
|
|
23
33
|
return file.exists() and file.stat().st_mtime < time.time() - Settings.load().www.refresh_interval_minutes * 60
|
24
34
|
|
25
35
|
|
26
|
-
@functools.lru_cache
|
27
|
-
def get_artifact_dir(artifact_id: str) -> Path:
|
28
|
-
"""Returns the directory for a specific artifact."""
|
29
|
-
cache_dir = get_cache_dir() / artifact_id
|
30
|
-
cache_dir.mkdir(parents=True, exist_ok=True)
|
31
|
-
return cache_dir
|
32
|
-
|
33
|
-
|
34
36
|
@functools.lru_cache
|
35
37
|
def get_api_root() -> str:
|
36
38
|
"""Returns the root URL for the K-Scale WWW API."""
|
@@ -1,7 +1,7 @@
|
|
1
|
-
kscale/__init__.py,sha256=
|
1
|
+
kscale/__init__.py,sha256=SnUAXz1ZDSR98-rg2Ne7pAF7YPDh5i3v3EyoZxR3l-4,172
|
2
2
|
kscale/api.py,sha256=jmiuFurTN_Gj_-k-6asqxw8wp-_bgJUXgMPFgJ4lqHA,230
|
3
3
|
kscale/cli.py,sha256=PMHLKR5UwdbbReVmqHXpJ-K9-mGHv_0I7KQkwxmFcUA,881
|
4
|
-
kscale/conf.py,sha256=
|
4
|
+
kscale/conf.py,sha256=OLGz2J-9NAaICoWeN9-hCxrOsyf0vpJBNw-UzIsHdwE,1572
|
5
5
|
kscale/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
6
6
|
kscale/requirements-dev.txt,sha256=WI7-ea4IRJakmqVMN8QKhOsDGrghwtvk03aIsFaNSIw,130
|
7
7
|
kscale/requirements.txt,sha256=_BGbnKTQaXKx0bNEG0wguod9swsiCb2mF6rLm7sFJ2Q,214
|
@@ -13,24 +13,24 @@ kscale/utils/api_base.py,sha256=Kk_WtRDdJHmOg6NtHmVxVrcfARSUkhfr29ypLch_pO0,112
|
|
13
13
|
kscale/utils/checksum.py,sha256=jt6QmmQND9zrOEnUtOfZpLYROhgto4Gh3OpdUWk4tZA,1093
|
14
14
|
kscale/utils/cli.py,sha256=JoaY5x5SdUx97KmMM9j5AjRRUqqrTlJ9qVckZptEsYA,827
|
15
15
|
kscale/web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
16
|
-
kscale/web/api.py,sha256=
|
17
|
-
kscale/web/utils.py,sha256=
|
16
|
+
kscale/web/api.py,sha256=oyW0XLfX96RPe1xNgdf8ejfATdLlNlP0CL1lP0FN1nM,593
|
17
|
+
kscale/web/utils.py,sha256=Mme-FAQ0_zbjjOQeX8wyq8F4kL4i9fH7ytri16U6qOA,1046
|
18
18
|
kscale/web/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
19
19
|
kscale/web/cli/robot.py,sha256=rI-A4_0uvJPeA71Apl4Z3mV5fIfWkgmzT9JRmJYxz3A,3307
|
20
20
|
kscale/web/cli/robot_class.py,sha256=ymC5phUqofvOXv5P6f51b9lMK5eRDaavvnzS0x9rDbU,3574
|
21
21
|
kscale/web/cli/token.py,sha256=1rFC8MYKtqbNsQa2KIqwW1tqpaMtFaxuNsallwejXTU,787
|
22
|
-
kscale/web/cli/user.py,sha256=
|
22
|
+
kscale/web/cli/user.py,sha256=aaJJCL1P5lfhK6ZC9OwOHXKA-I3MWqVZ_k7TYnx33CY,1303
|
23
23
|
kscale/web/clients/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
24
|
-
kscale/web/clients/base.py,sha256=
|
24
|
+
kscale/web/clients/base.py,sha256=uovIxtkotRxrIFL0PhLYDIhhfXrIGjXxNY2FLVO3L18,15110
|
25
25
|
kscale/web/clients/client.py,sha256=QjBicdHQYNoUG9XRjAYmGu3THae9DzWa_hQox3OO1Gw,214
|
26
26
|
kscale/web/clients/robot.py,sha256=HMfJnkDxaJ_o7X2vdYYS9iob1JRoBG2qiGmQpCQZpAk,1485
|
27
|
-
kscale/web/clients/robot_class.py,sha256=
|
28
|
-
kscale/web/clients/user.py,sha256=
|
27
|
+
kscale/web/clients/robot_class.py,sha256=O_6lAKAcdNGFVtojuiQlgPCVP5MQlYPZG5Z-awwauho,5206
|
28
|
+
kscale/web/clients/user.py,sha256=jsa1_s6qXRM-AGBbHlPhd1NierUtynjY9tVAPNr6_Os,568
|
29
29
|
kscale/web/gen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
30
30
|
kscale/web/gen/api.py,sha256=SovcII36JFgK9jd2CXlLPMjiUROGB4vEnapOsYMUrkU,2188
|
31
|
-
kscale-0.1.
|
32
|
-
kscale-0.1.
|
33
|
-
kscale-0.1.
|
34
|
-
kscale-0.1.
|
35
|
-
kscale-0.1.
|
36
|
-
kscale-0.1.
|
31
|
+
kscale-0.1.5.dist-info/LICENSE,sha256=HCN2bImAzUOXldAZZI7JZ9PYq6OwMlDAP_PpX1HnuN0,1071
|
32
|
+
kscale-0.1.5.dist-info/METADATA,sha256=kTsVXOA7Zh2m_eqBm2iaxanfL92BNpPadsC-vasZ018,2340
|
33
|
+
kscale-0.1.5.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
34
|
+
kscale-0.1.5.dist-info/entry_points.txt,sha256=N_0pCpPnwGDYVzOeuaSOrbJkS5L3lS9d8CxpJF1f8UI,62
|
35
|
+
kscale-0.1.5.dist-info/top_level.txt,sha256=C2ynjYwopg6YjgttnI2dJjasyq3EKNmYp-IfQg9Xms4,7
|
36
|
+
kscale-0.1.5.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|