kscale 0.1.1__py3-none-any.whl → 0.1.3__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.1"
3
+ __version__ = "0.1.3"
4
4
 
5
5
  from pathlib import Path
6
6
 
kscale/conf.py CHANGED
@@ -22,6 +22,7 @@ def get_path() -> Path:
22
22
  class WWWSettings:
23
23
  api_root: str = field(default=DEFAULT_API_ROOT)
24
24
  cache_dir: str = field(default=II("oc.env:KSCALE_CACHE_DIR,'~/.kscale/cache/'"))
25
+ refresh_interval_minutes: int = field(default=60 * 24)
25
26
 
26
27
 
27
28
  @dataclass
@@ -29,14 +29,22 @@ OAUTH_PORT = 16821
29
29
 
30
30
  class OAuthCallback:
31
31
  def __init__(self) -> None:
32
+ self.token_type: str | None = None
32
33
  self.access_token: str | None = None
34
+ self.id_token: str | None = None
35
+ self.state: str | None = None
36
+ self.expires_in: str | None = None
33
37
  self.app = web.Application()
34
38
  self.app.router.add_get("/token", self.handle_token)
35
39
  self.app.router.add_get("/callback", self.handle_callback)
36
40
 
37
41
  async def handle_token(self, request: web.Request) -> web.Response:
38
42
  """Handle the token extraction."""
43
+ self.token_type = request.query.get("token_type")
39
44
  self.access_token = request.query.get("access_token")
45
+ self.id_token = request.query.get("id_token")
46
+ self.state = request.query.get("state")
47
+ self.expires_in = request.query.get("expires_in")
40
48
  return web.Response(text="OK")
41
49
 
42
50
  async def handle_callback(self, request: web.Request) -> web.Response:
