kscale 0.1.3__tar.gz → 0.1.5__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. {kscale-0.1.3/kscale.egg-info → kscale-0.1.5}/PKG-INFO +1 -1
  2. {kscale-0.1.3 → kscale-0.1.5}/kscale/__init__.py +1 -1
  3. {kscale-0.1.3 → kscale-0.1.5}/kscale/conf.py +1 -1
  4. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/api.py +4 -0
  5. kscale-0.1.5/kscale/web/cli/user.py +53 -0
  6. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/clients/base.py +29 -6
  7. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/clients/robot_class.py +7 -3
  8. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/clients/user.py +4 -0
  9. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/utils.py +12 -10
  10. {kscale-0.1.3 → kscale-0.1.5/kscale.egg-info}/PKG-INFO +1 -1
  11. kscale-0.1.3/kscale/web/cli/user.py +0 -33
  12. {kscale-0.1.3 → kscale-0.1.5}/LICENSE +0 -0
  13. {kscale-0.1.3 → kscale-0.1.5}/MANIFEST.in +0 -0
  14. {kscale-0.1.3 → kscale-0.1.5}/README.md +0 -0
  15. {kscale-0.1.3 → kscale-0.1.5}/kscale/api.py +0 -0
  16. {kscale-0.1.3 → kscale-0.1.5}/kscale/artifacts/__init__.py +0 -0
  17. {kscale-0.1.3 → kscale-0.1.5}/kscale/artifacts/plane.obj +0 -0
  18. {kscale-0.1.3 → kscale-0.1.5}/kscale/artifacts/plane.urdf +0 -0
  19. {kscale-0.1.3 → kscale-0.1.5}/kscale/cli.py +0 -0
  20. {kscale-0.1.3 → kscale-0.1.5}/kscale/py.typed +0 -0
  21. {kscale-0.1.3 → kscale-0.1.5}/kscale/requirements-dev.txt +0 -0
  22. {kscale-0.1.3 → kscale-0.1.5}/kscale/requirements.txt +0 -0
  23. {kscale-0.1.3 → kscale-0.1.5}/kscale/utils/__init__.py +0 -0
  24. {kscale-0.1.3 → kscale-0.1.5}/kscale/utils/api_base.py +0 -0
  25. {kscale-0.1.3 → kscale-0.1.5}/kscale/utils/checksum.py +0 -0
  26. {kscale-0.1.3 → kscale-0.1.5}/kscale/utils/cli.py +0 -0
  27. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/__init__.py +0 -0
  28. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/cli/__init__.py +0 -0
  29. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/cli/robot.py +0 -0
  30. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/cli/robot_class.py +0 -0
  31. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/cli/token.py +0 -0
  32. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/clients/__init__.py +0 -0
  33. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/clients/client.py +0 -0
  34. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/clients/robot.py +0 -0
  35. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/gen/__init__.py +0 -0
  36. {kscale-0.1.3 → kscale-0.1.5}/kscale/web/gen/api.py +0 -0
  37. {kscale-0.1.3 → kscale-0.1.5}/kscale.egg-info/SOURCES.txt +0 -0
  38. {kscale-0.1.3 → kscale-0.1.5}/kscale.egg-info/dependency_links.txt +0 -0
  39. {kscale-0.1.3 → kscale-0.1.5}/kscale.egg-info/entry_points.txt +0 -0
  40. {kscale-0.1.3 → kscale-0.1.5}/kscale.egg-info/not-zip-safe +0 -0
  41. {kscale-0.1.3 → kscale-0.1.5}/kscale.egg-info/requires.txt +0 -0
  42. {kscale-0.1.3 → kscale-0.1.5}/kscale.egg-info/top_level.txt +0 -0
  43. {kscale-0.1.3 → kscale-0.1.5}/pyproject.toml +0 -0
  44. {kscale-0.1.3 → kscale-0.1.5}/setup.cfg +0 -0
  45. {kscale-0.1.3 → kscale-0.1.5}/setup.py +0 -0
  46. {kscale-0.1.3 → kscale-0.1.5}/tests/test_dummy.py +0 -0
@@ -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,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
 
@@ -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
 
@@ -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)
@@ -0,0 +1,53 @@
1
+ """Defines the CLI for getting information about the current user."""
2
+
3
+ import logging
4
+
5
+ import click
6
+ from tabulate import tabulate
7
+
8
+ from kscale.utils.cli import coro
9
+ from kscale.web.clients.user import UserClient
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @click.group()
15
+ def cli() -> None:
16
+ """Get information about the currently-authenticated user."""
17
+ pass
18
+
19
+
20
+ @cli.command()
21
+ @coro
22
+ async def me() -> None:
23
+ """Get information about the currently-authenticated user."""
24
+ client = UserClient()
25
+ profile = await client.get_profile_info()
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"))
50
+
51
+
52
+ if __name__ == "__main__":
53
+ cli()
@@ -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"]
@@ -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,33 +0,0 @@
1
- """Defines the CLI for getting information about the current user."""
2
-
3
- import logging
4
-
5
- import click
6
-
7
- from kscale.utils.cli import coro
8
- from kscale.web.clients.user import UserClient
9
-
10
- logger = logging.getLogger(__name__)
11
-
12
-
13
- @click.group()
14
- def cli() -> None:
15
- """Get information about the currently-authenticated user."""
16
- pass
17
-
18
-
19
- @cli.command()
20
- @coro
21
- async def me() -> None:
22
- client = UserClient()
23
- 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)
30
-
31
-
32
- if __name__ == "__main__":
33
- cli()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes