kleinkram 0.38.1.dev20241120100707__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.
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/PKG-INFO +5 -3
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/README.md +4 -2
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/api/client.py +31 -23
- kleinkram-0.38.1.dev20241125112529/kleinkram/api/file_transfer.py +466 -0
- kleinkram-0.38.1.dev20241125112529/kleinkram/api/parsing.py +86 -0
- kleinkram-0.38.1.dev20241125112529/kleinkram/api/routes.py +235 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/app.py +20 -54
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/auth.py +0 -2
- kleinkram-0.38.1.dev20241125112529/kleinkram/commands/download.py +103 -0
- kleinkram-0.38.1.dev20241125112529/kleinkram/commands/list.py +107 -0
- kleinkram-0.38.1.dev20241125112529/kleinkram/commands/mission.py +69 -0
- kleinkram-0.38.1.dev20241125112529/kleinkram/commands/upload.py +164 -0
- kleinkram-0.38.1.dev20241125112529/kleinkram/commands/verify.py +142 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/config.py +2 -3
- kleinkram-0.38.1.dev20241125112529/kleinkram/errors.py +82 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/models.py +2 -2
- kleinkram-0.38.1.dev20241125112529/kleinkram/resources.py +158 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/utils.py +16 -47
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/PKG-INFO +5 -3
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/SOURCES.txt +4 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/setup.cfg +3 -1
- kleinkram-0.38.1.dev20241125112529/tests/test_end_to_end.py +105 -0
- kleinkram-0.38.1.dev20241125112529/tests/test_resources.py +137 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/tests/test_utils.py +13 -59
- kleinkram-0.38.1.dev20241120100707/kleinkram/api/file_transfer.py +0 -346
- kleinkram-0.38.1.dev20241120100707/kleinkram/api/routes.py +0 -469
- kleinkram-0.38.1.dev20241120100707/kleinkram/commands/download.py +0 -103
- kleinkram-0.38.1.dev20241120100707/kleinkram/commands/list.py +0 -102
- kleinkram-0.38.1.dev20241120100707/kleinkram/commands/mission.py +0 -57
- kleinkram-0.38.1.dev20241120100707/kleinkram/commands/upload.py +0 -138
- kleinkram-0.38.1.dev20241120100707/kleinkram/commands/verify.py +0 -119
- kleinkram-0.38.1.dev20241120100707/kleinkram/errors.py +0 -65
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/LICENSE +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/__init__.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/__main__.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/_version.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/api/__init__.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/commands/__init__.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/commands/endpoint.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/commands/project.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/consts.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/core.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/enums.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/main.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/dependency_links.txt +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/entry_points.txt +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/requires.txt +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram.egg-info/top_level.txt +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/pyproject.toml +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/requirements.txt +0 -0
- {kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/setup.py +0 -0
- {kleinkram-0.38.1.dev20241120100707 → 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.
|
|
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
|
-
|
|
112
|
+
or if you want to skip slow tests...
|
|
113
|
+
```bash
|
|
114
|
+
pytest -m "not slow" .
|
|
115
|
+
```
|
{kleinkram-0.38.1.dev20241120100707 → kleinkram-0.38.1.dev20241125112529}/kleinkram/api/client.py
RENAMED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
from threading import Lock
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
7
|
import httpx
|
|
7
8
|
from kleinkram.auth import Config
|
|
8
9
|
from kleinkram.config import Credentials
|
|
9
|
-
from kleinkram.errors import
|
|
10
|
-
from kleinkram.errors import NotAuthenticatedException
|
|
10
|
+
from kleinkram.errors import NotAuthenticated
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger(__name__)
|
|
13
13
|
|
|
@@ -21,31 +21,35 @@ class NotLoggedInException(Exception): ...
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class AuthenticatedClient(httpx.Client):
|
|
24
|
+
_config: Config
|
|
25
|
+
_config_lock: Lock
|
|
26
|
+
|
|
24
27
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
25
28
|
super().__init__(*args, **kwargs)
|
|
26
|
-
self.config = Config()
|
|
27
29
|
|
|
28
|
-
|
|
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"
|
|
30
35
|
logger.info("using cli key...")
|
|
31
|
-
self.cookies.set(COOKIE_CLI_KEY, self.
|
|
36
|
+
self.cookies.set(COOKIE_CLI_KEY, self._config.cli_key)
|
|
32
37
|
|
|
33
|
-
elif self.
|
|
38
|
+
elif self._config.has_refresh_token:
|
|
34
39
|
logger.info("using refresh token...")
|
|
35
|
-
assert self.
|
|
36
|
-
self.cookies.set(COOKIE_AUTH_TOKEN, self.
|
|
40
|
+
assert self._config.auth_token is not None, "unreachable"
|
|
41
|
+
self.cookies.set(COOKIE_AUTH_TOKEN, self._config.auth_token)
|
|
37
42
|
else:
|
|
38
43
|
logger.info("not authenticated...")
|
|
39
|
-
raise
|
|
44
|
+
raise NotAuthenticated
|
|
40
45
|
|
|
41
46
|
def _refresh_token(self) -> None:
|
|
42
|
-
if self.
|
|
43
|
-
raise RuntimeError
|
|
44
|
-
|
|
45
|
-
refresh_token = self.config.refresh_token
|
|
46
|
-
if not refresh_token:
|
|
47
|
-
raise RuntimeError
|
|
47
|
+
if self._config.has_cli_key:
|
|
48
|
+
raise RuntimeError("cannot refresh token when using cli key auth")
|
|
48
49
|
|
|
50
|
+
refresh_token = self._config.refresh_token
|
|
51
|
+
if refresh_token is None:
|
|
52
|
+
raise RuntimeError("no refresh token found")
|
|
49
53
|
self.cookies.set(COOKIE_REFRESH_TOKEN, refresh_token)
|
|
50
54
|
|
|
51
55
|
logger.info("refreshing token...")
|
|
@@ -53,30 +57,35 @@ class AuthenticatedClient(httpx.Client):
|
|
|
53
57
|
"/auth/refresh-token",
|
|
54
58
|
)
|
|
55
59
|
response.raise_for_status()
|
|
56
|
-
|
|
57
60
|
new_access_token = response.cookies[COOKIE_AUTH_TOKEN]
|
|
58
61
|
creds = Credentials(auth_token=new_access_token, refresh_token=refresh_token)
|
|
59
62
|
|
|
60
63
|
logger.info("saving new tokens...")
|
|
61
|
-
|
|
64
|
+
|
|
65
|
+
with self._config_lock:
|
|
66
|
+
self._config.save_credentials(creds)
|
|
67
|
+
|
|
62
68
|
self.cookies.set(COOKIE_AUTH_TOKEN, new_access_token)
|
|
63
69
|
|
|
64
70
|
def request(
|
|
65
71
|
self, method: str, url: str | httpx.URL, *args: Any, **kwargs: Any
|
|
66
72
|
) -> httpx.Response:
|
|
67
73
|
if isinstance(url, httpx.URL):
|
|
68
|
-
raise
|
|
74
|
+
raise NotImplementedError(f"`httpx.URL` is not supported {url!r}")
|
|
75
|
+
if not url.startswith("/"):
|
|
76
|
+
url = f"/{url}"
|
|
69
77
|
|
|
70
78
|
# try to do a request
|
|
71
|
-
full_url = f"{self.
|
|
79
|
+
full_url = f"{self._config.endpoint}{url}"
|
|
72
80
|
logger.info(f"requesting {method} {full_url}")
|
|
73
81
|
response = super().request(method, full_url, *args, **kwargs)
|
|
82
|
+
|
|
74
83
|
logger.info(f"got response {response}")
|
|
75
84
|
|
|
76
85
|
# if the requesting a refresh token fails, we are not logged in
|
|
77
86
|
if (url == "/auth/refresh-token") and response.status_code == 401:
|
|
78
87
|
logger.info("got 401, not logged in...")
|
|
79
|
-
raise
|
|
88
|
+
raise NotAuthenticated
|
|
80
89
|
|
|
81
90
|
# otherwise we try to refresh the token
|
|
82
91
|
if response.status_code == 401:
|
|
@@ -84,12 +93,11 @@ class AuthenticatedClient(httpx.Client):
|
|
|
84
93
|
try:
|
|
85
94
|
self._refresh_token()
|
|
86
95
|
except Exception:
|
|
87
|
-
raise
|
|
96
|
+
raise NotAuthenticated
|
|
88
97
|
|
|
89
98
|
logger.info(f"retrying request {method} {full_url}")
|
|
90
99
|
resp = super().request(method, full_url, *args, **kwargs)
|
|
91
100
|
logger.info(f"got response {resp}")
|
|
92
101
|
return resp
|
|
93
|
-
|
|
94
102
|
else:
|
|
95
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
|