kleinkram 0.37.0.dev20241113182530__py3-none-any.whl → 0.37.0.dev20241118070559__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.
Potentially problematic release.
This version of kleinkram might be problematic. Click here for more details.
- kleinkram/__init__.py +6 -0
- kleinkram/__main__.py +6 -0
- kleinkram/_version.py +6 -0
- kleinkram/api/__init__.py +0 -0
- kleinkram/api/client.py +65 -0
- kleinkram/api/file_transfer.py +328 -0
- kleinkram/api/routes.py +460 -0
- kleinkram/app.py +180 -0
- kleinkram/auth.py +96 -0
- kleinkram/commands/__init__.py +1 -0
- kleinkram/commands/download.py +103 -0
- kleinkram/commands/endpoint.py +62 -0
- kleinkram/commands/list.py +93 -0
- kleinkram/commands/mission.py +57 -0
- kleinkram/commands/project.py +24 -0
- kleinkram/commands/upload.py +138 -0
- kleinkram/commands/verify.py +117 -0
- kleinkram/config.py +171 -0
- kleinkram/consts.py +8 -1
- kleinkram/core.py +14 -0
- kleinkram/enums.py +10 -0
- kleinkram/errors.py +59 -0
- kleinkram/main.py +6 -489
- kleinkram/models.py +186 -0
- kleinkram/utils.py +179 -0
- {kleinkram-0.37.0.dev20241113182530.dist-info/licenses → kleinkram-0.37.0.dev20241118070559.dist-info}/LICENSE +1 -1
- kleinkram-0.37.0.dev20241118070559.dist-info/METADATA +113 -0
- kleinkram-0.37.0.dev20241118070559.dist-info/RECORD +33 -0
- {kleinkram-0.37.0.dev20241113182530.dist-info → kleinkram-0.37.0.dev20241118070559.dist-info}/WHEEL +2 -1
- kleinkram-0.37.0.dev20241118070559.dist-info/entry_points.txt +2 -0
- kleinkram-0.37.0.dev20241118070559.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_utils.py +153 -0
- kleinkram/api_client.py +0 -63
- kleinkram/auth/auth.py +0 -160
- kleinkram/endpoint/endpoint.py +0 -58
- kleinkram/error_handling.py +0 -177
- kleinkram/file/file.py +0 -144
- kleinkram/helper.py +0 -272
- kleinkram/mission/mission.py +0 -310
- kleinkram/project/project.py +0 -138
- kleinkram/queue/queue.py +0 -8
- kleinkram/tag/tag.py +0 -71
- kleinkram/topic/topic.py +0 -55
- kleinkram/user/user.py +0 -75
- kleinkram-0.37.0.dev20241113182530.dist-info/METADATA +0 -24
- kleinkram-0.37.0.dev20241113182530.dist-info/RECORD +0 -20
- kleinkram-0.37.0.dev20241113182530.dist-info/entry_points.txt +0 -2
kleinkram/__init__.py
CHANGED
kleinkram/__main__.py
ADDED
kleinkram/_version.py
ADDED
|
File without changes
|
kleinkram/api/client.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from kleinkram.auth import Config
|
|
5
|
+
from kleinkram.config import Credentials
|
|
6
|
+
from kleinkram.errors import LOGIN_MESSAGE
|
|
7
|
+
from kleinkram.errors import NotAuthenticatedException
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
COOKIE_AUTH_TOKEN = "authtoken"
|
|
11
|
+
COOKIE_REFRESH_TOKEN = "refreshtoken"
|
|
12
|
+
COOKIE_CLI_KEY = "clikey"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthenticatedClient(httpx.Client):
|
|
16
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
17
|
+
super().__init__(*args, **kwargs)
|
|
18
|
+
self.config = Config()
|
|
19
|
+
|
|
20
|
+
if self.config.has_cli_key:
|
|
21
|
+
assert self.config.cli_key, "unreachable"
|
|
22
|
+
self.cookies.set(COOKIE_CLI_KEY, self.config.cli_key)
|
|
23
|
+
|
|
24
|
+
elif self.config.has_refresh_token:
|
|
25
|
+
assert self.config.auth_token is not None, "unreachable"
|
|
26
|
+
self.cookies.set(COOKIE_AUTH_TOKEN, self.config.auth_token)
|
|
27
|
+
else:
|
|
28
|
+
raise NotAuthenticatedException(self.config.endpoint)
|
|
29
|
+
|
|
30
|
+
def _refresh_token(self) -> None:
|
|
31
|
+
if self.config.has_cli_key:
|
|
32
|
+
raise RuntimeError
|
|
33
|
+
|
|
34
|
+
refresh_token = self.config.refresh_token
|
|
35
|
+
if not refresh_token:
|
|
36
|
+
raise RuntimeError
|
|
37
|
+
|
|
38
|
+
self.cookies.set(COOKIE_REFRESH_TOKEN, refresh_token)
|
|
39
|
+
|
|
40
|
+
response = self.post(
|
|
41
|
+
"/auth/refresh-token",
|
|
42
|
+
)
|
|
43
|
+
response.raise_for_status()
|
|
44
|
+
|
|
45
|
+
new_access_token = response.cookies[COOKIE_AUTH_TOKEN]
|
|
46
|
+
creds = Credentials(auth_token=new_access_token, refresh_token=refresh_token)
|
|
47
|
+
|
|
48
|
+
self.config.save_credentials(creds)
|
|
49
|
+
self.cookies.set(COOKIE_AUTH_TOKEN, new_access_token)
|
|
50
|
+
|
|
51
|
+
def request(self, method, url, *args, **kwargs):
|
|
52
|
+
full_url = f"{self.config.endpoint}{url}"
|
|
53
|
+
response = super().request(method, full_url, *args, **kwargs)
|
|
54
|
+
|
|
55
|
+
if (url == "/auth/refresh-token") and response.status_code == 401:
|
|
56
|
+
raise RuntimeError(LOGIN_MESSAGE)
|
|
57
|
+
|
|
58
|
+
if response.status_code == 401:
|
|
59
|
+
try:
|
|
60
|
+
self._refresh_token()
|
|
61
|
+
except Exception:
|
|
62
|
+
raise RuntimeError(LOGIN_MESSAGE)
|
|
63
|
+
return super().request(method, full_url, *args, **kwargs)
|
|
64
|
+
else:
|
|
65
|
+
return response
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from time import monotonic
|
|
8
|
+
from typing import Dict
|
|
9
|
+
from typing import List
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from typing import Tuple
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
import boto3.s3.transfer
|
|
16
|
+
import httpx
|
|
17
|
+
from kleinkram.api.client import AuthenticatedClient
|
|
18
|
+
from kleinkram.config import Config
|
|
19
|
+
from kleinkram.config import LOCAL_S3
|
|
20
|
+
from kleinkram.errors import AccessDeniedException
|
|
21
|
+
from kleinkram.errors import CorruptedFile
|
|
22
|
+
from kleinkram.errors import UploadFailed
|
|
23
|
+
from kleinkram.utils import b64_md5
|
|
24
|
+
from kleinkram.utils import raw_rich
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
from tqdm import tqdm
|
|
27
|
+
|
|
28
|
+
UPLOAD_CREDS = "/file/temporaryAccess"
|
|
29
|
+
UPLOAD_CONFIRM = "/queue/confirmUpload"
|
|
30
|
+
UPLOAD_CANCEL = "/file/cancelUpload"
|
|
31
|
+
|
|
32
|
+
DOWNLOAD_CHUNK_SIZE = 1024 * 1024 * 16
|
|
33
|
+
DOWNLOAD_URL = "/file/download"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UploadCredentials(NamedTuple):
|
|
37
|
+
access_key: str
|
|
38
|
+
secret_key: str
|
|
39
|
+
session_token: str
|
|
40
|
+
file_id: UUID
|
|
41
|
+
bucket: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FileUploadJob(NamedTuple):
|
|
45
|
+
mission_id: UUID
|
|
46
|
+
name: str
|
|
47
|
+
path: Path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_s3_endpoint() -> str:
|
|
51
|
+
config = Config()
|
|
52
|
+
endpoint = config.endpoint
|
|
53
|
+
|
|
54
|
+
if "localhost" in endpoint:
|
|
55
|
+
return LOCAL_S3
|
|
56
|
+
else:
|
|
57
|
+
return endpoint.replace("api", "minio")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _confirm_file_upload(
|
|
61
|
+
client: AuthenticatedClient, file_id: UUID, file_hash: str
|
|
62
|
+
) -> None:
|
|
63
|
+
data = {
|
|
64
|
+
"uuid": str(file_id),
|
|
65
|
+
"md5": file_hash,
|
|
66
|
+
}
|
|
67
|
+
resp = client.post(UPLOAD_CONFIRM, json=data)
|
|
68
|
+
|
|
69
|
+
if 400 <= resp.status_code < 500:
|
|
70
|
+
raise CorruptedFile()
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cancel_file_upload(
|
|
75
|
+
client: AuthenticatedClient, file_id: UUID, mission_id: UUID
|
|
76
|
+
) -> None:
|
|
77
|
+
data = {
|
|
78
|
+
"uuid": [str(file_id)],
|
|
79
|
+
"missionUUID": str(mission_id),
|
|
80
|
+
}
|
|
81
|
+
resp = client.post(UPLOAD_CANCEL, json=data)
|
|
82
|
+
resp.raise_for_status()
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_file_download(client: AuthenticatedClient, id: UUID) -> str:
|
|
87
|
+
"""\
|
|
88
|
+
get the download url for a file by file id
|
|
89
|
+
"""
|
|
90
|
+
resp = client.get(DOWNLOAD_URL, params={"uuid": str(id), "expires": True})
|
|
91
|
+
|
|
92
|
+
if 400 <= resp.status_code < 500:
|
|
93
|
+
raise AccessDeniedException(
|
|
94
|
+
f"Failed to download file: {resp.json()['message']}",
|
|
95
|
+
"Status Code: " + str(resp.status_code),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
|
|
100
|
+
return resp.text
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_upload_creditials(
|
|
104
|
+
client: AuthenticatedClient, internal_filenames: List[str], mission_id: UUID
|
|
105
|
+
) -> Dict[str, UploadCredentials]:
|
|
106
|
+
if mission_id.version != 4:
|
|
107
|
+
raise ValueError("Mission ID must be a UUIDv4")
|
|
108
|
+
dct = {
|
|
109
|
+
"filenames": internal_filenames,
|
|
110
|
+
"missionUUID": str(mission_id),
|
|
111
|
+
}
|
|
112
|
+
resp = client.post(UPLOAD_CREDS, json=dct)
|
|
113
|
+
|
|
114
|
+
if resp.status_code >= 400:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"Failed to get temporary credentials. Status Code: "
|
|
117
|
+
f"{resp.status_code}\n{resp.json()['message'][0]}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
data = resp.json()
|
|
121
|
+
|
|
122
|
+
ret = {}
|
|
123
|
+
for record in data:
|
|
124
|
+
if "error" in record:
|
|
125
|
+
# TODO: handle this better
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
bucket = record["bucket"]
|
|
129
|
+
file_id = UUID(record["fileUUID"], version=4)
|
|
130
|
+
filename = record["fileName"]
|
|
131
|
+
|
|
132
|
+
creds = record["accessCredentials"]
|
|
133
|
+
|
|
134
|
+
access_key = creds["accessKey"]
|
|
135
|
+
secret_key = creds["secretKey"]
|
|
136
|
+
session_token = creds["sessionToken"]
|
|
137
|
+
|
|
138
|
+
ret[filename] = UploadCredentials(
|
|
139
|
+
access_key=access_key,
|
|
140
|
+
secret_key=secret_key,
|
|
141
|
+
session_token=session_token,
|
|
142
|
+
file_id=file_id,
|
|
143
|
+
bucket=bucket,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return ret
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _s3_upload(
|
|
150
|
+
local_path: Path,
|
|
151
|
+
*,
|
|
152
|
+
endpoint: str,
|
|
153
|
+
credentials: UploadCredentials,
|
|
154
|
+
pbar: tqdm,
|
|
155
|
+
) -> bool:
|
|
156
|
+
# configure boto3
|
|
157
|
+
try:
|
|
158
|
+
client = boto3.client(
|
|
159
|
+
"s3",
|
|
160
|
+
endpoint_url=endpoint,
|
|
161
|
+
aws_access_key_id=credentials.access_key,
|
|
162
|
+
aws_secret_access_key=credentials.secret_key,
|
|
163
|
+
aws_session_token=credentials.session_token,
|
|
164
|
+
)
|
|
165
|
+
client.upload_file(
|
|
166
|
+
str(local_path),
|
|
167
|
+
credentials.bucket,
|
|
168
|
+
str(credentials.file_id),
|
|
169
|
+
Callback=pbar.update,
|
|
170
|
+
)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
err = f"error uploading file: {local_path}: {type(e).__name__}"
|
|
173
|
+
pbar.write(raw_rich(Text(err, style="red")))
|
|
174
|
+
return False
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _upload_file(
|
|
179
|
+
client: AuthenticatedClient,
|
|
180
|
+
job: FileUploadJob,
|
|
181
|
+
hide_progress: bool = False,
|
|
182
|
+
global_pbar: Optional[tqdm] = None,
|
|
183
|
+
) -> Tuple[int, Path]:
|
|
184
|
+
"""\
|
|
185
|
+
returns bytes uploaded
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
pbar = tqdm(
|
|
189
|
+
total=os.path.getsize(job.path),
|
|
190
|
+
unit="B",
|
|
191
|
+
unit_scale=True,
|
|
192
|
+
desc=f"uploading {job.path.name}...",
|
|
193
|
+
leave=False,
|
|
194
|
+
disable=hide_progress,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# get creditials for the upload
|
|
198
|
+
try:
|
|
199
|
+
# get upload credentials for a single file
|
|
200
|
+
access = _get_upload_creditials(
|
|
201
|
+
client, internal_filenames=[job.name], mission_id=job.mission_id
|
|
202
|
+
)
|
|
203
|
+
# upload file
|
|
204
|
+
creds = access[job.name]
|
|
205
|
+
except Exception as e:
|
|
206
|
+
pbar.write(f"unable to get upload credentials for file {job.path.name}: {e}")
|
|
207
|
+
pbar.close()
|
|
208
|
+
if global_pbar is not None:
|
|
209
|
+
global_pbar.update()
|
|
210
|
+
return (0, job.path)
|
|
211
|
+
|
|
212
|
+
# do the upload
|
|
213
|
+
endpoint = _get_s3_endpoint()
|
|
214
|
+
success = _s3_upload(job.path, endpoint=endpoint, credentials=creds, pbar=pbar)
|
|
215
|
+
|
|
216
|
+
if not success:
|
|
217
|
+
try:
|
|
218
|
+
_cancel_file_upload(client, creds.file_id, job.mission_id)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
msg = Text(f"failed to cancel upload: {type(e).__name__}", style="red")
|
|
221
|
+
pbar.write(raw_rich(msg))
|
|
222
|
+
else:
|
|
223
|
+
# tell backend that upload is complete
|
|
224
|
+
try:
|
|
225
|
+
local_hash = b64_md5(job.path)
|
|
226
|
+
_confirm_file_upload(client, creds.file_id, local_hash)
|
|
227
|
+
|
|
228
|
+
if global_pbar is not None:
|
|
229
|
+
msg = Text(f"uploaded {job.path}", style="green")
|
|
230
|
+
global_pbar.write(raw_rich(msg))
|
|
231
|
+
global_pbar.update()
|
|
232
|
+
|
|
233
|
+
except Exception as e:
|
|
234
|
+
msg = Text(
|
|
235
|
+
f"error confirming upload {job.path}: {type(e).__name__}", style="red"
|
|
236
|
+
)
|
|
237
|
+
pbar.write(raw_rich(msg))
|
|
238
|
+
|
|
239
|
+
pbar.close()
|
|
240
|
+
return (job.path.stat().st_size, job.path)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def upload_files(
|
|
244
|
+
files_map: Dict[str, Path],
|
|
245
|
+
mission_id: UUID,
|
|
246
|
+
*,
|
|
247
|
+
verbose: bool = False,
|
|
248
|
+
n_workers: int = 2,
|
|
249
|
+
) -> None:
|
|
250
|
+
futures = []
|
|
251
|
+
|
|
252
|
+
pbar = tqdm(
|
|
253
|
+
total=len(files_map),
|
|
254
|
+
unit="files",
|
|
255
|
+
desc="Uploading files",
|
|
256
|
+
disable=not verbose,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
start = monotonic()
|
|
260
|
+
with ThreadPoolExecutor(max_workers=n_workers) as executor:
|
|
261
|
+
for name, path in files_map.items():
|
|
262
|
+
# client is not thread safe
|
|
263
|
+
client = AuthenticatedClient()
|
|
264
|
+
job = FileUploadJob(mission_id=mission_id, name=name, path=path)
|
|
265
|
+
future = executor.submit(
|
|
266
|
+
_upload_file,
|
|
267
|
+
client=client,
|
|
268
|
+
job=job,
|
|
269
|
+
hide_progress=not verbose,
|
|
270
|
+
global_pbar=pbar,
|
|
271
|
+
)
|
|
272
|
+
futures.append(future)
|
|
273
|
+
|
|
274
|
+
errors = []
|
|
275
|
+
total_size = 0
|
|
276
|
+
for f in futures:
|
|
277
|
+
try:
|
|
278
|
+
size, path = f.result()
|
|
279
|
+
size = size / 1024 / 1024 # convert to MB
|
|
280
|
+
|
|
281
|
+
if not verbose and size > 0:
|
|
282
|
+
print(path.absolte())
|
|
283
|
+
|
|
284
|
+
total_size += size
|
|
285
|
+
except Exception as e:
|
|
286
|
+
errors.append(e)
|
|
287
|
+
|
|
288
|
+
pbar.close()
|
|
289
|
+
|
|
290
|
+
time = monotonic() - start
|
|
291
|
+
print(f"upload took {time:.2f} seconds", file=sys.stderr)
|
|
292
|
+
print(f"total size: {int(total_size)} MB", file=sys.stderr)
|
|
293
|
+
print(f"average speed: {total_size / time:.2f} MB/s", file=sys.stderr)
|
|
294
|
+
|
|
295
|
+
if errors:
|
|
296
|
+
raise UploadFailed(f"got unhandled errors: {errors} when uploading files")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _url_download(url: str, path: Path, size: int, overwrite: bool = False) -> None:
|
|
300
|
+
if path.exists() and not overwrite:
|
|
301
|
+
raise FileExistsError(f"File already exists: {path}")
|
|
302
|
+
|
|
303
|
+
with httpx.stream("GET", url) as response:
|
|
304
|
+
with open(path, "wb") as f:
|
|
305
|
+
with tqdm(
|
|
306
|
+
total=size, desc=f"Downloading {path.name}", unit="B", unit_scale=True
|
|
307
|
+
) as pbar:
|
|
308
|
+
for chunk in response.iter_bytes(chunk_size=DOWNLOAD_CHUNK_SIZE):
|
|
309
|
+
f.write(chunk)
|
|
310
|
+
pbar.update(len(chunk))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def download_file(
|
|
314
|
+
client: AuthenticatedClient,
|
|
315
|
+
file_id: UUID,
|
|
316
|
+
name: str,
|
|
317
|
+
dest: Path,
|
|
318
|
+
hash: str,
|
|
319
|
+
size: int,
|
|
320
|
+
) -> None:
|
|
321
|
+
download_url = _get_file_download(client, file_id)
|
|
322
|
+
|
|
323
|
+
file_path = dest / name
|
|
324
|
+
_url_download(download_url, file_path, size)
|
|
325
|
+
observed_hash = b64_md5(file_path)
|
|
326
|
+
|
|
327
|
+
if observed_hash != hash:
|
|
328
|
+
raise CorruptedFile("file hash does not match")
|