@@ -45,62 +53,92 @@ class OAuthCallback:
45
53
  text="""
46
54
  <!DOCTYPE html>
47
55
  <html lang="en">
48
-
49
56
  <head>
50
57
  <meta charset="UTF-8">
51
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
52
58
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
53
59
  <title>Authentication successful</title>
54
60
  <style>
55
61
  body {
62
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
56
63
  display: flex;
57
64
  justify-content: center;
58
65
  align-items: center;
59
66
  min-height: 100vh;
60
67
  margin: 0;
61
- text-align: center;
68
+ background: #f5f5f5;
69
+ color: #333;
70
+ }
71
+ .container {
72
+ background: white;
73
+ padding: 2rem;
74
+ border-radius: 8px;
75
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
76
+ max-width: 600px;
77
+ width: 90%;
78
+ }
79
+ h1 {
80
+ color: #2c3e50;
81
+ margin-bottom: 1rem;
62
82
  }
63
- #content {
64
- padding: 20px;
83
+ .token-info {
84
+ background: #f8f9fa;
85
+ border: 1px solid #dee2e6;
86
+ border-radius: 4px;
87
+ padding: 1rem;
88
+ margin: 1rem 0;
89
+ word-break: break-all;
65
90
  }
66
- #closeNotification {
67
- display: none;
68
- padding: 10px 20px;
69
- margin-top: 20px;
70
- cursor: pointer;
71
- margin-left: auto;
72
- margin-right: auto;
91
+ .token-label {
92
+ font-weight: bold;
93
+ color: #6c757d;
94
+ margin-bottom: 0.5rem;
95
+ }
96
+ .success-icon {
97
+ color: #28a745;
98
+ font-size: 48px;
99
+ margin-bottom: 1rem;
73
100
  }
74
101
  </style>
75
102
  </head>
76
-
77
103
  <body>
78
- <div id="content">
104
+ <div class="container">
105
+ <div class="success-icon">✓</div>
79
106
  <h1>Authentication successful!</h1>
80
- <p>This window will close in <span id="countdown">3</span> seconds.</p>
81
- <p id="closeNotification" onclick="window.close()">Please close this window manually.</p>
107
+ <p>Your authentication tokens are shown below. You can now close this window.</p>
108
+
109
+ <div class="token-info">
110
+ <div class="token-label">Access Token:</div>
111
+ <div id="accessTokenDisplay"></div>
112
+ </div>
113
+
114
+ <div class="token-info">
115
+ <div class="token-label">ID Token:</div>
116
+ <div id="idTokenDisplay"></div>
117
+ </div>
82
118
  </div>
119
+
83
120
  <script>
84
121
  const params = new URLSearchParams(window.location.hash.substring(1));
85
- const token = params.get('access_token');
86
- if (token) {
87
- fetch('/token?access_token=' + token);
122
+ const tokenType = params.get('token_type');
123
+ const accessToken = params.get('access_token');
124
+ const idToken = params.get('id_token');
125
+ const state = params.get('state');
126
+ const expiresIn = params.get('expires_in');
127
+
128
+ // Display tokens
129
+ document.getElementById('accessTokenDisplay').textContent = accessToken || 'Not provided';
130
+ document.getElementById('idTokenDisplay').textContent = idToken || 'Not provided';
131
+
132
+ if (accessToken) {
133
+ const tokenUrl = new URL(window.location.href);
134
+ tokenUrl.pathname = '/token';
135
+ tokenUrl.searchParams.set('access_token', accessToken);
136
+ tokenUrl.searchParams.set('token_type', tokenType);
137
+ tokenUrl.searchParams.set('id_token', idToken);
138
+ tokenUrl.searchParams.set('state', state);
139
+ tokenUrl.searchParams.set('expires_in', expiresIn);
140
+ fetch(tokenUrl.toString());
88
141
  }
89
-
90
- let timeLeft = 3;
91
- const countdownElement = document.getElementById('countdown');
92
- const closeNotification = document.getElementById('closeNotification');
93
- const timer = setInterval(() => {
94
- timeLeft--;
95
- countdownElement.textContent = timeLeft;
96
- if (timeLeft <= 0) {
97
- clearInterval(timer);
98
- window.close();
99
- setTimeout(() => {
100
- closeNotification.style.display = 'block';
101
- }, 500);
102
- }
103
- }, 1000);
104
142
  </script>
105
143
  </body>
106
144
  </html>
@@ -167,8 +205,23 @@ class BaseClient:
167
205
  oicd_info = await self._get_oicd_info()
168
206
  metadata = await self._get_oicd_metadata()
169
207
  auth_endpoint = metadata["authorization_endpoint"]
170
- state = secrets.token_urlsafe(32)
171
- nonce = secrets.token_urlsafe(32)
208
+
209
+ # Use the cached state and nonce if available, otherwise generate.
210
+ state_file = get_cache_dir() / "oauth_state.json"
211
+ state: str | None = None
212
+ nonce: str | None = None
213
+ if state_file.exists():
214
+ with open(state_file, "r") as f:
215
+ state_data = json.load(f)
216
+ state = state_data.get("state")
217
+ nonce = state_data.get("nonce")
218
+ if state is None:
219
+ state = secrets.token_urlsafe(32)
220
+ if nonce is None:
221
+ nonce = secrets.token_urlsafe(32)
222
+
223
+ # Change /oauth2/authorize to /login to use the login endpoint.
224
+ auth_endpoint = auth_endpoint.replace("/oauth2/authorize", "/login")
172
225
 
173
226
  auth_url = str(
174
227
  URL(auth_endpoint).with_query(
@@ -208,6 +261,11 @@ class BaseClient:
208
261
  raise TimeoutError("Authentication timed out after 30 seconds")
209
262
  await asyncio.sleep(0.1)
210
263
 
264
+ # Save the state and nonce to the cache.
265
+ state = callback_handler.state
266
+ state_file.parent.mkdir(parents=True, exist_ok=True)
267
+ state_file.write_text(json.dumps({"state": state, "nonce": nonce}))
268
+
211
269
  return callback_handler.access_token
212
270
  finally:
213
271
  await runner.cleanup()
@@ -1,14 +1,19 @@
1
1
  """Defines the client for interacting with the K-Scale robot class endpoints."""
2
2
 
3
3
  import hashlib
4
+ import json
4
5
  import logging
5
6
  from pathlib import Path
6
7
 
7
8
  import httpx
8
9
 
9
10
  from kscale.web.clients.base import BaseClient
10
- from kscale.web.gen.api import RobotClass, RobotDownloadURDFResponse, RobotUploadURDFResponse
11
- from kscale.web.utils import get_cache_dir
11
+ from kscale.web.gen.api import (
12
+ RobotClass,
13
+ RobotDownloadURDFResponse,
14
+ RobotUploadURDFResponse,
15
+ )
16
+ from kscale.web.utils import get_cache_dir, should_refresh_file
12
17
 
13
18
  logger = logging.getLogger(__name__)
14
19
 
@@ -94,13 +99,22 @@ class RobotClassClient(BaseClient):
94
99
 
95
100
  async def download_robot_class_urdf(self, class_name: str, *, cache: bool = True) -> Path:
96
101
  cache_path = get_cache_dir() / class_name / "robot.tgz"
97
- if cache and cache_path.exists():
102
+ if cache and cache_path.exists() and not should_refresh_file(cache_path):
98
103
  return cache_path
99
104
  data = await self._request("GET", f"/robots/urdf/{class_name}", auth=True)
100
105
  response = RobotDownloadURDFResponse.model_validate(data)
101
106
  expected_hash = response.md5_hash
102
107
  cache_path.parent.mkdir(parents=True, exist_ok=True)
103
108
 
109
+ # Checks the md5 hash of the file.
110
+ cache_path_info = cache_path.parent / "info.json"
111
+ if cache_path_info.exists():
112
+ with open(cache_path_info, "r") as f:
113
+ info = json.load(f)
114
+ if info["md5_hash"] == expected_hash:
115
+ cache_path.touch()
116
+ return cache_path
117
+
104
118
  logger.info("Downloading URDF file from %s", response.url)
105
119
  async with httpx.AsyncClient(
106
120
  timeout=httpx.Timeout(DOWNLOAD_TIMEOUT),
@@ -118,4 +132,10 @@ class RobotClassClient(BaseClient):
118
132
  if hash_value_hex != expected_hash:
119
133
  raise ValueError(f"MD5 hash mismatch: {hash_value_hex} != {expected_hash}")
120
134
 
135
+ # Updates the info file.
136
+ logger.info("Updating downloaded file information")
137
+ info = {"md5_hash": hash_value_hex}
138
+ with open(cache_path_info, "w") as f:
139
+ json.dump(info, f)
140
+
121
141
  return cache_path
kscale/web/utils.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import functools
4
4
  import logging
5
+ import time
5
6
  from pathlib import Path
6
7
 
7
8
  from kscale.conf import Settings
@@ -17,6 +18,11 @@ def get_cache_dir() -> Path:
17
18
  return Path(Settings.load().www.cache_dir).expanduser().resolve()
18
19
 
19
20
 
21
+ def should_refresh_file(file: Path) -> bool:
22
+ """Returns whether the file should be refreshed."""
23
+ return file.exists() and file.stat().st_mtime < time.time() - Settings.load().www.refresh_interval_minutes * 60
24
+
25
+
20
26
  @functools.lru_cache
21
27
  def get_artifact_dir(artifact_id: str) -> Path:
22
28
  """Returns the directory for a specific artifact."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: kscale
