kleinkram 0.36.3.dev20241113165309__tar.gz → 0.37.0__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 (62) hide show
  1. {kleinkram-0.36.3.dev20241113165309 → kleinkram-0.37.0}/LICENSE +1 -1
  2. kleinkram-0.37.0/PKG-INFO +113 -0
  3. kleinkram-0.37.0/README.md +89 -0
  4. kleinkram-0.37.0/kleinkram/__init__.py +6 -0
  5. kleinkram-0.37.0/kleinkram/__main__.py +6 -0
  6. kleinkram-0.37.0/kleinkram/_version.py +6 -0
  7. kleinkram-0.37.0/kleinkram/api/client.py +65 -0
  8. kleinkram-0.37.0/kleinkram/api/file_transfer.py +337 -0
  9. kleinkram-0.37.0/kleinkram/api/routes.py +460 -0
  10. kleinkram-0.37.0/kleinkram/app.py +180 -0
  11. kleinkram-0.37.0/kleinkram/auth.py +96 -0
  12. kleinkram-0.37.0/kleinkram/commands/__init__.py +1 -0
  13. kleinkram-0.37.0/kleinkram/commands/download.py +103 -0
  14. kleinkram-0.37.0/kleinkram/commands/endpoint.py +62 -0
  15. kleinkram-0.37.0/kleinkram/commands/list.py +93 -0
  16. kleinkram-0.37.0/kleinkram/commands/mission.py +57 -0
  17. kleinkram-0.37.0/kleinkram/commands/project.py +24 -0
  18. kleinkram-0.37.0/kleinkram/commands/upload.py +138 -0
  19. kleinkram-0.37.0/kleinkram/commands/verify.py +117 -0
  20. kleinkram-0.37.0/kleinkram/config.py +171 -0
  21. kleinkram-0.37.0/kleinkram/consts.py +8 -0
  22. kleinkram-0.37.0/kleinkram/core.py +14 -0
  23. kleinkram-0.37.0/kleinkram/enums.py +10 -0
  24. kleinkram-0.37.0/kleinkram/errors.py +59 -0
  25. kleinkram-0.37.0/kleinkram/main.py +12 -0
  26. kleinkram-0.37.0/kleinkram/models.py +186 -0
  27. kleinkram-0.37.0/kleinkram/utils.py +179 -0
  28. kleinkram-0.37.0/kleinkram.egg-info/PKG-INFO +113 -0
  29. kleinkram-0.37.0/kleinkram.egg-info/SOURCES.txt +39 -0
  30. kleinkram-0.37.0/kleinkram.egg-info/dependency_links.txt +1 -0
  31. kleinkram-0.37.0/kleinkram.egg-info/entry_points.txt +2 -0
  32. kleinkram-0.37.0/kleinkram.egg-info/requires.txt +7 -0
  33. kleinkram-0.37.0/kleinkram.egg-info/top_level.txt +2 -0
  34. kleinkram-0.37.0/pyproject.toml +2 -0
  35. kleinkram-0.37.0/requirements.txt +7 -0
  36. kleinkram-0.37.0/setup.cfg +48 -0
  37. kleinkram-0.37.0/setup.py +6 -0
  38. kleinkram-0.37.0/tests/__init__.py +0 -0
  39. kleinkram-0.37.0/tests/test_utils.py +153 -0
  40. kleinkram-0.36.3.dev20241113165309/.gitignore +0 -12
  41. kleinkram-0.36.3.dev20241113165309/PKG-INFO +0 -24
  42. kleinkram-0.36.3.dev20241113165309/README.md +0 -4
  43. kleinkram-0.36.3.dev20241113165309/deploy.sh +0 -6
  44. kleinkram-0.36.3.dev20241113165309/dev.sh +0 -6
  45. kleinkram-0.36.3.dev20241113165309/pyproject.toml +0 -35
  46. kleinkram-0.36.3.dev20241113165309/requirements.txt +0 -12
  47. kleinkram-0.36.3.dev20241113165309/src/klein.py +0 -9
  48. kleinkram-0.36.3.dev20241113165309/src/kleinkram/api_client.py +0 -63
  49. kleinkram-0.36.3.dev20241113165309/src/kleinkram/auth/auth.py +0 -160
  50. kleinkram-0.36.3.dev20241113165309/src/kleinkram/consts.py +0 -1
  51. kleinkram-0.36.3.dev20241113165309/src/kleinkram/endpoint/endpoint.py +0 -58
  52. kleinkram-0.36.3.dev20241113165309/src/kleinkram/error_handling.py +0 -177
  53. kleinkram-0.36.3.dev20241113165309/src/kleinkram/file/file.py +0 -144
  54. kleinkram-0.36.3.dev20241113165309/src/kleinkram/helper.py +0 -272
  55. kleinkram-0.36.3.dev20241113165309/src/kleinkram/main.py +0 -490
  56. kleinkram-0.36.3.dev20241113165309/src/kleinkram/mission/mission.py +0 -310
  57. kleinkram-0.36.3.dev20241113165309/src/kleinkram/project/project.py +0 -138
  58. kleinkram-0.36.3.dev20241113165309/src/kleinkram/queue/queue.py +0 -8
  59. kleinkram-0.36.3.dev20241113165309/src/kleinkram/tag/tag.py +0 -71
  60. kleinkram-0.36.3.dev20241113165309/src/kleinkram/topic/topic.py +0 -55
  61. kleinkram-0.36.3.dev20241113165309/src/kleinkram/user/user.py +0 -75
  62. {kleinkram-0.36.3.dev20241113165309/src/kleinkram → kleinkram-0.37.0/kleinkram/api}/__init__.py +0 -0
@@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you
671
671
  may consider it more useful to permit linking proprietary applications with
