kscale 0.1.2__py3-none-any.whl → 0.1.4__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/web/api.py +4 -0
- kscale/web/cli/user.py +26 -6
- kscale/web/clients/base.py +110 -41
- kscale/web/clients/robot_class.py +12 -4
- kscale/web/clients/user.py +4 -0
- kscale/web/utils.py +11 -9
- {kscale-0.1.2.dist-info → kscale-0.1.4.dist-info}/METADATA +1 -1
- {kscale-0.1.2.dist-info → kscale-0.1.4.dist-info}/RECORD +13 -13
- {kscale-0.1.2.dist-info → kscale-0.1.4.dist-info}/LICENSE +0 -0
- {kscale-0.1.2.dist-info → kscale-0.1.4.dist-info}/WHEEL +0 -0
- {kscale-0.1.2.dist-info → kscale-0.1.4.dist-info}/entry_points.txt +0 -0
- {kscale-0.1.2.dist-info → kscale-0.1.4.dist-info}/top_level.txt +0 -0
kscale/__init__.py
CHANGED
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,24 +20,35 @@ 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:
|
36
|
+
self.token_type: str | None = None
|
32
37
|
self.access_token: str | None = None
|
38
|
+
self.id_token: str | None = None
|
39
|
+
self.state: str | None = None
|
40
|
+
self.expires_in: str | None = None
|
33
41
|
self.app = web.Application()
|
34
42
|
self.app.router.add_get("/token", self.handle_token)
|
35
43
|
self.app.router.add_get("/callback", self.handle_callback)
|
36
44
|
|
37
45
|
async def handle_token(self, request: web.Request) -> web.Response:
|
38
46
|
"""Handle the token extraction."""
|
47
|
+
self.token_type = request.query.get("token_type")
|
39
48
|
self.access_token = request.query.get("access_token")
|
49
|
+
self.id_token = request.query.get("id_token")
|
50
|
+
self.state = request.query.get("state")
|
51
|
+
self.expires_in = request.query.get("expires_in")
|
40
52
|
return web.Response(text="OK")
|
41
53
|
|
42
54
|
async def handle_callback(self, request: web.Request) -> web.Response:
|
@@ -45,62 +57,92 @@ class OAuthCallback:
|
|
45
57
|
text="""
|
46
58
|
<!DOCTYPE html>
|
47
59
|
<html lang="en">
|
48
|
-
|
49
60
|
<head>
|
50
61
|
<meta charset="UTF-8">
|
51
|
-
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
52
62
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
53
63
|
<title>Authentication successful</title>
|
54
64
|
<style>
|
55
65
|
body {
|
66
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
56
67
|
display: flex;
|
57
68
|
justify-content: center;
|
58
69
|
align-items: center;
|
59
70
|
min-height: 100vh;
|
60
71
|
margin: 0;
|
61
|
-
|
72
|
+
background: #f5f5f5;
|
73
|
+
color: #333;
|
74
|
+
}
|
75
|
+
.container {
|
76
|
+
background: white;
|
77
|
+
padding: 2rem;
|
78
|
+
border-radius: 8px;
|
79
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
80
|
+
max-width: 600px;
|
81
|
+
width: 90%;
|
82
|
+
}
|
83
|
+
h1 {
|
84
|
+
color: #2c3e50;
|
85
|
+
margin-bottom: 1rem;
|
62
86
|
}
|
63
|
-
|
64
|
-
|
87
|
+
.token-info {
|
88
|
+
background: #f8f9fa;
|
89
|
+
border: 1px solid #dee2e6;
|
90
|
+
border-radius: 4px;
|
91
|
+
padding: 1rem;
|
92
|
+
margin: 1rem 0;
|
93
|
+
word-break: break-all;
|
65
94
|
}
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
margin-
|
70
|
-
|
71
|
-
|
72
|
-
|
95
|
+
.token-label {
|
96
|
+
font-weight: bold;
|
97
|
+
color: #6c757d;
|
98
|
+
margin-bottom: 0.5rem;
|
99
|
+
}
|
100
|
+
.success-icon {
|
101
|
+
color: #28a745;
|
102
|
+
font-size: 48px;
|
103
|
+
margin-bottom: 1rem;
|
73
104
|
}
|
74
105
|
</style>
|
75
106
|
</head>
|
76
|
-
|
77
107
|
<body>
|
78
|
-
<div
|
108
|
+
<div class="container">
|
109
|
+
<div class="success-icon">✓</div>
|
79
110
|
<h1>Authentication successful!</h1>
|
80
|
-
<p>
|
81
|
-
|
111
|
+
<p>Your authentication tokens are shown below. You can now close this window.</p>
|
112
|
+
|
113
|
+
<div class="token-info">
|
114
|
+
<div class="token-label">Access Token:</div>
|
115
|
+
<div id="accessTokenDisplay"></div>
|
116
|
+
</div>
|
117
|
+
|
118
|
+
<div class="token-info">
|
119
|
+
<div class="token-label">ID Token:</div>
|
120
|
+
<div id="idTokenDisplay"></div>
|
121
|
+
</div>
|
82
122
|
</div>
|
123
|
+
|
83
124
|
<script>
|
84
125
|
const params = new URLSearchParams(window.location.hash.substring(1));
|
85
|
-
const
|
86
|
-
|
87
|
-
|
126
|
+
const tokenType = params.get('token_type');
|
127
|
+
const accessToken = params.get('access_token');
|
128
|
+
const idToken = params.get('id_token');
|
129
|
+
const state = params.get('state');
|
130
|
+
const expiresIn = params.get('expires_in');
|
131
|
+
|
132
|
+
// Display tokens
|
133
|
+
document.getElementById('accessTokenDisplay').textContent = accessToken || 'Not provided';
|
134
|
+
document.getElementById('idTokenDisplay').textContent = idToken || 'Not provided';
|
135
|
+
|
136
|
+
if (accessToken) {
|
137
|
+
const tokenUrl = new URL(window.location.href);
|
138
|
+
tokenUrl.pathname = '/token';
|
139
|
+
tokenUrl.searchParams.set('access_token', accessToken);
|
140
|
+
tokenUrl.searchParams.set('token_type', tokenType);
|
141
|
+
tokenUrl.searchParams.set('id_token', idToken);
|
142
|
+
tokenUrl.searchParams.set('state', state);
|
143
|
+
tokenUrl.searchParams.set('expires_in', expiresIn);
|
144
|
+
fetch(tokenUrl.toString());
|
88
145
|
}
|
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
146
|
</script>
|
105
147
|
</body>
|
106
148
|
</html>
|
@@ -124,7 +166,7 @@ class BaseClient:
|
|
124
166
|
|
125
167
|
@alru_cache
|
126
168
|
async def _get_oicd_info(self) -> OICDInfo:
|
127
|
-
cache_path =
|
169
|
+
cache_path = get_auth_dir() / "oicd_info.json"
|
128
170
|
if self.use_cache and cache_path.exists():
|
129
171
|
with open(cache_path, "r") as f:
|
130
172
|
return OICDInfo(**json.load(f))
|
@@ -142,7 +184,7 @@ class BaseClient:
|
|
142
184
|
Returns:
|
143
185
|
The OpenID Connect server configuration.
|
144
186
|
"""
|
145
|
-
cache_path =
|
187
|
+
cache_path = get_auth_dir() / "oicd_metadata.json"
|
146
188
|
if self.use_cache and cache_path.exists():
|
147
189
|
with open(cache_path, "r") as f:
|
148
190
|
return json.load(f)
|
@@ -167,8 +209,23 @@ class BaseClient:
|
|
167
209
|
oicd_info = await self._get_oicd_info()
|
168
210
|
metadata = await self._get_oicd_metadata()
|
169
211
|
auth_endpoint = metadata["authorization_endpoint"]
|
170
|
-
|
171
|
-
nonce
|
212
|
+
|
213
|
+
# Use the cached state and nonce if available, otherwise generate.
|
214
|
+
state_file = get_auth_dir() / "oauth_state.json"
|
215
|
+
state: str | None = None
|
216
|
+
nonce: str | None = None
|
217
|
+
if state_file.exists():
|
218
|
+
with open(state_file, "r") as f:
|
219
|
+
state_data = json.load(f)
|
220
|
+
state = state_data.get("state")
|
221
|
+
nonce = state_data.get("nonce")
|
222
|
+
if state is None:
|
223
|
+
state = secrets.token_urlsafe(32)
|
224
|
+
if nonce is None:
|
225
|
+
nonce = secrets.token_urlsafe(32)
|
226
|
+
|
227
|
+
# Change /oauth2/authorize to /login to use the login endpoint.
|
228
|
+
auth_endpoint = auth_endpoint.replace("/oauth2/authorize", "/login")
|
172
229
|
|
173
230
|
auth_url = str(
|
174
231
|
URL(auth_endpoint).with_query(
|
@@ -208,6 +265,11 @@ class BaseClient:
|
|
208
265
|
raise TimeoutError("Authentication timed out after 30 seconds")
|
209
266
|
await asyncio.sleep(0.1)
|
210
267
|
|
268
|
+
# Save the state and nonce to the cache.
|
269
|
+
state = callback_handler.state
|
270
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
271
|
+
state_file.write_text(json.dumps({"state": state, "nonce": nonce}))
|
272
|
+
|
211
273
|
return callback_handler.access_token
|
212
274
|
finally:
|
213
275
|
await runner.cleanup()
|
@@ -243,7 +305,7 @@ class BaseClient:
|
|
243
305
|
Returns:
|
244
306
|
A bearer token to use with the K-Scale WWW API.
|
245
307
|
"""
|
246
|
-
cache_path =
|
308
|
+
cache_path = get_auth_dir() / "bearer_token.txt"
|
247
309
|
if self.use_cache and cache_path.exists():
|
248
310
|
token = cache_path.read_text()
|
249
311
|
if not await self._is_token_expired(token):
|
@@ -257,9 +319,16 @@ class BaseClient:
|
|
257
319
|
async def get_client(self, *, auth: bool = True) -> httpx.AsyncClient:
|
258
320
|
client = self._client if auth else self._client_no_auth
|
259
321
|
if client is None:
|
322
|
+
headers: dict[str, str] = {}
|
323
|
+
if auth:
|
324
|
+
if "KSCALE_API_KEY" in os.environ:
|
325
|
+
headers[HEADER_NAME] = os.environ["KSCALE_API_KEY"]
|
326
|
+
else:
|
327
|
+
headers["Authorization"] = f"Bearer {await self.get_bearer_token()}"
|
328
|
+
|
260
329
|
client = httpx.AsyncClient(
|
261
330
|
base_url=self.base_url,
|
262
|
-
headers=
|
331
|
+
headers=headers,
|
263
332
|
timeout=httpx.Timeout(30.0),
|
264
333
|
)
|
265
334
|
if auth:
|
@@ -3,13 +3,18 @@
|
|
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
|
9
10
|
|
10
11
|
from kscale.web.clients.base import BaseClient
|
11
|
-
from kscale.web.gen.api import
|
12
|
-
|
12
|
+
from kscale.web.gen.api import (
|
13
|
+
RobotClass,
|
14
|
+
RobotDownloadURDFResponse,
|
15
|
+
RobotUploadURDFResponse,
|
16
|
+
)
|
17
|
+
from kscale.web.utils import get_robots_dir, should_refresh_file
|
13
18
|
|
14
19
|
logger = logging.getLogger(__name__)
|
15
20
|
|
@@ -94,7 +99,7 @@ class RobotClassClient(BaseClient):
|
|
94
99
|
return response
|
95
100
|
|
96
101
|
async def download_robot_class_urdf(self, class_name: str, *, cache: bool = True) -> Path:
|
97
|
-
cache_path =
|
102
|
+
cache_path = get_robots_dir() / class_name / "robot.tgz"
|
98
103
|
if cache and cache_path.exists() and not should_refresh_file(cache_path):
|
99
104
|
return cache_path
|
100
105
|
data = await self._request("GET", f"/robots/urdf/{class_name}", auth=True)
|
@@ -128,7 +133,10 @@ class RobotClassClient(BaseClient):
|
|
128
133
|
if hash_value_hex != expected_hash:
|
129
134
|
raise ValueError(f"MD5 hash mismatch: {hash_value_hex} != {expected_hash}")
|
130
135
|
|
131
|
-
|
136
|
+
logger.info("Unpacking downloaded file")
|
137
|
+
with tarfile.open(cache_path, "r:gz") as tar:
|
138
|
+
tar.extractall(path=cache_path.parent)
|
139
|
+
|
132
140
|
logger.info("Updating downloaded file information")
|
133
141
|
info = {"md5_hash": hash_value_hex}
|
134
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,24 +13,26 @@ 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
18
|
return Path(Settings.load().www.cache_dir).expanduser().resolve()
|
19
19
|
|
20
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"
|
29
|
+
|
30
|
+
|
21
31
|
def should_refresh_file(file: Path) -> bool:
|
22
32
|
"""Returns whether the file should be refreshed."""
|
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,4 +1,4 @@
|
|
1
|
-
kscale/__init__.py,sha256=
|
1
|
+
kscale/__init__.py,sha256=yhQJ7pWCzruLSpCCQHN706gdrMsRyf9UFGe9NRP-QPM,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
4
|
kscale/conf.py,sha256=x9jJ8pmSweUsLScN741B21SySCrnRZioV-1ZkhmERbI,1585
|
@@ -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=kZcOYw5oa1vDe2wNn8ZaHRGhTElIJ8tSq-vVhtY9dzM,1047
|
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=eHdHH6uYLLN7lKRU78JiyOxtzflvYwQ9f7oxYlDwmK4,14594
|
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.4.dist-info/LICENSE,sha256=HCN2bImAzUOXldAZZI7JZ9PYq6OwMlDAP_PpX1HnuN0,1071
|
32
|
+
kscale-0.1.4.dist-info/METADATA,sha256=BIuP-bpb_ed4i0R1LeQ1UAU5UUst3eU3G4E1FkrrT2k,2340
|
33
|
+
kscale-0.1.4.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
34
|
+
kscale-0.1.4.dist-info/entry_points.txt,sha256=N_0pCpPnwGDYVzOeuaSOrbJkS5L3lS9d8CxpJF1f8UI,62
|
35
|
+
kscale-0.1.4.dist-info/top_level.txt,sha256=C2ynjYwopg6YjgttnI2dJjasyq3EKNmYp-IfQg9Xms4,7
|
36
|
+
kscale-0.1.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|