kscale 0.3.16__py3-none-any.whl → 0.3.17__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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """Defines the common interface for the K-Scale Python API."""
2
2
 
3
- __version__ = "0.3.16"
3
+ __version__ = "0.3.17"
4
4
 
5
5
  from pathlib import Path
6
6
 
@@ -192,7 +192,7 @@ async def run_pybullet(
192
192
  ) -> None:
193
193
  """Shows the URDF file for a robot class in PyBullet."""
194
194
  try:
195
- import pybullet as p
195
+ import pybullet as p # noqa: PLC0415
196
196
  except ImportError:
197
197
  click.echo(click.style("PyBullet is not installed; install it with `pip install pybullet`", fg="red"))
198
198
  return
@@ -453,14 +453,14 @@ async def run_mujoco(class_name: str, scene: str, no_cache: bool) -> None:
453
453
  launches the Mujoco viewer using the provided MJCF file.
454
454
  """
455
455
  try:
456
- from mujoco_scenes.errors import TemplateNotFoundError
457
- from mujoco_scenes.mjcf import list_scenes, load_mjmodel
456
+ from mujoco_scenes.errors import TemplateNotFoundError # noqa: PLC0415
457
+ from mujoco_scenes.mjcf import list_scenes, load_mjmodel # noqa: PLC0415
458
458
  except ImportError:
459
459
  click.echo(click.style("Mujoco Scenes is required; install with `pip install mujoco-scenes`", fg="red"))
460
460
  return
461
461
 
462
462
  try:
463
- import mujoco.viewer
463
+ import mujoco.viewer # noqa: PLC0415
464
464
  except ImportError:
465
465
  click.echo(click.style("Mujoco is required; install with `pip install mujoco`", fg="red"))
466
466
  return
kscale/web/cli/user.py CHANGED
@@ -39,14 +39,5 @@ async def me() -> None:
39
39
  )
40
40
 
41
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(f"API key: {click.style(api_key, fg='green')}")
49
-
50
-
51
42
  if __name__ == "__main__":
52
43
  cli()
@@ -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.gen.api import OICDInfo
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
- headers[HEADER_NAME] = os.environ["KSCALE_API_KEY"]
343
- else:
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,
@@ -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"]
kscale/web/gen/api.py CHANGED
@@ -2,15 +2,30 @@
2
2
 
3
3
  # generated by datamodel-codegen:
4
4
  # filename: openapi.json
5
- # timestamp: 2025-05-24T14:48:07+00:00
5
+ # timestamp: 2025-07-10T20:02:21+00:00
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from datetime import datetime
9
10
  from typing import Dict, List, Optional, Union
10
11
 
11
12
  from pydantic import BaseModel, Field
12
13
 
13
14
 
15
+ class APIKey(BaseModel):
16
+ id: str = Field(..., title="Id")
17
+ user_id: str = Field(..., title="User Id")
18
+ name: str = Field(..., title="Name")
19
+ key_hash: str = Field(..., title="Key Hash")
20
+ permissions: List[str] = Field(..., title="Permissions")
21
+ email: str = Field(..., title="Email")
22
+ email_verified: bool = Field(..., title="Email Verified")
23
+ created_at: datetime = Field(..., title="Created At")
24
+ last_used_at: Optional[datetime] = Field(None, title="Last Used At")
25
+ expires_at: Optional[datetime] = Field(None, title="Expires At")
26
+ is_active: Optional[bool] = Field(True, title="Is Active")
27
+
28
+
14
29
  class APIKeyRequest(BaseModel):
15
30
  num_hours: Optional[int] = Field(24, title="Num Hours")
16
31
 
@@ -19,6 +34,16 @@ class APIKeyResponse(BaseModel):
19
34
  api_key: str = Field(..., title="Api Key")
20
35
 
21
36
 
37
+ class APIKeySummaryResponse(BaseModel):
38
+ id: str = Field(..., title="Id")
39
+ name: str = Field(..., title="Name")
40
+ permissions: List[str] = Field(..., title="Permissions")
41
+ created_at: datetime = Field(..., title="Created At")
42
+ last_used_at: Optional[datetime] = Field(..., title="Last Used At")
43
+ expires_at: Optional[datetime] = Field(..., title="Expires At")
44
+ is_active: bool = Field(..., title="Is Active")
45
+
46
+
22
47
  class ActuatorMetadataInput(BaseModel):
23
48
  actuator_type: Optional[str] = Field(None, title="Actuator Type")
24
49
  sysid: Optional[str] = Field(None, title="Sysid")
@@ -62,6 +87,45 @@ class AddRobotRequest(BaseModel):
62
87
  class_name: str = Field(..., title="Class Name")
63
88
 
64
89
 
90
+ class Agent(BaseModel):
91
+ id: str = Field(..., title="Id")
92
+ user_id: str = Field(..., title="User Id")
93
+ upload_time: str = Field(..., title="Upload Time")
94
+ description: Optional[str] = Field(None, title="Description")
95
+ num_downloads: Optional[int] = Field(0, title="Num Downloads")
96
+
97
+
98
+ class AgentDownloadResponse(BaseModel):
99
+ url: str = Field(..., title="Url")
100
+ md5_hash: str = Field(..., title="Md5 Hash")
101
+
102
+
103
+ class AgentUploadRequest(BaseModel):
104
+ filename: str = Field(..., title="Filename")
105
+ content_type: str = Field(..., title="Content Type")
106
+
107
+
108
+ class AgentUploadResponse(BaseModel):
109
+ url: str = Field(..., title="Url")
110
+ filename: str = Field(..., title="Filename")
111
+ content_type: str = Field(..., title="Content Type")
112
+
113
+
114
+ class CreateAPIKeyRequest(BaseModel):
115
+ name: str = Field(..., title="Name")
116
+ permissions: List[str] = Field(..., title="Permissions")
117
+ expires_at: Optional[datetime] = Field(None, title="Expires At")
118
+
119
+
120
+ class CreateAPIKeyResponse(BaseModel):
121
+ api_key: APIKey
122
+ plain_key: str = Field(..., title="Plain Key")
123
+
124
+
125
+ class CreateAgentRequest(BaseModel):
126
+ description: Optional[str] = Field(None, title="Description")
127
+
128
+
65
129
  class JointMetadataInput(BaseModel):
66
130
  id: Optional[int] = Field(None, title="Id")
67
131
  kp: Optional[Union[float, str]] = Field(None, title="Kp")
@@ -92,7 +156,7 @@ class JointMetadataOutput(BaseModel):
92
156
  max_angle_deg: Optional[str] = Field(None, title="Max Angle Deg")
93
157
 
94
158
 
95
- class OICDInfo(BaseModel):
159
+ class OIDCInfo(BaseModel):
96
160
  authority: str = Field(..., title="Authority")
97
161
  client_id: str = Field(..., title="Client Id")
98
162
 
@@ -145,6 +209,14 @@ class RobotUploadURDFResponse(BaseModel):
145
209
  content_type: str = Field(..., title="Content Type")
146
210
 
147
211
 
212
+ class UpdateAPIKeyPermissionsRequest(BaseModel):
213
+ permissions: List[str] = Field(..., title="Permissions")
214
+
215
+
216
+ class UpdateAgentRequest(BaseModel):
217
+ description: Optional[str] = Field(None, title="Description")
218
+
219
+
148
220
  class UpdateRobotClassRequest(BaseModel):
149
221
  new_class_name: Optional[str] = Field(None, title="New Class Name")
150
222
  new_description: Optional[str] = Field(None, title="New Description")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kscale
3
- Version: 0.3.16
3
+ Version: 0.3.17
4
4
  Summary: The kscale project
5
5
  Home-page: https://github.com/kscalelabs/kscale
6
6
  Author: Benjamin Bolte
@@ -1,4 +1,4 @@
1
- kscale/__init__.py,sha256=5x-_pHWpOD5zG1UOxmRMHWvFPV0wM_MPn7MhE8xYFgg,201
1
+ kscale/__init__.py,sha256=6HuPRxAl_xLwIYPRTPkAJ6Hz0BgwBehLHoHw2OWOtyQ,201
2
2
  kscale/cli.py,sha256=JvaPtmWvF7s0D4I3K98eZAItf3oOi2ULsn5aPGxDcu4,795
3
3
  kscale/conf.py,sha256=dm35XSnzJp93St-ixVtYN4Nvqvb5upPGBrWkSI6Yb-4,1743
4
4
  kscale/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -15,19 +15,19 @@ kscale/web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  kscale/web/utils.py,sha256=Mme-FAQ0_zbjjOQeX8wyq8F4kL4i9fH7ytri16U6qOA,1046
16
16
  kscale/web/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  kscale/web/cli/robot.py,sha256=rI-A4_0uvJPeA71Apl4Z3mV5fIfWkgmzT9JRmJYxz3A,3307
18
- kscale/web/cli/robot_class.py,sha256=dOmB1z1l0bkSXnwgb4M6kbcJpHV4YAdT9l_po83C05w,19647
19
- kscale/web/cli/user.py,sha256=9IGsJBPyhjsmT04mZ2RGOs35ePfqB2RltYP0Ty5zF5o,1290
18
+ kscale/web/cli/robot_class.py,sha256=lmA0gxXiFTMoUtq6FBPwr8gRfM_hkAMJ7KOog5RUZII,19715
19
+ kscale/web/cli/user.py,sha256=SLugjzUkfhxBTd8-pLnyA2uTcuaX9RqmllVaR-rs9w8,1049
20
20
  kscale/web/clients/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
- kscale/web/clients/base.py,sha256=P5AZdoawNpocgx_8j5v0eauowG5suUf8SnnD7q1_gx0,16213
21
+ kscale/web/clients/base.py,sha256=bd2ifjLtMGIO7wQ4PHdFvc13EDSEO5GnwiiknwSjX8o,4119
22
22
  kscale/web/clients/client.py,sha256=rzW2s8T7bKVuybOSQ65-ghl02rcXBoOxnx_nUDwgEPw,362
23
23
  kscale/web/clients/robot.py,sha256=PI8HHkU-4Re9I5rLpp6dGbekRE-rBNVfXZxR_mO2MqE,1485
24
24
  kscale/web/clients/robot_class.py,sha256=LCKje6nNsDBeKDxZAGCO9vQXdyOwtx0woMmB5vDoUmE,7230
25
- kscale/web/clients/user.py,sha256=jsa1_s6qXRM-AGBbHlPhd1NierUtynjY9tVAPNr6_Os,568
25
+ kscale/web/clients/user.py,sha256=9iv8J-ROm_yBIwi-0oqldReLkNBFktdHRv3UCOxBzjY,377
26
26
  kscale/web/gen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
- kscale/web/gen/api.py,sha256=eV_IiF6-RhD7fd8naqKwK56bQtG1_-XVlr5PMpYr38E,7607
28
- kscale-0.3.16.dist-info/licenses/LICENSE,sha256=HCN2bImAzUOXldAZZI7JZ9PYq6OwMlDAP_PpX1HnuN0,1071
29
- kscale-0.3.16.dist-info/METADATA,sha256=cI35QIX6j-cVeWr_if6dv-5EQIneJk3jsqd_72Nph94,2350
30
- kscale-0.3.16.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
31
- kscale-0.3.16.dist-info/entry_points.txt,sha256=N_0pCpPnwGDYVzOeuaSOrbJkS5L3lS9d8CxpJF1f8UI,62
32
- kscale-0.3.16.dist-info/top_level.txt,sha256=C2ynjYwopg6YjgttnI2dJjasyq3EKNmYp-IfQg9Xms4,7
33
- kscale-0.3.16.dist-info/RECORD,,
27
+ kscale/web/gen/api.py,sha256=4xUbD_6lDrqB5jrgr1_SK9hAtU6t-EApFsazhxNiWmA,10101
28
+ kscale-0.3.17.dist-info/licenses/LICENSE,sha256=HCN2bImAzUOXldAZZI7JZ9PYq6OwMlDAP_PpX1HnuN0,1071
29
+ kscale-0.3.17.dist-info/METADATA,sha256=P-ZBO_i0zw_6G2kXGO2b8mnrtGMURjYl46RZbYtxJGc,2350
30
+ kscale-0.3.17.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
+ kscale-0.3.17.dist-info/entry_points.txt,sha256=N_0pCpPnwGDYVzOeuaSOrbJkS5L3lS9d8CxpJF1f8UI,62
32
+ kscale-0.3.17.dist-info/top_level.txt,sha256=C2ynjYwopg6YjgttnI2dJjasyq3EKNmYp-IfQg9Xms4,7
33
+ kscale-0.3.17.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.8.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5