672
672
  the library. If this is what you want to do, use the GNU Lesser General
673
673
  Public License instead of this License. But first, please read
674
- <https://www.gnu.org/licenses/why-not-lgpl.html>.
674
+ <https://www.gnu.org/licenses/why-not-lgpl.html>.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.1
2
+ Name: kleinkram
3
+ Version: 0.37.0
4
+ Summary: give me your bags
5
+ Author: Cyrill Püntener, Dominique Garmier, Johann Schwabe
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3 :: Only
8
+ Classifier: Programming Language :: Python :: 3.8
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: Implementation :: CPython
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: boto3
18
+ Requires-Dist: botocore
19
+ Requires-Dist: httpx
20
+ Requires-Dist: pyyaml
21
+ Requires-Dist: rich
22
+ Requires-Dist: tqdm
23
+ Requires-Dist: typer
24
+
25
+ # Kleinkram: CLI
26
+
27
+ Install the package
28
+
29
+ ```bash
30
+ pip install kleinkram
31
+ ```
32
+
33
+ Run the CLI
34
+
35
+ ```bash
36
+ klein
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ Here are some basic examples of how to use the CLI.
42
+
43
+ ### Listing Files
44
+
45
+ To list all files in a mission:
46
+
47
+ ```bash
48
+ klein list -p project-name -m mission-name
49
+ ```
50
+
51
+ ### Uploading Files
52
+
53
+ To upload all `*.bag` files in the current directory to a mission:
54
+
55
+ ```bash
56
+ klein upload -p project-name -m mission-name *.bag
57
+ ```
58
+
59
+ If you would like to create a new mission on upload use the `--create` flag.
60
+
61
+ ### Downloading Files
62
+
63
+ To download all files from a mission and save them `out`:
64
+
65
+ ```bash
66
+ klein download -p project-name -m mission-name --dest out
67
+ ```
68
+
69
+ You can additionally specify filenames or ids if you only want to download specific files.
70
+
71
+ Instead of downloading files from a specified mission you can download arbitrary files by specifying their ids:
72
+
73
+ ```bash
74
+ klein download --dest out *id1* *id2* *id3*
75
+ ```
76
+
77
+ For more information consult the [documentation](https://docs.datasets.leggedrobotics.com/usage/cli/cli-getting-started.html).
78
+
79
+ ## Development
80
+
81
+ Clone the repo
82
+
83
+ ```bash
84
+ git clone git@github.com:leggedrobotics/kleinkram.git
85
+ cd kleinkram/cli
86
+ ```
87
+
88
+ Setup the environment
89
+
90
+ ```bash
91
+ virtualenv -ppython3.8 .venv
92
+ source .venv/bin/activate
93
+ pip install -e . -r requirements.txt
94
+ ```
95
+
96
+ Install `pre-commit` hooks
97
+
98
+ ```bash
99
+ pre-commit install
100
+ ```
101
+
102
+ Run the CLI
103
+
104
+ ```bash
105
+ klein --help
106
+ ```
107
+
108
+ ### Run Tests
109
+ ```bash
110
+ pytest .
111
+ ```
112
+
113
+ You can also look in `scripts` for some scripts that might be useful for testing.
@@ -0,0 +1,89 @@
1
+ # Kleinkram: CLI
2
+
3
+ Install the package
4
+
5
+ ```bash
6
+ pip install kleinkram
7
+ ```
8
+
9
+ Run the CLI
10
+
11
+ ```bash
12
+ klein
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ Here are some basic examples of how to use the CLI.
18
+
19
+ ### Listing Files
20
+
21
+ To list all files in a mission:
22
+
23
+ ```bash
24
+ klein list -p project-name -m mission-name
25
+ ```
26
+
27
+ ### Uploading Files
28
+
29
+ To upload all `*.bag` files in the current directory to a mission:
30
+
31
+ ```bash
32
+ klein upload -p project-name -m mission-name *.bag
33
+ ```
34
+
35
+ If you would like to create a new mission on upload use the `--create` flag.
36
+
37
+ ### Downloading Files
38
+
39
+ To download all files from a mission and save them `out`:
40
+
41
+ ```bash
42
+ klein download -p project-name -m mission-name --dest out
43
+ ```
44
+
45
+ You can additionally specify filenames or ids if you only want to download specific files.
46
+
47
+ Instead of downloading files from a specified mission you can download arbitrary files by specifying their ids:
48
+
49
+ ```bash
50
+ klein download --dest out *id1* *id2* *id3*
51
+ ```
52
+
53
+ For more information consult the [documentation](https://docs.datasets.leggedrobotics.com/usage/cli/cli-getting-started.html).
54
+
55
+ ## Development
56
+
57
+ Clone the repo
58
+
59
+ ```bash
60
+ git clone git@github.com:leggedrobotics/kleinkram.git
61
+ cd kleinkram/cli
62
+ ```
63
+
64
+ Setup the environment
65
+
66
+ ```bash
67
+ virtualenv -ppython3.8 .venv
68
+ source .venv/bin/activate
69
+ pip install -e . -r requirements.txt
70
+ ```
71
+
72
+ Install `pre-commit` hooks
73
+
74
+ ```bash
75
+ pre-commit install
76
+ ```
77
+
78
+ Run the CLI
79
+
80
+ ```bash
81
+ klein --help
82
+ ```
83
+
84
+ ### Run Tests
85
+ ```bash
86
+ pytest .
87
+ ```
88
+
89
+ You can also look in `scripts` for some scripts that might be useful for testing.
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from kleinkram._version import __version__
4
+
5
+
6
+ __all__ = ["__version__"]
@@ -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())
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("kleinkram")
6
+ __local__ = False
@@ -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")