kleinkram 0.38.1.dev20241119134715__tar.gz → 0.38.1.dev20241125112529__tar.gz

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 (53) hide show
  1. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/PKG-INFO +5 -3
  2. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/README.md +4 -2
  3. kleinkram-0.38.1.dev20241125112529/kleinkram/api/client.py +103 -0
  4. kleinkram-0.38.1.dev20241125112529/kleinkram/api/file_transfer.py +466 -0
  5. kleinkram-0.38.1.dev20241125112529/kleinkram/api/parsing.py +86 -0
  6. kleinkram-0.38.1.dev20241125112529/kleinkram/api/routes.py +235 -0
  7. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/app.py +60 -63
  8. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/auth.py +0 -2
  9. kleinkram-0.38.1.dev20241125112529/kleinkram/commands/download.py +103 -0
  10. kleinkram-0.38.1.dev20241125112529/kleinkram/commands/list.py +107 -0
  11. kleinkram-0.38.1.dev20241125112529/kleinkram/commands/mission.py +69 -0
  12. kleinkram-0.38.1.dev20241125112529/kleinkram/commands/upload.py +164 -0
  13. kleinkram-0.38.1.dev20241125112529/kleinkram/commands/verify.py +142 -0
  14. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/config.py +2 -3
  15. kleinkram-0.38.1.dev20241125112529/kleinkram/errors.py +82 -0
  16. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/models.py +2 -2
  17. kleinkram-0.38.1.dev20241125112529/kleinkram/resources.py +158 -0
  18. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/utils.py +32 -53
  19. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/PKG-INFO +5 -3
  20. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/SOURCES.txt +4 -0
  21. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/setup.cfg +3 -1
  22. kleinkram-0.38.1.dev20241125112529/tests/test_end_to_end.py +105 -0
  23. kleinkram-0.38.1.dev20241125112529/tests/test_resources.py +137 -0
  24. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/tests/test_utils.py +13 -59
  25. kleinkram-0.38.1.dev20241119134715/kleinkram/api/client.py +0 -65
  26. kleinkram-0.38.1.dev20241119134715/kleinkram/api/file_transfer.py +0 -337
  27. kleinkram-0.38.1.dev20241119134715/kleinkram/api/routes.py +0 -466
  28. kleinkram-0.38.1.dev20241119134715/kleinkram/commands/download.py +0 -103
  29. kleinkram-0.38.1.dev20241119134715/kleinkram/commands/list.py +0 -102
  30. kleinkram-0.38.1.dev20241119134715/kleinkram/commands/mission.py +0 -57
  31. kleinkram-0.38.1.dev20241119134715/kleinkram/commands/upload.py +0 -138
  32. kleinkram-0.38.1.dev20241119134715/kleinkram/commands/verify.py +0 -117
  33. kleinkram-0.38.1.dev20241119134715/kleinkram/errors.py +0 -59
  34. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/LICENSE +0 -0
  35. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/__init__.py +0 -0
  36. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/__main__.py +0 -0
  37. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/_version.py +0 -0
  38. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/api/__init__.py +0 -0
  39. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/commands/__init__.py +0 -0
  40. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/commands/endpoint.py +0 -0
  41. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/commands/project.py +0 -0
  42. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/consts.py +0 -0
  43. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/core.py +0 -0
  44. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/enums.py +0 -0
  45. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram/main.py +0 -0
  46. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/dependency_links.txt +0 -0
  47. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/entry_points.txt +0 -0
  48. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/requires.txt +0 -0
  49. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/top_level.txt +0 -0
  50. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/pyproject.toml +0 -0
  51. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/requirements.txt +0 -0
  52. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/setup.py +0 -0
  53. {kleinkram-0.38.1.dev20241119134715 → kleinkram-0.38.1.dev20241125112529}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kleinkram
3
- Version: 0.38.1.dev20241119134715
3
+ Version: 0.38.1.dev20241125112529
4
4
  Summary: give me your bags
5
5
  Author: Cyrill Püntener, Dominique Garmier, Johann Schwabe
