kleinkram 0.36.3.dev20241113174857__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.

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 +328 -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.dev20241118070559.dist-info}/LICENSE +1 -1
  27. kleinkram-0.37.0.dev20241118070559.dist-info/METADATA +113 -0
  28. kleinkram-0.37.0.dev20241118070559.dist-info/RECORD +33 -0
  29. {kleinkram-0.36.3.dev20241113174857.dist-info → kleinkram-0.37.0.dev20241118070559.dist-info}/WHEEL +2 -1
  30. kleinkram-0.37.0.dev20241118070559.dist-info/entry_points.txt +2 -0
  31. kleinkram-0.37.0.dev20241118070559.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,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")