kscale 0.1.3__py3-none-any.whl → 0.1.5__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
kscale/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Defines the common interface for the K-Scale Python API."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.5"
4
4
 
5
5
  from pathlib import Path
6
6
 
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
- cache_dir: str = field(default=II("oc.env:KSCALE_CACHE_DIR,'~/.kscale/cache/'"))
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
- logger.info("Email: %s", profile.email)
25
- logger.info("Email verified: %s", profile.email_verified)
26
- logger.info("User ID: %s", profile.user.user_id)
27
- logger.info("Is admin: %s", profile.user.is_admin)
28
- logger.info("Can upload: %s", profile.user.can_upload)
29
- logger.info("Can test: %s", profile.user.can_test)
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__":
@@ -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, get_cache_dir
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 = get_cache_dir() / "oicd_info.json"
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 = get_cache_dir() / "oicd_metadata.json"
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 = get_cache_dir() / "oauth_state.json"
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 = get_cache_dir() / "bearer_token.txt"
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={"Authorization": f"Bearer {await self.get_bearer_token()}"} if auth else None,
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 get_cache_dir, should_refresh_file
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 = get_cache_dir() / class_name / "robot.tgz"
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
- # Updates the info file.
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:
@@ -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 get_cache_dir() -> Path:
16
+ def get_kscale_dir() -> Path:
17
17
  """Returns the cache directory for artifacts."""
18
- return Path(Settings.load().www.cache_dir).expanduser().resolve()
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,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: kscale
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: The kscale project
5
5
  Home-page: https://github.com/kscalelabs/kscale
6
6
  Author: Benjamin Bolte
@@ -1,7 +1,7 @@
1
- kscale/__init__.py,sha256=jCPBaF41wGta1VKhadMOzjzqpwaKVoU5lWY21BAvCZY,172
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=x9jJ8pmSweUsLScN741B21SySCrnRZioV-1ZkhmERbI,1585
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=YioIdruq7LCKSBf9SvOGkv914W36_zBmpTzsJqKc0wE,439
17
- kscale/web/utils.py,sha256=l_m8GETCThhnXbHfXq53j1b-E__uZ5GuUbsQ8HR0WA0,1037
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=qO0z2K5uA48hEiOOYEzv6BO2nOlCpITTDZFuiNl6d34,817
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=voOgOGlrYy-fyFJiLVNVwL4osOo53-ARBsqdhBp4BWA,14263
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=yP-GBToyTyNc9eZzsoVmQuNC98aQHmgScxwsxRiM2v8,5067
28
- kscale/web/clients/user.py,sha256=9iv8J-ROm_yBIwi-0oqldReLkNBFktdHRv3UCOxBzjY,377
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.3.dist-info/LICENSE,sha256=HCN2bImAzUOXldAZZI7JZ9PYq6OwMlDAP_PpX1HnuN0,1071
32
- kscale-0.1.3.dist-info/METADATA,sha256=Aw4Q4bvhclIV1tG_1a8Gk-ZylIPe84vUUtIkQLXBa68,2340
33
- kscale-0.1.3.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
34
- kscale-0.1.3.dist-info/entry_points.txt,sha256=N_0pCpPnwGDYVzOeuaSOrbJkS5L3lS9d8CxpJF1f8UI,62
35
- kscale-0.1.3.dist-info/top_level.txt,sha256=C2ynjYwopg6YjgttnI2dJjasyq3EKNmYp-IfQg9Xms4,7
36
- kscale-0.1.3.dist-info/RECORD,,
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