3
- Version: 0.1.1
3
+ Version: 0.1.3
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=VTe_Q7u82mwbBYPfRHuUZc5OSlkHHxN5JxTCKvZnASU,172
1
+ kscale/__init__.py,sha256=jCPBaF41wGta1VKhadMOzjzqpwaKVoU5lWY21BAvCZY,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=i5gDTs8D73l2OvX4pOlPWtT2PC26MmoKw3PPwf8HM7U,1526
4
+ kscale/conf.py,sha256=x9jJ8pmSweUsLScN741B21SySCrnRZioV-1ZkhmERbI,1585
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
@@ -14,23 +14,23 @@ 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
16
  kscale/web/api.py,sha256=YioIdruq7LCKSBf9SvOGkv914W36_zBmpTzsJqKc0wE,439
17
- kscale/web/utils.py,sha256=KFB9lrgn_2BRY38Sfbb_QOKZ8fWyINqIqLPwN0rjbyk,806
17
+ kscale/web/utils.py,sha256=l_m8GETCThhnXbHfXq53j1b-E__uZ5GuUbsQ8HR0WA0,1037
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
22
  kscale/web/cli/user.py,sha256=qO0z2K5uA48hEiOOYEzv6BO2nOlCpITTDZFuiNl6d34,817
23
23
  kscale/web/clients/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
- kscale/web/clients/base.py,sha256=NRqRH2JTXjJLUMDM93txKgwFu9bQRYhCYLYy7vE76Ik,11462
24
+ kscale/web/clients/base.py,sha256=voOgOGlrYy-fyFJiLVNVwL4osOo53-ARBsqdhBp4BWA,14263
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=skk0XccQGij8VhmeZORjp0UMhWwzromkd6qCf108V7U,4406
27
+ kscale/web/clients/robot_class.py,sha256=yP-GBToyTyNc9eZzsoVmQuNC98aQHmgScxwsxRiM2v8,5067
28
28
  kscale/web/clients/user.py,sha256=9iv8J-ROm_yBIwi-0oqldReLkNBFktdHRv3UCOxBzjY,377
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.1.dist-info/LICENSE,sha256=HCN2bImAzUOXldAZZI7JZ9PYq6OwMlDAP_PpX1HnuN0,1071
32
- kscale-0.1.1.dist-info/METADATA,sha256=8N3og_O5jjo8JokdIJrjLFrQEkPj8d413-A5iTbceiA,2340
33
- kscale-0.1.1.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
34
- kscale-0.1.1.dist-info/entry_points.txt,sha256=N_0pCpPnwGDYVzOeuaSOrbJkS5L3lS9d8CxpJF1f8UI,62
35
- kscale-0.1.1.dist-info/top_level.txt,sha256=C2ynjYwopg6YjgttnI2dJjasyq3EKNmYp-IfQg9Xms4,7
36
- kscale-0.1.1.dist-info/RECORD,,
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,,
File without changes