6
6
  Classifier: Programming Language :: Python :: 3
@@ -109,5 +109,7 @@ klein --help
109
109
  ```bash
110
110
  pytest .
111
111
  ```
112
-
113
- You can also look in `scripts` for some scripts that might be useful for testing.
112
+ or if you want to skip slow tests...
113
+ ```bash
114
+ pytest -m "not slow" .
115
+ ```
@@ -85,5 +85,7 @@ klein --help
85
85
  ```bash
86
86
  pytest .
87
87
  ```
88
-
89
- You can also look in `scripts` for some scripts that might be useful for testing.
88
+ or if you want to skip slow tests...
89
+ ```bash
90
+ pytest -m "not slow" .
91
+ ```
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from threading import Lock
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from kleinkram.auth import Config
9
+ from kleinkram.config import Credentials
10
+ from kleinkram.errors import NotAuthenticated
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ COOKIE_AUTH_TOKEN = "authtoken"
16
+ COOKIE_REFRESH_TOKEN = "refreshtoken"
17
+ COOKIE_CLI_KEY = "clikey"
18
+
19
+
20
+ class NotLoggedInException(Exception): ...
21
+
22
+
23
+ class AuthenticatedClient(httpx.Client):
24
+ _config: Config
25
+ _config_lock: Lock
26
+
27
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
28
+ super().__init__(*args, **kwargs)
29
+
30
+ self._config = Config()
31
+ self._config_lock = Lock()
32
+
33
+ if self._config.has_cli_key:
34
+ assert self._config.cli_key, "unreachable"
35
+ logger.info("using cli key...")
36
+ self.cookies.set(COOKIE_CLI_KEY, self._config.cli_key)
37
+
38
+ elif self._config.has_refresh_token:
39
+ logger.info("using refresh token...")
40
+ assert self._config.auth_token is not None, "unreachable"
41
+ self.cookies.set(COOKIE_AUTH_TOKEN, self._config.auth_token)
42
+ else:
43
+ logger.info("not authenticated...")
44
+ raise NotAuthenticated
45
+
46
+ def _refresh_token(self) -> None:
47
+ if self._config.has_cli_key:
48
+ raise RuntimeError("cannot refresh token when using cli key auth")
49
+
50
+ refresh_token = self._config.refresh_token
51
+ if refresh_token is None:
52
+ raise RuntimeError("no refresh token found")
53
+ self.cookies.set(COOKIE_REFRESH_TOKEN, refresh_token)
54
+
55
+ logger.info("refreshing token...")
56
+ response = self.post(
57
+ "/auth/refresh-token",
58
+ )
59
+ response.raise_for_status()
60
+ new_access_token = response.cookies[COOKIE_AUTH_TOKEN]
61
+ creds = Credentials(auth_token=new_access_token, refresh_token=refresh_token)
62
+
63
+ logger.info("saving new tokens...")
64
+
65
+ with self._config_lock:
66
+ self._config.save_credentials(creds)
67
+
68
+ self.cookies.set(COOKIE_AUTH_TOKEN, new_access_token)
69
+
70
+ def request(
71
+ self, method: str, url: str | httpx.URL, *args: Any, **kwargs: Any
72
+ ) -> httpx.Response:
73
+ if isinstance(url, httpx.URL):
74
+ raise NotImplementedError(f"`httpx.URL` is not supported {url!r}")
75
+ if not url.startswith("/"):
76
+ url = f"/{url}"
77
+
78
+ # try to do a request
79
+ full_url = f"{self._config.endpoint}{url}"
80
+ logger.info(f"requesting {method} {full_url}")
81
+ response = super().request(method, full_url, *args, **kwargs)
82
+
83
+ logger.info(f"got response {response}")
84
+
85
+ # if the requesting a refresh token fails, we are not logged in
86
+ if (url == "/auth/refresh-token") and response.status_code == 401:
87
+ logger.info("got 401, not logged in...")
88
+ raise NotAuthenticated
89
+
90
+ # otherwise we try to refresh the token
91
+ if response.status_code == 401:
92
+ logger.info("got 401, trying to refresh token...")
93
+ try:
94
+ self._refresh_token()
95
+ except Exception:
96
+ raise NotAuthenticated
97
+
98
+ logger.info(f"retrying request {method} {full_url}")
99
+ resp = super().request(method, full_url, *args, **kwargs)
100
+ logger.info(f"got response {resp}")
101
+ return resp
102
+ else:
103
+ return response
@@ -0,0 +1,466 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sys
5
+ from concurrent.futures import as_completed
6
+ from concurrent.futures import Future
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from time import monotonic
11
+ from typing import Dict
12
+ from typing import NamedTuple
13
+ from typing import Optional
14
+ from typing import Tuple
15
+ from uuid import UUID
16
+
17
+ import boto3.s3.transfer
18
+ import botocore.config
19
+ import httpx
20
+ from kleinkram.api.client import AuthenticatedClient
21
+ from kleinkram.config import Config
22
+ from kleinkram.config import LOCAL_S3
23
+ from kleinkram.errors import AccessDenied
24
+ from kleinkram.models import File
25
+ from kleinkram.models import FileState
26
+ from kleinkram.utils import b64_md5
27
+ from kleinkram.utils import format_error
28
+ from kleinkram.utils import format_traceback
29
+ from kleinkram.utils import styled_string
30
+ from rich.console import Console
31
+ from tqdm import tqdm
32
+
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ UPLOAD_CREDS = "/file/temporaryAccess"
37
+ UPLOAD_CONFIRM = "/queue/confirmUpload"
38
+ UPLOAD_CANCEL = "/file/cancelUpload"
39
+
40
+ DOWNLOAD_CHUNK_SIZE = 1024 * 1024 * 16
41
+ DOWNLOAD_URL = "/file/download"
42
+
43
+ S3_MAX_RETRIES = 60 # same as frontend
44
+ S3_READ_TIMEOUT = 60 * 5 # 5 minutes
45
+
46
+
47
+ class UploadCredentials(NamedTuple):
48
+ access_key: str
49
+ secret_key: str
50
+ session_token: str
51
+ file_id: UUID
52
+ bucket: str
53
+
54
+
55
+ def _get_s3_endpoint() -> str:
56
+ config = Config()
57
+ endpoint = config.endpoint
58
+
59
+ if "localhost" in endpoint:
60
+ return LOCAL_S3
61
+ else:
62
+ return endpoint.replace("api", "minio")
63
+
64
+
65
+ def _confirm_file_upload(
66
+ client: AuthenticatedClient, file_id: UUID, file_hash: str
67
+ ) -> None:
68
+ data = {
69
+ "uuid": str(file_id),
70
+ "md5": file_hash,
71
+ }
72
+ resp = client.post(UPLOAD_CONFIRM, json=data)
73
+ resp.raise_for_status()
74
+
75
+
76
+ def _cancel_file_upload(
77
+ client: AuthenticatedClient, file_id: UUID, mission_id: UUID
78
+ ) -> None:
79
+ data = {
80
+ "uuid": [str(file_id)],
81
+ "missionUUID": str(mission_id),
82
+ }
83
+ resp = client.post(UPLOAD_CANCEL, json=data)
84
+ resp.raise_for_status()
85
+ return
86
+
87
+
88
+ FILE_EXISTS_ERROR = "File already exists"
89
+
90
+ # fields for upload credentials
91
+ ACCESS_KEY_FIELD = "accessKey"
92
+ SECRET_KEY_FIELD = "secretKey"
93
+ SESSION_TOKEN_FIELD = "sessionToken"
94
+ CREDENTIALS_FIELD = "accessCredentials"
95
+ FILE_ID_FIELD = "fileUUID"
96
+ BUCKET_FIELD = "bucket"
97
+
98
+
99
+ def _get_upload_creditials(
100
+ client: AuthenticatedClient, internal_filename: str, mission_id: UUID
101
+ ) -> Optional[UploadCredentials]:
102
+ dct = {
103
+ "filenames": [internal_filename],
104
+ "missionUUID": str(mission_id),
105
+ }
106
+ resp = client.post(UPLOAD_CREDS, json=dct)
107
+ resp.raise_for_status()
108
+
109
+ data = resp.json()[0]
110
+
111
+ if data.get("error") == FILE_EXISTS_ERROR:
112
+ return None
113
+
114
+ bucket = data[BUCKET_FIELD]
115
+ file_id = UUID(data[FILE_ID_FIELD], version=4)
116
+
117
+ creds = data[CREDENTIALS_FIELD]
118
+ access_key = creds[ACCESS_KEY_FIELD]
119
+ secret_key = creds[SECRET_KEY_FIELD]
120
+ session_token = creds[SESSION_TOKEN_FIELD]
121
+
122
+ return UploadCredentials(
123
+ access_key=access_key,
124
+ secret_key=secret_key,
125
+ session_token=session_token,
126
+ file_id=file_id,
127
+ bucket=bucket,
128
+ )
129
+
130
+
131
+ def _s3_upload(
132
+ local_path: Path,
133
+ *,
134
+ endpoint: str,
135
+ credentials: UploadCredentials,
136
+ pbar: tqdm,
137
+ ) -> None:
138
+ # configure boto3
139
+ config = botocore.config.Config(
140
+ retries={"max_attempts": S3_MAX_RETRIES},
141
+ read_timeout=S3_READ_TIMEOUT,
142
+ )
143
+ client = boto3.client(
144
+ "s3",
145
+ endpoint_url=endpoint,
146
+ aws_access_key_id=credentials.access_key,
147
+ aws_secret_access_key=credentials.secret_key,
148
+ aws_session_token=credentials.session_token,
149
+ config=config,
150
+ )
151
+ client.upload_file(
152
+ str(local_path),
153
+ credentials.bucket,
154
+ str(credentials.file_id),
155
+ Callback=pbar.update,
156
+ )
157
+
158
+
159
+ class UploadState(Enum):
160
+ UPLOADED = 1
161
+ EXISTS = 2
162
+ CANCELED = 3
163
+
164
+
165
+ # TODO: i dont want to handle errors at this level
166
+ def upload_file(
167
+ client: AuthenticatedClient,
168
+ *,
169
+ mission_id: UUID,
170
+ filename: str,
171
+ path: Path,
172
+ verbose: bool = False,
173
+ ) -> UploadState:
174
+ """\
175
+ returns bytes uploaded
176
+ """
177
+
178
+ total_size = path.stat().st_size
179
+ with tqdm(
180
+ total=total_size,
181
+ unit="B",
182
+ unit_scale=True,
183
+ desc=f"uploading {path}...",
184
+ leave=False,
185
+ disable=not verbose,
186
+ ) as pbar:
187
+ endpoint = _get_s3_endpoint()
188
+
189
+ # get per file upload credentials
190
+ creds = _get_upload_creditials(
191
+ client, internal_filename=filename, mission_id=mission_id
192
+ )
193
+ if creds is None:
194
+ return UploadState.EXISTS
195
+
196
+ try:
197
+ _s3_upload(path, endpoint=endpoint, credentials=creds, pbar=pbar)
198
+ except Exception as e:
199
+ logger.error(format_traceback(e))
200
+ _cancel_file_upload(client, creds.file_id, mission_id)
201
+ return UploadState.CANCELED
202
+
203
+ else:
204
+ _confirm_file_upload(client, creds.file_id, b64_md5(path))
205
+ return UploadState.UPLOADED
206
+
207
+
208
+ def _get_file_download(client: AuthenticatedClient, id: UUID) -> str:
209
+ """\
210
+ get the download url for a file by file id
211
+ """
212
+ resp = client.get(DOWNLOAD_URL, params={"uuid": str(id), "expires": True})
213
+
214
+ if 400 <= resp.status_code < 500:
215
+ raise AccessDenied(
216
+ f"Failed to download file: {resp.json()['message']}"
217
+ f"Status Code: {resp.status_code}",
218
+ )
219
+
220
+ resp.raise_for_status()
221
+
222
+ return resp.text
223
+
224
+
225
+ def _url_download(
226
+ url: str, *, path: Path, size: int, overwrite: bool = False, verbose: bool = False
227
+ ) -> None:
228
+ if path.exists() and not overwrite:
229
+ raise FileExistsError(f"file already exists: {path}")
230
+
231
+ with httpx.stream("GET", url) as response:
232
+ with open(path, "wb") as f:
233
+ with tqdm(
234
+ total=size,
235
+ desc=f"downloading {path.name}",
236
+ unit="B",
237
+ unit_scale=True,
238
+ leave=False,
239
+ disable=not verbose,
240
+ ) as pbar:
241
+ for chunk in response.iter_bytes(chunk_size=DOWNLOAD_CHUNK_SIZE):
242
+ f.write(chunk)
243
+ pbar.update(len(chunk))
244
+
245
+
246
+ class DownloadState(Enum):
247
+ DOWNLOADED_OK = 1
248
+ SKIPPED_OK = 2
249
+ DOWNLOADED_INVALID_HASH = 3
250
+ SKIPPED_INVALID_HASH = 4
251
+ SKIPPED_INVALID_REMOTE_STATE = 5
252
+
253
+
254
+ def download_file(
255
+ client: AuthenticatedClient,
256
+ *,
257
+ file: File,
258
+ path: Path,
259
+ overwrite: bool = False,
260
+ verbose: bool = False,
261
+ ) -> DownloadState:
262
+ # skip files that are not ok on remote
263
+ if file.state != FileState.OK:
264
+ return DownloadState.SKIPPED_INVALID_REMOTE_STATE
265
+
266
+ # skip existing files depending on flags set
267
+ if path.exists():
268
+ local_hash = b64_md5(path)
269
+ if local_hash != file.hash and not overwrite and file.hash is not None:
270
+ return DownloadState.SKIPPED_INVALID_HASH
271
+
272
+ elif local_hash == file.hash:
273
+ return DownloadState.SKIPPED_OK
274
+
275
+ # this has to be here
276
+ if verbose:
277
+ tqdm.write(
278
+ styled_string(f"overwriting {path}, hash missmatch", style="yellow")
279
+ )
280
+
281
+ # request a download url
282
+ download_url = _get_file_download(client, file.id)
283
+
284
+ # create parent directories
285
+ path.parent.mkdir(parents=True, exist_ok=True)
286
+
287
+ # download the file and check the hash
288
+ _url_download(
289
+ download_url, path=path, size=file.size, overwrite=overwrite, verbose=verbose
290
+ )
291
+ observed_hash = b64_md5(path)
292
+ if file.hash is not None and observed_hash != file.hash:
293
+ return DownloadState.DOWNLOADED_INVALID_HASH
294
+ return DownloadState.DOWNLOADED_OK
295
+
296
+
297
+ UPLOAD_STATE_COLOR = {
298
+ UploadState.UPLOADED: "green",
299
+ UploadState.EXISTS: "yellow",
300
+ UploadState.CANCELED: "red",
301
+ }
302
+
303
+
304
+ def _upload_handler(
305
+ future: Future[UploadState], path: Path, *, verbose: bool = False
306
+ ) -> int:
307
+ try:
308
+ state = future.result()
309
+ except Exception as e:
310
+ logger.error(format_traceback(e))
311
+ if verbose:
312
+ tqdm.write(format_error(f"error uploading {path}", e))
313
+ else:
314
+ print(path.absolute(), file=sys.stderr)
315
+ return 0
316
+
317
+ if state == UploadState.UPLOADED:
318
+ msg = f"uploaded {path}"
319
+ elif state == UploadState.EXISTS:
320
+ msg = f"skipped {path} already uploaded"
321
+ else:
322
+ msg = f"canceled {path} upload"
323
+
324
+ if verbose:
325
+ tqdm.write(styled_string(msg, style=UPLOAD_STATE_COLOR[state]))
326
+ else:
327
+ stream = sys.stdout if state == UploadState.UPLOADED else sys.stderr
328
+ print(path.absolute(), file=stream)
329
+
330
+ return path.stat().st_size if state == UploadState.UPLOADED else 0
331
+
332
+
333
+ DOWNLOAD_STATE_COLOR = {
334
+ DownloadState.DOWNLOADED_OK: "green",
335
+ DownloadState.SKIPPED_OK: "green",
336
+ DownloadState.DOWNLOADED_INVALID_HASH: "red",
337
+ DownloadState.SKIPPED_INVALID_HASH: "yellow",
338
+ DownloadState.SKIPPED_INVALID_REMOTE_STATE: "purple",
339
+ }
340
+
341
+
342
+ def _download_handler(
343
+ future: Future[DownloadState], file: File, path: Path, *, verbose: bool = False
344
+ ) -> int:
345
+ try:
346
+ state = future.result()
347
+ except Exception as e:
348
+ logger.error(format_traceback(e))
349
+ if verbose:
350
+ tqdm.write(format_error(f"error uploading {path}", e))
351
+ else:
352
+ print(path.absolute(), file=sys.stderr)
353
+ return 0
354
+
355
+ if state == DownloadState.DOWNLOADED_OK:
356
+ msg = f"downloaded {path}"
357
+ elif state == DownloadState.DOWNLOADED_INVALID_HASH:
358
+ msg = f"downloaded {path} failed hash check"
359
+ elif state == DownloadState.SKIPPED_OK:
360
+ msg = f"skipped {path} already downloaded"
361
+ elif state == DownloadState.SKIPPED_INVALID_HASH:
362
+ msg = f"skipped {path} already downloaded, hash missmatch, cosider using `--overwrite`"
363
+ else:
364
+ msg = f"skipped {path} remote file has invalid state"
365
+
366
+ if verbose:
367
+ tqdm.write(styled_string(msg, style=DOWNLOAD_STATE_COLOR[state]))
368
+ else:
369
+ stream = (
370
+ sys.stdout
371
+ if state in (DownloadState.DOWNLOADED_OK, DownloadState.SKIPPED_OK)
372
+ else sys.stderr
373
+ )
374
+ print(path.absolute(), file=stream)
375
+
376
+ # number of bytes downloaded
377
+ return file.size if state == DownloadState.DOWNLOADED_OK else 0
378
+
379
+
380
+ def upload_files(
381
+ client: AuthenticatedClient,
382
+ files_map: Dict[str, Path],
383
+ mission_id: UUID,
384
+ *,
385
+ verbose: bool = False,
386
+ n_workers: int = 2,
387
+ ) -> None:
388
+ with tqdm(
389
+ total=len(files_map),
390
+ unit="files",
391
+ desc="uploading files",
392
+ disable=not verbose,
393
+ leave=False,
394
+ ) as pbar:
395
+ start = monotonic()
396
+ futures: Dict[Future[UploadState], Path] = {}
397
+ with ThreadPoolExecutor(max_workers=n_workers) as executor:
398
+ for name, path in files_map.items():
399
+ future = executor.submit(
400
+ upload_file,
401
+ client=client,
402
+ mission_id=mission_id,
403
+ filename=name,
404
+ path=path,
405
+ verbose=verbose,
406
+ )
407
+ futures[future] = path
408
+
409
+ total_size = 0
410
+ for future in as_completed(futures):
411
+ size = _upload_handler(future, futures[future], verbose=verbose)
412
+ total_size += size / 1024 / 1024
413
+
414
+ pbar.update()
415
+ pbar.refresh()
416
+
417
+ t = monotonic() - start
418
+ c = Console(file=sys.stderr)
419
+ c.print(f"upload took {t:.2f} seconds")
420
+ c.print(f"total size: {int(total_size)} MB")
421
+ c.print(f"average speed: {total_size / t:.2f} MB/s")
422
+
423
+
424
+ def download_files(
425
+ client: AuthenticatedClient,
426
+ files: Dict[Path, File],
427
+ *,
428
+ verbose: bool = False,
429
+ overwrite: bool = False,
430
+ n_workers: int = 2,
431
+ ) -> None:
432
+ with tqdm(
433
+ total=len(files),
434
+ unit="files",
435
+ desc="downloading files",
436
+ disable=not verbose,
437
+ leave=False,
438
+ ) as pbar:
439
+
440
+ start = monotonic()
441
+ futures: Dict[Future[DownloadState], Tuple[File, Path]] = {}
442
+ with ThreadPoolExecutor(max_workers=n_workers) as executor:
443
+ for path, file in files.items():
444
+ future = executor.submit(
445
+ download_file,
446
+ client=client,
447
+ file=file,
448
+ path=path,
449
+ overwrite=overwrite,
450
+ verbose=verbose,
451
+ )
452
+ futures[future] = (file, path)
453
+
454
+ total_size = 0
455
+ for future in as_completed(futures):
456
+ file, path = futures[future]
457
+ size = _download_handler(future, file, path, verbose=verbose)
458
+ total_size += size / 1024 / 1024 # MB
459
+ pbar.update()
460
+ pbar.refresh()
461
+
462
+ time = monotonic() - start
463
+ c = Console(file=sys.stderr)
464
+ c.print(f"download took {time:.2f} seconds")
465
+ c.print(f"total size: {int(total_size)} MB")
466
+ c.print(f"average speed: {total_size / time:.2f} MB/s")
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from typing import Dict
5
+ from typing import Optional
6
+ from uuid import UUID
7
+
8
+ from kleinkram.errors import ParsingError
9
+ from kleinkram.models import File
10
+ from kleinkram.models import FileState
11
+ from kleinkram.models import Mission
12
+ from kleinkram.models import Project
13
+
14
+ __all__ = [
15
+ "_parse_project",
16
+ "_parse_mission",
17
+ "_parse_file",
18
+ ]
19
+
20
+
21
+ def _parse_project(project: Dict[str, Any]) -> Project:
22
+ try:
23
+ project_id = UUID(project["uuid"], version=4)
24
+ project_name = project["name"]
25
+ project_description = project["description"]
26
+
27
+ parsed = Project(
28
+ id=project_id, name=project_name, description=project_description
29
+ )
30
+ except Exception:
31
+ raise ParsingError(f"error parsing project: {project}")
32
+ return parsed
33
+
34
+
35
+ def _parse_mission(
36
+ mission: Dict[str, Any], project: Optional[Project] = None
37
+ ) -> Mission:
38
+ try:
39
+ mission_id = UUID(mission["uuid"], version=4)
40
+ mission_name = mission["name"]
41
+
42
+ project_id = (
43
+ project.id if project else UUID(mission["project"]["uuid"], version=4)
44
+ )
45
+ project_name = project.name if project else mission["project"]["name"]
46
+
47
+ parsed = Mission(
48
+ id=mission_id,
49
+ name=mission_name,
50
+ project_id=project_id,
51
+ project_name=project_name,
52
+ )
53
+ except Exception:
54
+ raise ParsingError(f"error parsing mission: {mission}")
55
+ return parsed
56
+
57
+
58
+ def _parse_file(file: Dict[str, Any], mission: Optional[Mission] = None) -> File:
59
+ try:
60
+ filename = file["filename"]
61
+ file_id = UUID(file["uuid"], version=4)
62
+ file_size = file["size"]
63
+ file_hash = file["hash"]
64
+
65
+ project_id = (
66
+ mission.project_id if mission else UUID(file["project"]["uuid"], version=4)
67
+ )
68
+ project_name = mission.project_name if mission else file["project"]["name"]
69
+
70
+ mission_id = mission.id if mission else UUID(file["mission"]["uuid"], version=4)
71
+ mission_name = mission.name if mission else file["mission"]["name"]
72
+
73
+ parsed = File(
74
+ id=file_id,
75
+ name=filename,
76
+ size=file_size,
77
+ hash=file_hash,
78
+ project_id=project_id,
79
+ project_name=project_name,
80
+ mission_id=mission_id,
81
+ mission_name=mission_name,
82
+ state=FileState(file["state"]),
83
+ )
84
+ except Exception:
85
+ raise ParsingError(f"error parsing file: {file}")
86
+ return parsed