kleinkram 0.38.1.dev20241212075157__py3-none-any.whl → 0.38.1.dev20250207122632__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of kleinkram might be problematic. Click here for more details.
- kleinkram/__init__.py +33 -2
- kleinkram/api/client.py +21 -16
- kleinkram/api/deser.py +165 -0
- kleinkram/api/file_transfer.py +13 -24
- kleinkram/api/pagination.py +56 -0
- kleinkram/api/query.py +111 -0
- kleinkram/api/routes.py +266 -97
- kleinkram/auth.py +21 -20
- kleinkram/cli/__init__.py +0 -0
- kleinkram/{commands/download.py → cli/_download.py} +18 -44
- kleinkram/cli/_endpoint.py +58 -0
- kleinkram/{commands/list.py → cli/_list.py} +25 -38
- kleinkram/cli/_mission.py +153 -0
- kleinkram/cli/_project.py +99 -0
- kleinkram/cli/_upload.py +84 -0
- kleinkram/cli/_verify.py +56 -0
- kleinkram/{app.py → cli/app.py} +57 -25
- kleinkram/cli/error_handling.py +67 -0
- kleinkram/config.py +141 -107
- kleinkram/core.py +251 -3
- kleinkram/errors.py +13 -45
- kleinkram/main.py +1 -1
- kleinkram/models.py +48 -149
- kleinkram/printing.py +325 -0
- kleinkram/py.typed +0 -0
- kleinkram/types.py +9 -0
- kleinkram/utils.py +88 -29
- kleinkram/wrappers.py +401 -0
- {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/METADATA +3 -3
- kleinkram-0.38.1.dev20250207122632.dist-info/RECORD +49 -0
- {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/WHEEL +1 -1
- {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/top_level.txt +1 -0
- testing/__init__.py +0 -0
- testing/backend_fixtures.py +67 -0
- tests/conftest.py +7 -0
- tests/test_config.py +115 -0
- tests/test_core.py +165 -0
- tests/test_end_to_end.py +29 -39
- tests/test_error_handling.py +44 -0
- tests/test_fixtures.py +34 -0
- tests/test_printing.py +62 -0
- tests/test_query.py +138 -0
- tests/test_utils.py +46 -24
- tests/test_wrappers.py +71 -0
- kleinkram/api/parsing.py +0 -86
- kleinkram/commands/__init__.py +0 -1
- kleinkram/commands/endpoint.py +0 -62
- kleinkram/commands/mission.py +0 -69
- kleinkram/commands/project.py +0 -24
- kleinkram/commands/upload.py +0 -164
- kleinkram/commands/verify.py +0 -142
- kleinkram/consts.py +0 -8
- kleinkram/enums.py +0 -10
- kleinkram/resources.py +0 -158
- kleinkram-0.38.1.dev20241212075157.dist-info/LICENSE +0 -674
- kleinkram-0.38.1.dev20241212075157.dist-info/RECORD +0 -37
- tests/test_resources.py +0 -137
- {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import Callable
|
|
7
|
+
from typing import Type
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from click import ClickException
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
|
|
14
|
+
from kleinkram.utils import upper_camel_case_to_words
|
|
15
|
+
|
|
16
|
+
ExceptionHandler = Callable[[Exception], int]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ErrorHandledTyper(typer.Typer):
|
|
20
|
+
"""\
|
|
21
|
+
error handlers that are last added will be used first
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
_error_handlers: OrderedDict[Type[Exception], ExceptionHandler]
|
|
25
|
+
|
|
26
|
+
def error_handler(
|
|
27
|
+
self, exc: Type[Exception]
|
|
28
|
+
) -> Callable[[ExceptionHandler], ExceptionHandler]:
|
|
29
|
+
def dec(func: ExceptionHandler) -> ExceptionHandler:
|
|
30
|
+
self._error_handlers[exc] = func
|
|
31
|
+
return func
|
|
32
|
+
|
|
33
|
+
return dec
|
|
34
|
+
|
|
35
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
36
|
+
super().__init__(*args, **kwargs)
|
|
37
|
+
self._error_handlers = OrderedDict()
|
|
38
|
+
|
|
39
|
+
def __call__(self, *args: Any, **kwargs: Any) -> int:
|
|
40
|
+
try:
|
|
41
|
+
return super().__call__(*args, **kwargs)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
if isinstance(e, ClickException):
|
|
44
|
+
raise
|
|
45
|
+
for tp, handler in reversed(self._error_handlers.items()):
|
|
46
|
+
if isinstance(e, tp):
|
|
47
|
+
exit_code = handler(e)
|
|
48
|
+
raise SystemExit(exit_code)
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def display_error(*, exc: Exception, verbose: bool) -> None:
|
|
53
|
+
split_exc_name = upper_camel_case_to_words(type(exc).__name__)
|
|
54
|
+
|
|
55
|
+
if verbose:
|
|
56
|
+
panel = Panel(
|
|
57
|
+
str(exc), # get the error message
|
|
58
|
+
title=" ".join(split_exc_name),
|
|
59
|
+
style="red",
|
|
60
|
+
border_style="bold",
|
|
61
|
+
)
|
|
62
|
+
Console(file=sys.stderr).print(panel)
|
|
63
|
+
else:
|
|
64
|
+
text = f"{type(exc).__name__}"
|
|
65
|
+
if str(exc):
|
|
66
|
+
text += f": {exc}"
|
|
67
|
+
print(text, file=sys.stderr)
|
kleinkram/config.py
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
this file contains a global config and a global state object
|
|
3
|
+
|
|
4
|
+
to get the config use `get_config()`
|
|
5
|
+
"""
|
|
6
|
+
|
|
1
7
|
from __future__ import annotations
|
|
2
8
|
|
|
3
9
|
import json
|
|
10
|
+
import logging
|
|
4
11
|
import os
|
|
5
12
|
import tempfile
|
|
6
13
|
from dataclasses import dataclass
|
|
14
|
+
from dataclasses import field
|
|
7
15
|
from enum import Enum
|
|
8
16
|
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
9
18
|
from typing import Dict
|
|
10
19
|
from typing import NamedTuple
|
|
11
20
|
from typing import Optional
|
|
12
21
|
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
13
25
|
from kleinkram._version import __local__
|
|
14
26
|
from kleinkram._version import __version__
|
|
15
|
-
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
16
29
|
|
|
17
30
|
CONFIG_PATH = Path().home() / ".kleinkram.json"
|
|
18
31
|
|
|
@@ -23,13 +36,33 @@ class Environment(Enum):
|
|
|
23
36
|
PROD = "prod"
|
|
24
37
|
|
|
25
38
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
class Endpoint(NamedTuple):
|
|
40
|
+
name: str
|
|
41
|
+
api: str
|
|
42
|
+
s3: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Credentials(NamedTuple):
|
|
46
|
+
auth_token: Optional[str] = None
|
|
47
|
+
refresh_token: Optional[str] = None
|
|
48
|
+
cli_key: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
DEFAULT_LOCAL_API = "http://localhost:3000"
|
|
52
|
+
DEFAULT_LOCAL_S3 = "http://localhost:9000"
|
|
53
|
+
|
|
54
|
+
DEFAULT_DEV_API = "https://api.datasets.dev.leggedrobotics.com"
|
|
55
|
+
DEFAULT_DEV_S3 = "https://s3.datasets.dev.leggedrobotics.com"
|
|
56
|
+
|
|
57
|
+
DEFAULT_PROD_API = "https://api.datasets.leggedrobotics.com"
|
|
58
|
+
DEFAULT_PROD_S3 = "https://s3.datasets.leggedrobotics.com"
|
|
31
59
|
|
|
32
|
-
|
|
60
|
+
|
|
61
|
+
DEFAULT_ENDPOINTS = {
|
|
62
|
+
"local": Endpoint("local", DEFAULT_LOCAL_API, DEFAULT_LOCAL_S3),
|
|
63
|
+
"dev": Endpoint("dev", DEFAULT_DEV_API, DEFAULT_DEV_S3),
|
|
64
|
+
"prod": Endpoint("prod", DEFAULT_PROD_API, DEFAULT_PROD_S3),
|
|
65
|
+
}
|
|
33
66
|
|
|
34
67
|
|
|
35
68
|
def get_env() -> Environment:
|
|
@@ -40,131 +73,132 @@ def get_env() -> Environment:
|
|
|
40
73
|
return Environment.PROD
|
|
41
74
|
|
|
42
75
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
76
|
+
@dataclass
|
|
77
|
+
class Config:
|
|
78
|
+
version: str = __version__
|
|
79
|
+
selected_endpoint: str = field(default_factory=lambda: get_env().value)
|
|
80
|
+
endpoints: Dict[str, Endpoint] = field(
|
|
81
|
+
default_factory=lambda: DEFAULT_ENDPOINTS.copy()
|
|
82
|
+
)
|
|
83
|
+
endpoint_credentials: Dict[str, Credentials] = field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def endpoint(self) -> Endpoint:
|
|
87
|
+
return self.endpoints[self.selected_endpoint]
|
|
88
|
+
|
|
89
|
+
@endpoint.setter
|
|
90
|
+
def endpoint(self, value: Endpoint) -> None:
|
|
91
|
+
self.endpoints[self.selected_endpoint] = value
|
|
46
92
|
|
|
93
|
+
@property
|
|
94
|
+
def credentials(self) -> Optional[Credentials]:
|
|
95
|
+
return self.endpoint_credentials.get(self.selected_endpoint)
|
|
47
96
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
cli_key: Optional[str] = None
|
|
97
|
+
@credentials.setter
|
|
98
|
+
def credentials(self, value: Credentials) -> None:
|
|
99
|
+
self.endpoint_credentials[self.selected_endpoint] = value
|
|
52
100
|
|
|
53
101
|
|
|
54
|
-
|
|
55
|
-
|
|
102
|
+
def _config_to_dict(config: Config) -> Dict[str, Any]:
|
|
103
|
+
return {
|
|
104
|
+
"version": config.version,
|
|
105
|
+
"endpoints": {key: value._asdict() for key, value in config.endpoints.items()},
|
|
106
|
+
"endpoint_credentials": {
|
|
107
|
+
key: value._asdict() for key, value in config.endpoint_credentials.items()
|
|
108
|
+
},
|
|
109
|
+
"selected_endpoint": config.endpoint.name,
|
|
110
|
+
}
|
|
56
111
|
|
|
57
112
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if not CONFIG_PATH.exists():
|
|
69
|
-
self.save()
|
|
70
|
-
|
|
71
|
-
try:
|
|
72
|
-
self._read_config()
|
|
73
|
-
except InvalidConfigFile:
|
|
74
|
-
if not overwrite:
|
|
75
|
-
self.credentials = {}
|
|
76
|
-
self.endpoint = default_endpoint
|
|
77
|
-
self.save()
|
|
78
|
-
else:
|
|
79
|
-
raise
|
|
80
|
-
|
|
81
|
-
def _read_config(self) -> None:
|
|
82
|
-
with open(CONFIG_PATH, "r") as file:
|
|
83
|
-
try:
|
|
84
|
-
content = json.load(file)
|
|
85
|
-
except Exception:
|
|
86
|
-
raise InvalidConfigFile
|
|
87
|
-
|
|
88
|
-
endpoint = content.get(JSON_ENDPOINT_KEY, None)
|
|
89
|
-
if not isinstance(endpoint, str):
|
|
90
|
-
raise InvalidConfigFile
|
|
91
|
-
|
|
92
|
-
credentials = content.get(JSON_CREDENTIALS_KEY, None)
|
|
93
|
-
if not isinstance(credentials, dict):
|
|
94
|
-
raise InvalidConfigFile
|
|
95
|
-
|
|
96
|
-
try:
|
|
97
|
-
parsed_creds = {}
|
|
98
|
-
for ep, creds in credentials.items():
|
|
99
|
-
parsed_creds[ep] = Credentials(**creds)
|
|
100
|
-
except Exception:
|
|
101
|
-
raise InvalidConfigFile
|
|
102
|
-
|
|
103
|
-
self.endpoint = endpoint
|
|
104
|
-
self.credentials = parsed_creds
|
|
113
|
+
def _config_from_dict(dct: Dict[str, Any]) -> Config:
|
|
114
|
+
return Config(
|
|
115
|
+
dct["version"],
|
|
116
|
+
dct["selected_endpoint"],
|
|
117
|
+
{key: Endpoint(**value) for key, value in dct["endpoints"].items()},
|
|
118
|
+
{
|
|
119
|
+
key: Credentials(**value)
|
|
120
|
+
for key, value in dct["endpoint_credentials"].items()
|
|
121
|
+
},
|
|
122
|
+
)
|
|
105
123
|
|
|
106
|
-
@property
|
|
107
|
-
def has_cli_key(self) -> bool:
|
|
108
|
-
if self.endpoint not in self.credentials:
|
|
109
|
-
return False
|
|
110
|
-
return self.credentials[self.endpoint].cli_key is not None
|
|
111
124
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
125
|
+
def save_config(config: Config, path: Path = CONFIG_PATH) -> None:
|
|
126
|
+
fd, temp_path = tempfile.mkstemp()
|
|
127
|
+
with os.fdopen(fd, "w") as f:
|
|
128
|
+
json.dump(_config_to_dict(config), f)
|
|
129
|
+
os.replace(temp_path, path)
|
|
117
130
|
|
|
118
|
-
@property
|
|
119
|
-
def auth_token(self) -> Optional[str]:
|
|
120
|
-
return self.credentials[self.endpoint].auth_token
|
|
121
131
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return
|
|
132
|
+
def _load_config(*, path: Path = CONFIG_PATH) -> Config:
|
|
133
|
+
if not path.exists():
|
|
134
|
+
return Config()
|
|
135
|
+
with open(path, "r") as f:
|
|
136
|
+
return _config_from_dict(json.load(f))
|
|
125
137
|
|
|
126
|
-
@property
|
|
127
|
-
def cli_key(self) -> Optional[str]:
|
|
128
|
-
return self.credentials[self.endpoint].cli_key
|
|
129
138
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
139
|
+
LOADED_CONFIGS: Dict[Path, Config] = {}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_config(path: Path = CONFIG_PATH) -> Config:
|
|
143
|
+
if path not in LOADED_CONFIGS:
|
|
144
|
+
LOADED_CONFIGS[path] = _load_config(path=path)
|
|
145
|
+
return LOADED_CONFIGS[path]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def select_endpoint(config: Config, name: str, path: Path = CONFIG_PATH) -> None:
|
|
149
|
+
if name not in config.endpoints:
|
|
150
|
+
raise ValueError(f"Endpoint {name} not found.")
|
|
151
|
+
config.selected_endpoint = name
|
|
152
|
+
save_config(config, path)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def add_endpoint(config: Config, endpoint: Endpoint, path: Path = CONFIG_PATH) -> None:
|
|
156
|
+
config.endpoints[endpoint.name] = endpoint
|
|
157
|
+
config.selected_endpoint = endpoint.name
|
|
158
|
+
save_config(config, path)
|
|
159
|
+
|
|
134
160
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
161
|
+
def check_config_compatibility(path: Path = CONFIG_PATH) -> bool:
|
|
162
|
+
"""\
|
|
163
|
+
returns `False` if config file exists but is not compatible with the current version
|
|
139
164
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
165
|
+
TODO: add more sophisticated version checking
|
|
166
|
+
"""
|
|
167
|
+
if not path.exists():
|
|
168
|
+
return True
|
|
169
|
+
try:
|
|
170
|
+
_ = _load_config(path=path)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.info(f"Error loading config: {e}")
|
|
173
|
+
return False
|
|
174
|
+
return True
|
|
144
175
|
|
|
145
|
-
os.replace(tmp_path, CONFIG_PATH)
|
|
146
176
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
self.save()
|
|
177
|
+
def endpoint_table(config: Config) -> Table:
|
|
178
|
+
table = Table(title="Available Endpoints")
|
|
179
|
+
table.add_column("Name", style="cyan")
|
|
180
|
+
table.add_column("API", style="cyan")
|
|
181
|
+
table.add_column("S3", style="cyan")
|
|
153
182
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
183
|
+
for name, endpoint in config.endpoints.items():
|
|
184
|
+
display_name = (
|
|
185
|
+
Text(name, style="bold yellow")
|
|
186
|
+
if name == config.selected_endpoint
|
|
187
|
+
else Text(name)
|
|
188
|
+
)
|
|
189
|
+
table.add_row(display_name, endpoint.api, endpoint.s3)
|
|
190
|
+
return table
|
|
157
191
|
|
|
158
192
|
|
|
159
193
|
@dataclass
|
|
160
|
-
class
|
|
194
|
+
class SharedState:
|
|
161
195
|
log_file: Optional[Path] = None
|
|
162
196
|
verbose: bool = True
|
|
163
197
|
debug: bool = False
|
|
164
198
|
|
|
165
199
|
|
|
166
|
-
SHARED_STATE =
|
|
200
|
+
SHARED_STATE = SharedState()
|
|
167
201
|
|
|
168
202
|
|
|
169
|
-
def get_shared_state() ->
|
|
203
|
+
def get_shared_state() -> SharedState:
|
|
170
204
|
return SHARED_STATE
|
kleinkram/core.py
CHANGED
|
@@ -1,14 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
this file contains the main functionality of kleinkram cli
|
|
3
|
+
|
|
4
|
+
- download
|
|
5
|
+
- upload
|
|
6
|
+
- verify
|
|
7
|
+
- update_file
|
|
8
|
+
- update_mission
|
|
9
|
+
- update_project
|
|
10
|
+
- delete_files
|
|
11
|
+
- delete_mission
|
|
12
|
+
- delete_project
|
|
13
|
+
"""
|
|
14
|
+
|
|
1
15
|
from __future__ import annotations
|
|
2
16
|
|
|
3
17
|
from pathlib import Path
|
|
18
|
+
from typing import Collection
|
|
19
|
+
from typing import Dict
|
|
4
20
|
from typing import List
|
|
21
|
+
from typing import Optional
|
|
22
|
+
from typing import Sequence
|
|
5
23
|
from uuid import UUID
|
|
6
24
|
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from tqdm import tqdm
|
|
27
|
+
|
|
28
|
+
import kleinkram.api.file_transfer
|
|
29
|
+
import kleinkram.api.routes
|
|
30
|
+
import kleinkram.errors
|
|
31
|
+
from kleinkram.api.client import AuthenticatedClient
|
|
32
|
+
from kleinkram.api.query import FileQuery
|
|
33
|
+
from kleinkram.api.query import MissionQuery
|
|
34
|
+
from kleinkram.api.query import ProjectQuery
|
|
35
|
+
from kleinkram.api.query import check_mission_query_is_creatable
|
|
36
|
+
from kleinkram.errors import MissionNotFound
|
|
37
|
+
from kleinkram.models import FileState
|
|
38
|
+
from kleinkram.models import FileVerificationStatus
|
|
39
|
+
from kleinkram.printing import files_to_table
|
|
40
|
+
from kleinkram.utils import b64_md5
|
|
41
|
+
from kleinkram.utils import check_file_paths
|
|
42
|
+
from kleinkram.utils import file_paths_from_files
|
|
43
|
+
from kleinkram.utils import get_filename_map
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def download(
|
|
47
|
+
*,
|
|
48
|
+
client: AuthenticatedClient,
|
|
49
|
+
query: FileQuery,
|
|
50
|
+
base_dir: Path,
|
|
51
|
+
nested: bool = False,
|
|
52
|
+
overwrite: bool = False,
|
|
53
|
+
verbose: bool = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""\
|
|
56
|
+
downloads files, asserts that the destition dir exists
|
|
57
|
+
returns the files that were downloaded
|
|
58
|
+
|
|
59
|
+
TODO: the above is a lie, at the moment we just return all files that were found
|
|
60
|
+
this might include some files that were skipped or not downloaded for some reason
|
|
61
|
+
we would need to modify the `download_files` function to return this in the future
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
if not base_dir.exists():
|
|
65
|
+
raise ValueError(f"Destination {base_dir.absolute()} does not exist")
|
|
66
|
+
if not base_dir.is_dir():
|
|
67
|
+
raise ValueError(f"Destination {base_dir.absolute()} is not a directory")
|
|
68
|
+
|
|
69
|
+
# retrive files and get the destination paths
|
|
70
|
+
files = list(kleinkram.api.routes.get_files(client, file_query=query))
|
|
71
|
+
paths = file_paths_from_files(files, dest=base_dir, allow_nested=nested)
|
|
72
|
+
|
|
73
|
+
if verbose:
|
|
74
|
+
table = files_to_table(files, title="downloading files...")
|
|
75
|
+
Console().print(table)
|
|
76
|
+
|
|
77
|
+
kleinkram.api.file_transfer.download_files(
|
|
78
|
+
client, paths, verbose=verbose, overwrite=overwrite
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def upload(
|
|
83
|
+
*,
|
|
84
|
+
client: AuthenticatedClient,
|
|
85
|
+
query: MissionQuery,
|
|
86
|
+
file_paths: Sequence[Path],
|
|
87
|
+
create: bool = False,
|
|
88
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
89
|
+
ignore_missing_metadata: bool = False,
|
|
90
|
+
verbose: bool = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""\
|
|
93
|
+
uploads files to a mission
|
|
94
|
+
|
|
95
|
+
create a mission if it does not exist if `create` is True
|
|
96
|
+
in that case you can also specify `metadata` and `ignore_missing_metadata`
|
|
97
|
+
"""
|
|
98
|
+
# check that file paths are for valid files and have valid suffixes
|
|
99
|
+
check_file_paths(file_paths)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
mission = kleinkram.api.routes.get_mission(client, query=query)
|
|
103
|
+
except MissionNotFound:
|
|
104
|
+
if not create:
|
|
105
|
+
raise
|
|
106
|
+
mission = None
|
|
107
|
+
|
|
108
|
+
if create and mission is None:
|
|
109
|
+
# check if project exists and get its id at the same time
|
|
110
|
+
project_id = kleinkram.api.routes.get_project(
|
|
111
|
+
client, query=query.project_query
|
|
112
|
+
).id
|
|
113
|
+
mission_name = check_mission_query_is_creatable(query)
|
|
114
|
+
kleinkram.api.routes._create_mission(
|
|
115
|
+
client,
|
|
116
|
+
project_id,
|
|
117
|
+
mission_name,
|
|
118
|
+
metadata=metadata or {},
|
|
119
|
+
ignore_missing_tags=ignore_missing_metadata,
|
|
120
|
+
)
|
|
121
|
+
mission = kleinkram.api.routes.get_mission(client, query)
|
|
122
|
+
|
|
123
|
+
assert mission is not None, "unreachable"
|
|
124
|
+
|
|
125
|
+
filename_map = get_filename_map(file_paths)
|
|
126
|
+
kleinkram.api.file_transfer.upload_files(
|
|
127
|
+
client, filename_map, mission.id, verbose=verbose
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def verify(
|
|
132
|
+
*,
|
|
133
|
+
client: AuthenticatedClient,
|
|
134
|
+
query: MissionQuery,
|
|
135
|
+
file_paths: Sequence[Path],
|
|
136
|
+
skip_hash: bool = False,
|
|
137
|
+
verbose: bool = False,
|
|
138
|
+
) -> Dict[Path, FileVerificationStatus]:
|
|
139
|
+
# check that file paths are for valid files and have valid suffixes
|
|
140
|
+
check_file_paths(file_paths)
|
|
141
|
+
|
|
142
|
+
# check that the mission exists
|
|
143
|
+
_ = kleinkram.api.routes.get_mission(client, query)
|
|
144
|
+
|
|
145
|
+
remote_files = {
|
|
146
|
+
f.name: f
|
|
147
|
+
for f in kleinkram.api.routes.get_files(
|
|
148
|
+
client, file_query=FileQuery(mission_query=query)
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
filename_map = get_filename_map(file_paths)
|
|
152
|
+
|
|
153
|
+
# verify files
|
|
154
|
+
file_status: Dict[Path, FileVerificationStatus] = {}
|
|
155
|
+
for name, file in tqdm(
|
|
156
|
+
filename_map.items(),
|
|
157
|
+
desc="verifying files",
|
|
158
|
+
unit="file",
|
|
159
|
+
disable=not verbose,
|
|
160
|
+
):
|
|
161
|
+
if name not in remote_files:
|
|
162
|
+
file_status[file] = FileVerificationStatus.MISSING
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
remote_file = remote_files[name]
|
|
166
|
+
|
|
167
|
+
if remote_file.state == FileState.UPLOADING:
|
|
168
|
+
file_status[file] = FileVerificationStatus.UPLOADING
|
|
169
|
+
elif remote_file.state == FileState.OK:
|
|
170
|
+
if remote_file.hash is None:
|
|
171
|
+
file_status[file] = FileVerificationStatus.COMPUTING_HASH
|
|
172
|
+
elif skip_hash or remote_file.hash == b64_md5(file):
|
|
173
|
+
file_status[file] = FileVerificationStatus.UPLAODED
|
|
174
|
+
else:
|
|
175
|
+
file_status[file] = FileVerificationStatus.MISMATCHED_HASH
|
|
176
|
+
else:
|
|
177
|
+
file_status[file] = FileVerificationStatus.UNKNOWN
|
|
178
|
+
return file_status
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def update_file(*, client: AuthenticatedClient, file_id: UUID) -> None:
|
|
182
|
+
"""\
|
|
183
|
+
TODO: what should this even do
|
|
184
|
+
"""
|
|
185
|
+
_ = client, file_id
|
|
186
|
+
raise NotImplementedError("if you have an idea what this should do, open an issue")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def update_mission(
|
|
190
|
+
*, client: AuthenticatedClient, mission_id: UUID, metadata: Dict[str, str]
|
|
191
|
+
) -> None:
|
|
192
|
+
# TODO: this funciton will do more than just overwirte the metadata in the future
|
|
193
|
+
kleinkram.api.routes._update_mission(client, mission_id, metadata=metadata)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def update_project(
|
|
197
|
+
*,
|
|
198
|
+
client: AuthenticatedClient,
|
|
199
|
+
project_id: UUID,
|
|
200
|
+
description: Optional[str] = None,
|
|
201
|
+
new_name: Optional[str] = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
# TODO: this function should do more in the future
|
|
204
|
+
kleinkram.api.routes._update_project(
|
|
205
|
+
client, project_id, description=description, new_name=new_name
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def delete_files(*, client: AuthenticatedClient, file_ids: Collection[UUID]) -> None:
|
|
210
|
+
"""\
|
|
211
|
+
deletes multiple files accross multiple missions
|
|
212
|
+
"""
|
|
213
|
+
files = list(kleinkram.api.routes.get_files(client, FileQuery(ids=list(file_ids))))
|
|
214
|
+
|
|
215
|
+
# check if all file_ids were actually found
|
|
216
|
+
found_ids = [f.id for f in files]
|
|
217
|
+
for file_id in file_ids:
|
|
218
|
+
if file_id not in found_ids:
|
|
219
|
+
raise kleinkram.errors.FileNotFound(
|
|
220
|
+
f"file {file_id} not found, did not delete any files"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# we can only batch delete files within the same mission
|
|
224
|
+
missions_to_files: Dict[UUID, List[UUID]] = {}
|
|
225
|
+
for file in files:
|
|
226
|
+
if file.mission_id not in missions_to_files:
|
|
227
|
+
missions_to_files[file.mission_id] = []
|
|
228
|
+
missions_to_files[file.mission_id].append(file.id)
|
|
229
|
+
|
|
230
|
+
for mission_id, ids_ in missions_to_files.items():
|
|
231
|
+
kleinkram.api.routes._delete_files(client, file_ids=ids_, mission_id=mission_id)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def delete_mission(*, client: AuthenticatedClient, mission_id: UUID) -> None:
|
|
235
|
+
mquery = MissionQuery(ids=[mission_id])
|
|
236
|
+
mission = kleinkram.api.routes.get_mission(client, mquery)
|
|
237
|
+
files = list(
|
|
238
|
+
kleinkram.api.routes.get_files(
|
|
239
|
+
client, file_query=FileQuery(mission_query=mquery)
|
|
240
|
+
)
|
|
241
|
+
)
|
|
7
242
|
|
|
8
|
-
|
|
243
|
+
# delete the files and then the mission
|
|
244
|
+
kleinkram.api.routes._delete_files(client, [f.id for f in files], mission.id)
|
|
245
|
+
kleinkram.api.routes._delete_mission(client, mission_id)
|
|
9
246
|
|
|
10
247
|
|
|
11
|
-
def
|
|
248
|
+
def delete_project(*, client: AuthenticatedClient, project_id: UUID) -> None:
|
|
249
|
+
pquery = ProjectQuery(ids=[project_id])
|
|
250
|
+
_ = kleinkram.api.routes.get_project(client, pquery) # check if project exists
|
|
12
251
|
|
|
252
|
+
# delete all missions and files
|
|
253
|
+
missions = list(
|
|
254
|
+
kleinkram.api.routes.get_missions(
|
|
255
|
+
client, mission_query=MissionQuery(project_query=pquery)
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
for mission in missions:
|
|
259
|
+
delete_mission(client=client, mission_id=mission.id)
|
|
13
260
|
|
|
14
|
-
|
|
261
|
+
# delete the project
|
|
262
|
+
kleinkram.api.routes._delete_project(client, project_id)
|