kleinkram 0.36.3.dev20241113174857__py3-none-any.whl → 0.37.0__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.

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