kleinkram 0.37.0.dev20241113182530__tar.gz → 0.37.0.dev20241118113347__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.
- {kleinkram-0.37.0.dev20241113182530 → kleinkram-0.37.0.dev20241118113347}/LICENSE +1 -1
- kleinkram-0.37.0.dev20241118113347/PKG-INFO +113 -0
- kleinkram-0.37.0.dev20241118113347/README.md +89 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/__init__.py +6 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/__main__.py +6 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/_version.py +6 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/api/client.py +65 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/api/file_transfer.py +337 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/api/routes.py +460 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/app.py +180 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/auth.py +96 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/commands/__init__.py +1 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/commands/download.py +103 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/commands/endpoint.py +62 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/commands/list.py +93 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/commands/mission.py +57 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/commands/project.py +24 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/commands/upload.py +138 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/commands/verify.py +117 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/config.py +171 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/consts.py +8 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/core.py +14 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/enums.py +10 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/errors.py +59 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/main.py +12 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/models.py +186 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram/utils.py +179 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram.egg-info/PKG-INFO +113 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram.egg-info/SOURCES.txt +39 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram.egg-info/dependency_links.txt +1 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram.egg-info/entry_points.txt +2 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram.egg-info/requires.txt +7 -0
- kleinkram-0.37.0.dev20241118113347/kleinkram.egg-info/top_level.txt +2 -0
- kleinkram-0.37.0.dev20241118113347/pyproject.toml +2 -0
- kleinkram-0.37.0.dev20241118113347/requirements.txt +7 -0
- kleinkram-0.37.0.dev20241118113347/setup.cfg +48 -0
- kleinkram-0.37.0.dev20241118113347/setup.py +6 -0
- kleinkram-0.37.0.dev20241118113347/tests/__init__.py +0 -0
- kleinkram-0.37.0.dev20241118113347/tests/test_utils.py +153 -0
- kleinkram-0.37.0.dev20241113182530/.gitignore +0 -12
- kleinkram-0.37.0.dev20241113182530/PKG-INFO +0 -24
- kleinkram-0.37.0.dev20241113182530/README.md +0 -4
- kleinkram-0.37.0.dev20241113182530/deploy.sh +0 -6
- kleinkram-0.37.0.dev20241113182530/dev.sh +0 -6
- kleinkram-0.37.0.dev20241113182530/pyproject.toml +0 -35
- kleinkram-0.37.0.dev20241113182530/requirements.txt +0 -12
- kleinkram-0.37.0.dev20241113182530/src/klein.py +0 -9
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/api_client.py +0 -63
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/auth/auth.py +0 -160
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/consts.py +0 -1
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/endpoint/endpoint.py +0 -58
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/error_handling.py +0 -177
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/file/file.py +0 -144
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/helper.py +0 -272
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/main.py +0 -495
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/mission/mission.py +0 -310
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/project/project.py +0 -138
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/queue/queue.py +0 -8
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/tag/tag.py +0 -71
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/topic/topic.py +0 -55
- kleinkram-0.37.0.dev20241113182530/src/kleinkram/user/user.py +0 -75
- {kleinkram-0.37.0.dev20241113182530/src/kleinkram → kleinkram-0.37.0.dev20241118113347/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.dev20241118113347
|
|
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,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")
|