kleinkram 0.32.3__tar.gz → 0.33.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.
- {kleinkram-0.32.3 → kleinkram-0.33.0}/PKG-INFO +1 -1
- {kleinkram-0.32.3 → kleinkram-0.33.0}/pyproject.toml +1 -1
- {kleinkram-0.32.3 → kleinkram-0.33.0}/requirements.txt +2 -1
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/endpoint/endpoint.py +2 -1
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/error_handling.py +29 -2
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/helper.py +71 -41
- kleinkram-0.33.0/src/kleinkram/main.py +381 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/mission/mission.py +56 -17
- kleinkram-0.33.0/src/kleinkram/project/project.py +138 -0
- kleinkram-0.32.3/src/kleinkram/main.py +0 -284
- kleinkram-0.32.3/src/kleinkram/project/project.py +0 -58
- {kleinkram-0.32.3 → kleinkram-0.33.0}/.gitignore +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/LICENSE +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/README.md +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/deploy.sh +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/dev.sh +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/klein.py +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/__init__.py +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/api_client.py +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/auth/auth.py +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/consts.py +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/file/file.py +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/queue/queue.py +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/tag/tag.py +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/topic/topic.py +0 -0
- {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/user/user.py +0 -0
|
@@ -4,7 +4,8 @@ from kleinkram.auth.auth import TokenFile
|
|
|
4
4
|
|
|
5
5
|
endpoint = typer.Typer(
|
|
6
6
|
name="endpoint",
|
|
7
|
-
help="Get Or Set the current endpoint"
|
|
7
|
+
help="Get Or Set the current endpoint.\n\nThe endpoint is used to determine the API server to connect to"
|
|
8
|
+
"(default is the API server of https://datasets.leggedrobotics.com).",
|
|
8
9
|
no_args_is_help=True,
|
|
9
10
|
context_settings={"help_option_names": ["-h", "--help"]},
|
|
10
11
|
)
|
|
@@ -13,6 +13,12 @@ ExceptionType = "typing.Type[Exception]"
|
|
|
13
13
|
ErrorHandlingCallback = typing.Callable[[Exception], int]
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
class AbortException(Exception):
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str):
|
|
19
|
+
self.message = message
|
|
20
|
+
|
|
21
|
+
|
|
16
22
|
class AccessDeniedException(Exception):
|
|
17
23
|
|
|
18
24
|
def __init__(self, message: str, api_error: str):
|
|
@@ -22,8 +28,9 @@ class AccessDeniedException(Exception):
|
|
|
22
28
|
|
|
23
29
|
def not_yet_implemented_handler(e: Exception):
|
|
24
30
|
console = Console(file=sys.stderr)
|
|
31
|
+
default_msg = "This feature is not yet implemented. Please check for updates or use the web interface."
|
|
25
32
|
panel = Panel(
|
|
26
|
-
"
|
|
33
|
+
f"{default_msg}",
|
|
27
34
|
title="Not Yet Implemented",
|
|
28
35
|
style="yellow",
|
|
29
36
|
padding=(1, 2),
|
|
@@ -105,6 +112,20 @@ def remote_down_handler(e: Exception):
|
|
|
105
112
|
print()
|
|
106
113
|
|
|
107
114
|
|
|
115
|
+
def abort_handler(e: AbortException):
|
|
116
|
+
console = Console(file=sys.stderr)
|
|
117
|
+
panel = Panel(
|
|
118
|
+
f"{e.message}",
|
|
119
|
+
title="Command Aborted",
|
|
120
|
+
style="yellow",
|
|
121
|
+
padding=(1, 2),
|
|
122
|
+
highlight=True,
|
|
123
|
+
)
|
|
124
|
+
print()
|
|
125
|
+
console.print(panel)
|
|
126
|
+
print()
|
|
127
|
+
|
|
128
|
+
|
|
108
129
|
class ErrorHandledTyper(Typer):
|
|
109
130
|
error_handlers: typing.Dict[ExceptionType, ErrorHandlingCallback] = {
|
|
110
131
|
NotAuthenticatedException: not_authenticated_handler,
|
|
@@ -114,6 +135,7 @@ class ErrorHandledTyper(Typer):
|
|
|
114
135
|
ValueError: value_error_handler,
|
|
115
136
|
RemoteProtocolError: remote_down_handler,
|
|
116
137
|
ReadError: remote_down_handler,
|
|
138
|
+
AbortException: abort_handler,
|
|
117
139
|
}
|
|
118
140
|
|
|
119
141
|
def __init__(self, *args, **kwargs):
|
|
@@ -138,11 +160,16 @@ class ErrorHandledTyper(Typer):
|
|
|
138
160
|
f"An unhanded error of type {type(e).__name__} occurred.",
|
|
139
161
|
fg=typer.colors.RED,
|
|
140
162
|
)
|
|
141
|
-
|
|
163
|
+
|
|
142
164
|
typer.secho(
|
|
143
165
|
" » Please report this error to the developers.",
|
|
144
166
|
fg=typer.colors.RED,
|
|
145
167
|
)
|
|
168
|
+
|
|
169
|
+
typer.secho(f"\n\n{e}:", fg=typer.colors.RED)
|
|
170
|
+
console = Console()
|
|
171
|
+
console.print_exception(show_locals=True)
|
|
172
|
+
|
|
146
173
|
else:
|
|
147
174
|
self.error_handlers[type(e)](e)
|
|
148
175
|
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import glob
|
|
2
2
|
import os
|
|
3
3
|
import queue
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
4
6
|
import threading
|
|
5
7
|
from datetime import datetime
|
|
6
8
|
from functools import partial
|
|
7
9
|
|
|
8
|
-
import typer
|
|
9
|
-
from botocore.config import Config
|
|
10
|
-
from botocore.utils import calculate_md5
|
|
11
|
-
from typing_extensions import Dict, List
|
|
12
10
|
import boto3
|
|
13
|
-
|
|
14
11
|
import tqdm
|
|
12
|
+
import typer
|
|
15
13
|
from boto3.s3.transfer import TransferConfig
|
|
16
|
-
from botocore.
|
|
14
|
+
from botocore.config import Config
|
|
15
|
+
from botocore.utils import calculate_md5
|
|
17
16
|
from rich import print
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from typing_extensions import Dict
|
|
18
19
|
|
|
19
20
|
from kleinkram.api_client import AuthenticatedClient
|
|
20
21
|
|
|
@@ -106,84 +107,101 @@ def expand_and_match(path_pattern):
|
|
|
106
107
|
return file_list
|
|
107
108
|
|
|
108
109
|
|
|
109
|
-
def uploadFiles(
|
|
110
|
+
def uploadFiles(
|
|
111
|
+
files_with_access: Dict[str, object], paths: Dict[str, str], nrThreads: int
|
|
112
|
+
):
|
|
110
113
|
client = AuthenticatedClient()
|
|
111
114
|
|
|
112
|
-
session = boto3.Session(
|
|
113
|
-
aws_access_key_id=credentials["accessKey"],
|
|
114
|
-
aws_secret_access_key=credentials["secretKey"],
|
|
115
|
-
aws_session_token=credentials["sessionToken"],
|
|
116
|
-
)
|
|
117
115
|
api_endpoint = client.tokenfile.endpoint
|
|
118
116
|
if api_endpoint == "http://localhost:3000":
|
|
119
117
|
minio_endpoint = "http://localhost:9000"
|
|
120
118
|
else:
|
|
121
119
|
minio_endpoint = api_endpoint.replace("api", "minio")
|
|
122
120
|
|
|
123
|
-
config = Config(retries={"max_attempts": 10, "mode": "standard"})
|
|
124
|
-
s3 = session.resource("s3", endpoint_url=minio_endpoint, config=config)
|
|
125
|
-
|
|
126
121
|
_queue = queue.Queue()
|
|
127
|
-
for
|
|
128
|
-
_queue.put(
|
|
122
|
+
for file_with_access in files_with_access:
|
|
123
|
+
_queue.put((file_with_access, str(paths[file_with_access["fileName"]])))
|
|
124
|
+
|
|
129
125
|
threads = []
|
|
130
|
-
|
|
131
|
-
failed_uploads = []
|
|
126
|
+
transfer_callback = TransferCallback()
|
|
132
127
|
|
|
133
128
|
for i in range(nrThreads):
|
|
134
129
|
thread = threading.Thread(
|
|
135
|
-
target=uploadFile,
|
|
130
|
+
target=uploadFile,
|
|
131
|
+
args=(_queue, minio_endpoint, transfer_callback),
|
|
136
132
|
)
|
|
137
133
|
thread.start()
|
|
138
134
|
threads.append(thread)
|
|
139
135
|
for thread in threads:
|
|
140
136
|
thread.join()
|
|
141
|
-
if len(failed_uploads) > 0:
|
|
142
|
-
print("Failed to upload the following files:")
|
|
143
|
-
for file in failed_uploads:
|
|
144
|
-
print(file)
|
|
145
137
|
|
|
146
138
|
|
|
147
139
|
def uploadFile(
|
|
148
140
|
_queue: queue.Queue,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
failed_uploads: List[str],
|
|
141
|
+
minio_endpoint: str,
|
|
142
|
+
transfer_callback: TransferCallback,
|
|
152
143
|
):
|
|
144
|
+
config = Config(retries={"max_attempts": 10, "mode": "standard"})
|
|
145
|
+
|
|
153
146
|
while True:
|
|
154
147
|
try:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
148
|
+
file_with_access, filepath = _queue.get(timeout=3)
|
|
149
|
+
|
|
150
|
+
if "error" in file_with_access and (
|
|
151
|
+
file_with_access["error"] is not None or file_with_access["error"] != ""
|
|
152
|
+
):
|
|
153
|
+
console = Console(file=sys.stderr, style="red", highlight=False)
|
|
154
|
+
console.print(
|
|
155
|
+
f"Error uploading file: {file_with_access['fileName']} ({filepath}): {file_with_access['error']}"
|
|
156
|
+
)
|
|
157
|
+
_queue.task_done()
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
access_key = file_with_access["accessCredentials"]["accessKey"]
|
|
161
|
+
secret_key = file_with_access["accessCredentials"]["secretKey"]
|
|
162
|
+
session_token = file_with_access["accessCredentials"]["sessionToken"]
|
|
163
|
+
|
|
164
|
+
session = boto3.Session(
|
|
165
|
+
aws_access_key_id=access_key,
|
|
166
|
+
aws_secret_access_key=secret_key,
|
|
167
|
+
aws_session_token=session_token,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
s3 = session.resource("s3", endpoint_url=minio_endpoint, config=config)
|
|
171
|
+
|
|
172
|
+
fileu_uid = file_with_access["fileUUID"]
|
|
173
|
+
bucket = file_with_access["bucket"]
|
|
174
|
+
|
|
175
|
+
transfer_config = TransferConfig(
|
|
176
|
+
multipart_chunksize=10 * 1024 * 1024,
|
|
177
|
+
max_concurrency=5,
|
|
162
178
|
)
|
|
163
179
|
with open(filepath, "rb") as f:
|
|
164
180
|
md5_checksum = calculate_md5(f)
|
|
165
181
|
file_size = os.path.getsize(filepath)
|
|
166
|
-
|
|
167
|
-
callback_function = create_transfer_callback(
|
|
182
|
+
transfer_callback.add_file(filepath, file_size)
|
|
183
|
+
callback_function = create_transfer_callback(
|
|
184
|
+
transfer_callback, filepath
|
|
185
|
+
)
|
|
168
186
|
s3.Bucket(bucket).upload_file(
|
|
169
187
|
filepath,
|
|
170
|
-
|
|
171
|
-
Config=
|
|
188
|
+
fileu_uid,
|
|
189
|
+
Config=transfer_config,
|
|
172
190
|
Callback=callback_function,
|
|
173
191
|
)
|
|
174
192
|
|
|
175
193
|
client = AuthenticatedClient()
|
|
176
194
|
res = client.post(
|
|
177
195
|
"/queue/confirmUpload",
|
|
178
|
-
json={"uuid":
|
|
196
|
+
json={"uuid": fileu_uid, "md5": md5_checksum},
|
|
179
197
|
)
|
|
180
198
|
res.raise_for_status()
|
|
181
199
|
_queue.task_done()
|
|
182
200
|
except queue.Empty:
|
|
183
201
|
break
|
|
184
202
|
except Exception as e:
|
|
185
|
-
print(
|
|
186
|
-
|
|
203
|
+
print("Error uploading file: " + filepath)
|
|
204
|
+
print(e)
|
|
187
205
|
_queue.task_done()
|
|
188
206
|
|
|
189
207
|
|
|
@@ -235,6 +253,18 @@ def promptForTags(setTags: Dict[str, str], requiredTags: Dict[str, str]):
|
|
|
235
253
|
setTags[required_tag["uuid"]] = tag_value
|
|
236
254
|
|
|
237
255
|
|
|
256
|
+
def is_valid_UUIDv4(uuid: str) -> bool:
|
|
257
|
+
has_correct_length = len(uuid) == 36
|
|
258
|
+
|
|
259
|
+
# is UUID4
|
|
260
|
+
uuid_regex = (
|
|
261
|
+
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
262
|
+
)
|
|
263
|
+
is_valid_uuid = re.match(uuid_regex, uuid)
|
|
264
|
+
|
|
265
|
+
return has_correct_length and is_valid_uuid
|
|
266
|
+
|
|
267
|
+
|
|
238
268
|
if __name__ == "__main__":
|
|
239
269
|
res = expand_and_match(
|
|
240
270
|
"~/Downloads/dodo_mission_2024_02_08-20240408T074313Z-003/**.bag"
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import typer
|
|
8
|
+
from rich import print
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from typer.core import TyperGroup
|
|
11
|
+
from typer.models import Context
|
|
12
|
+
from typing_extensions import Annotated, List, Optional
|
|
13
|
+
|
|
14
|
+
from kleinkram.api_client import AuthenticatedClient
|
|
15
|
+
from kleinkram.auth.auth import login, setCliKey, logout
|
|
16
|
+
from kleinkram.endpoint.endpoint import endpoint
|
|
17
|
+
from kleinkram.error_handling import (
|
|
18
|
+
ErrorHandledTyper,
|
|
19
|
+
AccessDeniedException,
|
|
20
|
+
)
|
|
21
|
+
from kleinkram.file.file import file
|
|
22
|
+
from kleinkram.mission.mission import missionCommands
|
|
23
|
+
from kleinkram.project.project import project
|
|
24
|
+
from kleinkram.queue.queue import queue
|
|
25
|
+
from kleinkram.tag.tag import tag
|
|
26
|
+
from kleinkram.topic.topic import topic
|
|
27
|
+
from kleinkram.user.user import user
|
|
28
|
+
from .helper import (
|
|
29
|
+
uploadFiles,
|
|
30
|
+
expand_and_match,
|
|
31
|
+
canUploadMission,
|
|
32
|
+
promptForTags,
|
|
33
|
+
is_valid_UUIDv4,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CommandPanel(str, Enum):
|
|
38
|
+
CoreCommands = "CORE COMMANDS"
|
|
39
|
+
Commands = "COMMANDS"
|
|
40
|
+
AdditionalCommands = "ADDITIONAL COMMANDS"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def version_callback(value: bool):
|
|
44
|
+
if value:
|
|
45
|
+
try:
|
|
46
|
+
_version = importlib.metadata.version("kleinkram")
|
|
47
|
+
except importlib.metadata.PackageNotFoundError:
|
|
48
|
+
_version = "local"
|
|
49
|
+
typer.echo(f"CLI Version: {_version}")
|
|
50
|
+
raise typer.Exit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OrderCommands(TyperGroup):
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
The following code snippet is taken from https://github.com/tiangolo/typer/discussions/855 (see comment
|
|
57
|
+
https://github.com/tiangolo/typer/discussions/855#discussioncomment-9824582) and adapted to our use case.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def list_commands(self, _ctx: Context) -> List[str]:
|
|
61
|
+
order = list(CommandPanel)
|
|
62
|
+
grouped_commands = {
|
|
63
|
+
name: getattr(command, "rich_help_panel")
|
|
64
|
+
for name, command in sorted(self.commands.items())
|
|
65
|
+
if getattr(command, "rich_help_panel") in order
|
|
66
|
+
}
|
|
67
|
+
ungrouped_command_names = [
|
|
68
|
+
command.name
|
|
69
|
+
for command in self.commands.values()
|
|
70
|
+
if command.name not in grouped_commands
|
|
71
|
+
]
|
|
72
|
+
return [
|
|
73
|
+
name
|
|
74
|
+
for name, command in sorted(
|
|
75
|
+
grouped_commands.items(),
|
|
76
|
+
key=lambda item: order.index(item[1]),
|
|
77
|
+
)
|
|
78
|
+
] + sorted(ungrouped_command_names)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
app = ErrorHandledTyper(
|
|
82
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
83
|
+
no_args_is_help=True,
|
|
84
|
+
cls=OrderCommands,
|
|
85
|
+
help=f"Kleinkram CLI\n\nThe Kleinkram CLI is a command line interface for Kleinkram. "
|
|
86
|
+
f"For a list of available commands, run 'klein --help' or visit "
|
|
87
|
+
f"https://docs.datasets.leggedrobotics.com/usage/cli/cli-getting-started.html for more information.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.callback()
|
|
92
|
+
def version(
|
|
93
|
+
version: bool = typer.Option(
|
|
94
|
+
None,
|
|
95
|
+
"--version",
|
|
96
|
+
"-v",
|
|
97
|
+
callback=version_callback,
|
|
98
|
+
is_eager=True,
|
|
99
|
+
help="Print the version and exit",
|
|
100
|
+
)
|
|
101
|
+
):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
app.add_typer(project, rich_help_panel=CommandPanel.Commands)
|
|
106
|
+
app.add_typer(missionCommands, rich_help_panel=CommandPanel.Commands)
|
|
107
|
+
|
|
108
|
+
app.add_typer(topic, rich_help_panel=CommandPanel.Commands)
|
|
109
|
+
app.add_typer(file, rich_help_panel=CommandPanel.Commands)
|
|
110
|
+
app.add_typer(queue, rich_help_panel=CommandPanel.Commands)
|
|
111
|
+
app.add_typer(user, rich_help_panel=CommandPanel.Commands)
|
|
112
|
+
app.add_typer(tag, rich_help_panel=CommandPanel.Commands)
|
|
113
|
+
app.add_typer(endpoint, rich_help_panel=CommandPanel.AdditionalCommands)
|
|
114
|
+
|
|
115
|
+
app.command(rich_help_panel=CommandPanel.AdditionalCommands)(login)
|
|
116
|
+
app.command(rich_help_panel=CommandPanel.AdditionalCommands)(logout)
|
|
117
|
+
app.command(hidden=True)(setCliKey)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command("download", rich_help_panel=CommandPanel.CoreCommands)
|
|
121
|
+
def download():
|
|
122
|
+
print(
|
|
123
|
+
"Not implemented yet. Consider using the 'klein file download' or 'klein mission download' commands."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("upload", rich_help_panel=CommandPanel.CoreCommands, no_args_is_help=True)
|
|
128
|
+
def upload(
|
|
129
|
+
path: Annotated[
|
|
130
|
+
List[str],
|
|
131
|
+
typer.Option(help="Path to files to upload, Regex supported"),
|
|
132
|
+
],
|
|
133
|
+
project: Annotated[str, typer.Option(help="Name or UUID of a Project")],
|
|
134
|
+
mission: Annotated[str, typer.Option(help="Name of UUID Mission to create")],
|
|
135
|
+
tags: Annotated[
|
|
136
|
+
Optional[List[str]],
|
|
137
|
+
typer.Option(help="Tags to add to the mission"),
|
|
138
|
+
] = None,
|
|
139
|
+
fix_filenames: Annotated[
|
|
140
|
+
bool,
|
|
141
|
+
typer.Option(help="Automatically fix filenames such that they are valid"),
|
|
142
|
+
] = False,
|
|
143
|
+
create_project: Annotated[
|
|
144
|
+
bool,
|
|
145
|
+
typer.Option(help="Allows adding files to an existing mission"),
|
|
146
|
+
] = False,
|
|
147
|
+
create_mission: Annotated[
|
|
148
|
+
bool,
|
|
149
|
+
typer.Option(help="Allows adding files to an existing mission"),
|
|
150
|
+
] = False,
|
|
151
|
+
):
|
|
152
|
+
"""
|
|
153
|
+
Upload files matching the path to a mission in a project.
|
|
154
|
+
|
|
155
|
+
The mission name must be unique within the project and not yet created.\n
|
|
156
|
+
Multiple paths can be given by using the option multiple times.\n
|
|
157
|
+
Examples:\n
|
|
158
|
+
- 'klein upload --path "~/data/**/*.bag" --project "Project 1" --mission "Mission 1" --tags "0700946d-1d6a-4520-b263-0e177f49c35b:LEE-H" --tags "1565118d-593c-4517-8c2d-9658452d9319:Dodo"'\n
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
client = AuthenticatedClient()
|
|
163
|
+
|
|
164
|
+
##############################
|
|
165
|
+
# Check if project exists
|
|
166
|
+
##############################
|
|
167
|
+
if is_valid_UUIDv4(project):
|
|
168
|
+
get_project_url = "/project/one"
|
|
169
|
+
project_response = client.get(get_project_url, params={"uuid": project})
|
|
170
|
+
else:
|
|
171
|
+
get_project_url = "/project/byName"
|
|
172
|
+
project_response = client.get(get_project_url, params={"name": project})
|
|
173
|
+
|
|
174
|
+
if project_response.status_code >= 400:
|
|
175
|
+
if not create_project and not is_valid_UUIDv4(project):
|
|
176
|
+
raise AccessDeniedException(
|
|
177
|
+
f"The project '{project}' does not exist or you do not have access to it.\n"
|
|
178
|
+
f"Consider using the following command to create a project: 'klein project create' "
|
|
179
|
+
f"or consider passing the flag '--create-project' to create the project automatically.",
|
|
180
|
+
f"{project_response.json()['message']} ({project_response.status_code})",
|
|
181
|
+
)
|
|
182
|
+
elif is_valid_UUIDv4(project):
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"Project '{project}' does not exist. UUIDs cannot be used to create projects.\n"
|
|
185
|
+
f"Please provide a valid project name or consider creating the project using the"
|
|
186
|
+
f" following command: 'klein project create'"
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
print(f"Project '{project}' does not exist. Creating it now.")
|
|
190
|
+
create_project_url = "/project/create"
|
|
191
|
+
project_response = client.post(
|
|
192
|
+
create_project_url,
|
|
193
|
+
json={
|
|
194
|
+
"name": project,
|
|
195
|
+
"description": "Autogenerated by klein CLI",
|
|
196
|
+
"requiredTags": [],
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
if project_response.status_code >= 400:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
f"Failed to create project. Status Code: "
|
|
202
|
+
f"{str(project_response.status_code)}\n"
|
|
203
|
+
f"{project_response.json()['message'][0]}"
|
|
204
|
+
)
|
|
205
|
+
print("Project created successfully.")
|
|
206
|
+
|
|
207
|
+
project_json = project_response.json()
|
|
208
|
+
if not project_json["uuid"]:
|
|
209
|
+
print(f"Project not found: '{project}'")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
can_upload = canUploadMission(client, project_json["uuid"])
|
|
213
|
+
if not can_upload:
|
|
214
|
+
raise AccessDeniedException(
|
|
215
|
+
f"You do not have the required permissions to upload to project '{project}'\n",
|
|
216
|
+
"Access Denied",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if not tags:
|
|
220
|
+
tags = []
|
|
221
|
+
tags_dict = {item.split(":")[0]: item.split(":")[1] for item in tags}
|
|
222
|
+
|
|
223
|
+
required_tags = (
|
|
224
|
+
project_json["requiredTags"] if "requiredTags" in project_json else []
|
|
225
|
+
)
|
|
226
|
+
promptForTags(tags_dict, required_tags)
|
|
227
|
+
|
|
228
|
+
##############################
|
|
229
|
+
# Check if mission exists
|
|
230
|
+
##############################
|
|
231
|
+
if is_valid_UUIDv4(mission):
|
|
232
|
+
get_mission_url = "/mission/one"
|
|
233
|
+
mission_response = client.get(get_mission_url, params={"uuid": mission})
|
|
234
|
+
else:
|
|
235
|
+
get_mission_url = "/mission/byName"
|
|
236
|
+
mission_response = client.get(get_mission_url, params={"name": mission})
|
|
237
|
+
|
|
238
|
+
if mission_response.status_code >= 400:
|
|
239
|
+
if not create_mission:
|
|
240
|
+
raise AccessDeniedException(
|
|
241
|
+
f"The mission '{mission}' does not exist or you do not have access to it.\n"
|
|
242
|
+
f"Consider using the following command to create a mission: 'klein mission create' "
|
|
243
|
+
f"or consider passing the flag '--create-mission' to create the mission automatically.",
|
|
244
|
+
f"{mission_response.json()['message']} ({mission_response.status_code})",
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
print(f"Mission '{mission}' does not exist. Creating it now.")
|
|
248
|
+
create_mission_url = "/mission/create"
|
|
249
|
+
mission_response = client.post(
|
|
250
|
+
create_mission_url,
|
|
251
|
+
json={
|
|
252
|
+
"name": mission,
|
|
253
|
+
"projectUUID": project_json["uuid"],
|
|
254
|
+
"tags": tags_dict,
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
if mission_response.status_code >= 400:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"Failed to create mission. Status Code: "
|
|
260
|
+
f"{str(mission_response.status_code)}\n"
|
|
261
|
+
f"{mission_response.json()['message'][0]}"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
mission_json = mission_response.json()
|
|
265
|
+
|
|
266
|
+
files = []
|
|
267
|
+
for p in path:
|
|
268
|
+
files.extend(expand_and_match(p))
|
|
269
|
+
|
|
270
|
+
print(
|
|
271
|
+
f"Uploading the following files to mission '{mission_json['name']}' in project '{project_json['name']}':"
|
|
272
|
+
)
|
|
273
|
+
filename_filepaths_map = {}
|
|
274
|
+
for path in files:
|
|
275
|
+
if not os.path.isdir(path):
|
|
276
|
+
|
|
277
|
+
filename = path.split("/")[-1]
|
|
278
|
+
filename_without_extension, extension = os.path.splitext(filename)
|
|
279
|
+
if fix_filenames:
|
|
280
|
+
|
|
281
|
+
# replace all non-alphanumeric characters with underscores
|
|
282
|
+
filename_without_extension = "".join(
|
|
283
|
+
char if char.isalnum() else "_"
|
|
284
|
+
for char in filename_without_extension
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# trim filename to 40 characters
|
|
288
|
+
filename_without_extension = filename_without_extension[:40]
|
|
289
|
+
filename = f"{filename_without_extension}{extension}"
|
|
290
|
+
|
|
291
|
+
if (
|
|
292
|
+
not filename.replace(".", "")
|
|
293
|
+
.replace("_", "")
|
|
294
|
+
.replace("-", "")
|
|
295
|
+
.isalnum()
|
|
296
|
+
):
|
|
297
|
+
raise ValueError(
|
|
298
|
+
f"Filename '{filename}' is not valid. It must only contain alphanumeric characters, underscores and "
|
|
299
|
+
f"hyphens. Consider using the '--fix-filenames' option to automatically fix the filenames."
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if not 3 <= len(filename_without_extension) <= 40:
|
|
303
|
+
raise ValueError(
|
|
304
|
+
f"Filename '{filename}' is not valid. It must be between 3 and 40 characters long. Consider using "
|
|
305
|
+
f"the '--fix-filenames' option to automatically fix the filenames."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
filename_filepaths_map[filename] = path
|
|
309
|
+
typer.secho(f" - {filename}", fg=typer.colors.RESET)
|
|
310
|
+
print("\n\n")
|
|
311
|
+
|
|
312
|
+
filenames = list(filename_filepaths_map.keys())
|
|
313
|
+
|
|
314
|
+
if not filenames:
|
|
315
|
+
raise ValueError("No files found matching the given path.")
|
|
316
|
+
|
|
317
|
+
# validate filenames
|
|
318
|
+
if len(filenames) != len(set(filenames)):
|
|
319
|
+
raise ValueError(
|
|
320
|
+
"Filenames must be unique. Please check the files you are trying to upload. This can happen if you have "
|
|
321
|
+
"multiple files with the same name in different directories or use the '--fix-filenames' option."
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
get_temporary_credentials = "/file/temporaryAccess"
|
|
325
|
+
response = client.post(
|
|
326
|
+
get_temporary_credentials,
|
|
327
|
+
json={"filenames": filenames, "missionUUID": mission_json["uuid"]},
|
|
328
|
+
)
|
|
329
|
+
if response.status_code >= 400:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
"Failed to get temporary credentials. Status Code: "
|
|
332
|
+
+ str(response.status_code)
|
|
333
|
+
+ "\n"
|
|
334
|
+
+ response.json()["message"][0]
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
uploadFiles(response.json(), filename_filepaths_map, 4)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@queue.command("list")
|
|
341
|
+
def list_queue():
|
|
342
|
+
"""List current Queue entities"""
|
|
343
|
+
try:
|
|
344
|
+
url = "/queue/active"
|
|
345
|
+
startDate = datetime.now().date() - timedelta(days=1)
|
|
346
|
+
client = AuthenticatedClient()
|
|
347
|
+
response = client.get(url, params={"startDate": startDate})
|
|
348
|
+
response.raise_for_status()
|
|
349
|
+
data = response.json()
|
|
350
|
+
table = Table("UUID", "filename", "mission", "state", "origin", "createdAt")
|
|
351
|
+
for topic in data:
|
|
352
|
+
table.add_row(
|
|
353
|
+
topic["uuid"],
|
|
354
|
+
topic["filename"],
|
|
355
|
+
topic["mission"]["name"],
|
|
356
|
+
topic["state"],
|
|
357
|
+
topic["location"],
|
|
358
|
+
topic["createdAt"],
|
|
359
|
+
)
|
|
360
|
+
print(table)
|
|
361
|
+
|
|
362
|
+
except httpx.HTTPError as e:
|
|
363
|
+
print(e)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.command("claim", hidden=True)
|
|
367
|
+
def claim():
|
|
368
|
+
"""
|
|
369
|
+
Claim admin rights as the first user
|
|
370
|
+
|
|
371
|
+
Only works if no other user has claimed admin rights before.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
client = AuthenticatedClient()
|
|
375
|
+
response = client.post("/user/claimAdmin")
|
|
376
|
+
response.raise_for_status()
|
|
377
|
+
print("Admin claimed.")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
if __name__ == "__main__":
|
|
381
|
+
app()
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import os
|
|
2
|
-
|
|
3
|
-
from botocore.utils import calculate_md5
|
|
4
|
-
from typing_extensions import Annotated, Optional, List
|
|
2
|
+
import re
|
|
5
3
|
|
|
6
4
|
import httpx
|
|
7
5
|
import requests
|
|
8
6
|
import typer
|
|
9
7
|
from rich.console import Console
|
|
10
8
|
from rich.table import Table
|
|
9
|
+
from tqdm import tqdm
|
|
10
|
+
from typing_extensions import Annotated, Optional, List
|
|
11
11
|
|
|
12
12
|
from kleinkram.api_client import AuthenticatedClient
|
|
13
13
|
from kleinkram.error_handling import AccessDeniedException
|
|
@@ -143,15 +143,20 @@ def mission_by_uuid(
|
|
|
143
143
|
|
|
144
144
|
if json:
|
|
145
145
|
print(data)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
146
|
+
return
|
|
147
|
+
print(f"mission: {data['name']}")
|
|
148
|
+
print(f"Creator: {data['creator']['name']}")
|
|
149
|
+
print("Project: " + data["project"]["name"])
|
|
150
|
+
table = Table("Filename", "Size", "date")
|
|
151
|
+
|
|
152
|
+
if "files" not in data:
|
|
153
|
+
print("No files found for mission.")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
for file in data["files"]:
|
|
157
|
+
table.add_row(file["filename"], f"{file['size']}", file["date"])
|
|
158
|
+
console = Console()
|
|
159
|
+
console.print(table)
|
|
155
160
|
|
|
156
161
|
|
|
157
162
|
@missionCommands.command("download")
|
|
@@ -160,6 +165,11 @@ def download(
|
|
|
160
165
|
List[str], typer.Option(help="UUIDs of Mission to download")
|
|
161
166
|
],
|
|
162
167
|
local_path: Annotated[str, typer.Option()],
|
|
168
|
+
pattern: Optional[str] = typer.Option(
|
|
169
|
+
None,
|
|
170
|
+
help="Simple pattern to match the filename against. Allowed are alphanumeric characters,"
|
|
171
|
+
" '_', '-', '.' and '*' as wildcard.",
|
|
172
|
+
),
|
|
163
173
|
):
|
|
164
174
|
"""
|
|
165
175
|
|
|
@@ -171,7 +181,13 @@ def download(
|
|
|
171
181
|
if not os.path.isdir(local_path):
|
|
172
182
|
raise ValueError(f"Local path '{local_path}' is not a directory.")
|
|
173
183
|
if not os.listdir(local_path) == []:
|
|
174
|
-
|
|
184
|
+
|
|
185
|
+
full_local_path = os.path.abspath(local_path)
|
|
186
|
+
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"Local path '{full_local_path}' is not empty, it contains {len(os.listdir(local_path))} files. "
|
|
189
|
+
f"The local target directory must be empty."
|
|
190
|
+
)
|
|
175
191
|
|
|
176
192
|
client = AuthenticatedClient()
|
|
177
193
|
for single_mission_uuid in mission_uuid:
|
|
@@ -189,20 +205,43 @@ def download(
|
|
|
189
205
|
if len(paths) == 0:
|
|
190
206
|
continue
|
|
191
207
|
|
|
208
|
+
# validate search pattern
|
|
209
|
+
if pattern:
|
|
210
|
+
if not re.match(r"^[a-zA-Z0-9_\-.*]+$", pattern):
|
|
211
|
+
raise ValueError(
|
|
212
|
+
"Invalid pattern. Allowed are alphanumeric characters, '_', '-', '.' and '*' as wildcard."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
regex = pattern.replace("*", ".*")
|
|
216
|
+
pattern = re.compile(regex)
|
|
217
|
+
|
|
218
|
+
print(f"Found {len(paths)} files in mission:")
|
|
219
|
+
paths = [
|
|
220
|
+
path for path in paths if not pattern or pattern.match(path["filename"])
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
if pattern:
|
|
224
|
+
print(
|
|
225
|
+
f" » filtered to {len(paths)} files matching pattern '{pattern.pattern}'."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
print(f"Start downloading {len(paths)} files to '{local_path}':\n")
|
|
192
229
|
for path in paths:
|
|
193
230
|
|
|
194
231
|
filename = path["filename"]
|
|
195
|
-
print(f" - {filename}")
|
|
196
232
|
|
|
197
233
|
response = requests.get(path["link"], stream=True) # Enable streaming mode
|
|
198
|
-
chunk_size = 1024 *
|
|
234
|
+
chunk_size = 1024 * 1024 * 10 # 10 MB chunks, adjust size if needed
|
|
199
235
|
|
|
200
236
|
# Open the file for writing in binary mode
|
|
201
237
|
with open(os.path.join(local_path, filename), "wb") as f:
|
|
202
|
-
for chunk in
|
|
238
|
+
for chunk in tqdm(
|
|
239
|
+
response.iter_content(chunk_size=chunk_size),
|
|
240
|
+
unit="MB",
|
|
241
|
+
desc=filename,
|
|
242
|
+
):
|
|
203
243
|
if chunk: # Filter out keep-alive new chunks
|
|
204
244
|
f.write(chunk)
|
|
205
|
-
print(f" Downloaded {filename}")
|
|
206
245
|
|
|
207
246
|
|
|
208
247
|
@missionCommands.command("upload")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import typer
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from typing_extensions import Annotated
|
|
6
|
+
|
|
7
|
+
from kleinkram.api_client import AuthenticatedClient
|
|
8
|
+
|
|
9
|
+
project = typer.Typer(
|
|
10
|
+
name="project",
|
|
11
|
+
help="Project operations",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@project.command("list", help="List all projects")
|
|
18
|
+
def list_projects():
|
|
19
|
+
"""
|
|
20
|
+
List all projects.
|
|
21
|
+
"""
|
|
22
|
+
client = AuthenticatedClient()
|
|
23
|
+
response = client.get("/project/filtered")
|
|
24
|
+
response.raise_for_status()
|
|
25
|
+
projects = response.json()[0]
|
|
26
|
+
|
|
27
|
+
if len(projects) == 0:
|
|
28
|
+
print("No projects found. Create a new project using 'klein project create'")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
stdout_console = Console(stderr=False)
|
|
32
|
+
stderr_console = Console(stderr=True)
|
|
33
|
+
stderr_console.print(f"\nfound {len(projects)} projects with the following UUIDs:")
|
|
34
|
+
|
|
35
|
+
# print the uuids to stdout for simple piping
|
|
36
|
+
for p in projects:
|
|
37
|
+
stderr_console.print(" - ", end="")
|
|
38
|
+
stdout_console.print(p["uuid"])
|
|
39
|
+
stderr_console.print("\n")
|
|
40
|
+
|
|
41
|
+
# Print a summary table using rich to stderr
|
|
42
|
+
table = Table(title="Projects", expand=True)
|
|
43
|
+
table.add_column("Project UUID", width=10)
|
|
44
|
+
table.add_column("Project Name", width=12)
|
|
45
|
+
table.add_column("Description")
|
|
46
|
+
for p in projects:
|
|
47
|
+
table.add_row(p["uuid"], p["name"], p["description"])
|
|
48
|
+
|
|
49
|
+
stderr_console.print(table)
|
|
50
|
+
stderr_console.print("\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@project.command("details", help="Get details of a project", no_args_is_help=True)
|
|
54
|
+
def project_details(
|
|
55
|
+
project_uuid: Annotated[
|
|
56
|
+
str, typer.Argument(help="UUID of the project to get details of")
|
|
57
|
+
]
|
|
58
|
+
):
|
|
59
|
+
"""
|
|
60
|
+
Get details of a project
|
|
61
|
+
"""
|
|
62
|
+
client = AuthenticatedClient()
|
|
63
|
+
response = client.get(f"/project/one?uuid={project_uuid}")
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
project = response.json()
|
|
66
|
+
|
|
67
|
+
stdout_console = Console(stderr=False)
|
|
68
|
+
stderr_console = Console(stderr=True)
|
|
69
|
+
stderr_console.print(
|
|
70
|
+
f"\nDetails of project with UUID {project_uuid}:", highlight=False
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Print the details to stderr using rich
|
|
74
|
+
table = Table(title="Project Details", expand=True)
|
|
75
|
+
table.add_column("Key", width=16)
|
|
76
|
+
table.add_column("Value")
|
|
77
|
+
for key, value in project.items():
|
|
78
|
+
|
|
79
|
+
access_name_map = {0: "READ", 10: "CREATE", 20: "WRITE", 30: "DELETE"}
|
|
80
|
+
|
|
81
|
+
if key == "project_accesses":
|
|
82
|
+
value = ", ".join(
|
|
83
|
+
[
|
|
84
|
+
f"'{access['accessGroup']['name']}' ({access_name_map[access['rights']]})"
|
|
85
|
+
for access in value
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if key == "missions":
|
|
90
|
+
value = ", ".join([f"'{mission['name']}'" for mission in value])
|
|
91
|
+
|
|
92
|
+
if key == "creator":
|
|
93
|
+
value = value["name"]
|
|
94
|
+
|
|
95
|
+
table.add_row(key, f"{value}")
|
|
96
|
+
|
|
97
|
+
stderr_console.print(table)
|
|
98
|
+
stderr_console.print("\nList of missions:")
|
|
99
|
+
for mission in project["missions"]:
|
|
100
|
+
stderr_console.print(" - ", end="")
|
|
101
|
+
stdout_console.print(mission["uuid"])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@project.command("create", no_args_is_help=True, help="Create a new project")
|
|
105
|
+
def create_project(
|
|
106
|
+
name: Annotated[str, typer.Option(help="Name of Project")],
|
|
107
|
+
description: Annotated[str, typer.Option(help="Description of Project")],
|
|
108
|
+
):
|
|
109
|
+
"""
|
|
110
|
+
Create a new project
|
|
111
|
+
"""
|
|
112
|
+
# Todo add required tags as option.
|
|
113
|
+
try:
|
|
114
|
+
url = "/project/create"
|
|
115
|
+
client = AuthenticatedClient()
|
|
116
|
+
response = client.post(
|
|
117
|
+
url, json={"name": name, "description": description, "requiredTags": []}
|
|
118
|
+
) # TODO: Add required tags as option
|
|
119
|
+
if response.status_code >= 400:
|
|
120
|
+
response_json = response.json()
|
|
121
|
+
response_text = response_json["message"]
|
|
122
|
+
raise ValueError(f"Failed to create project\n » {response_text}!")
|
|
123
|
+
|
|
124
|
+
stderr_console = Console(stderr=True)
|
|
125
|
+
stderr_console.print(f"Project '{name}' created successfully.")
|
|
126
|
+
|
|
127
|
+
stdout_console = Console(stderr=False)
|
|
128
|
+
stderr_console.print("\nProject UUID:\n - ", end="")
|
|
129
|
+
stdout_console.print(response.json()["uuid"])
|
|
130
|
+
|
|
131
|
+
except httpx.HTTPError as e:
|
|
132
|
+
print(f"Failed to create project: {e}")
|
|
133
|
+
raise e
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@project.command("delete", help="Delete a project")
|
|
137
|
+
def delete_project():
|
|
138
|
+
raise NotImplementedError()
|
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from datetime import datetime, timedelta
|
|
3
|
-
from enum import Enum
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
import importlib.metadata
|
|
7
|
-
import typer
|
|
8
|
-
from rich import print
|
|
9
|
-
from rich.table import Table
|
|
10
|
-
from typer.core import TyperGroup
|
|
11
|
-
from typer.models import Context
|
|
12
|
-
from typing_extensions import Annotated, List, Optional
|
|
13
|
-
|
|
14
|
-
from kleinkram.api_client import AuthenticatedClient
|
|
15
|
-
from kleinkram.auth.auth import login, setCliKey, logout
|
|
16
|
-
from kleinkram.endpoint.endpoint import endpoint
|
|
17
|
-
from kleinkram.error_handling import ErrorHandledTyper, AccessDeniedException
|
|
18
|
-
from kleinkram.file.file import file
|
|
19
|
-
from kleinkram.mission.mission import missionCommands
|
|
20
|
-
from kleinkram.project.project import project
|
|
21
|
-
from kleinkram.queue.queue import queue
|
|
22
|
-
from kleinkram.tag.tag import tag
|
|
23
|
-
from kleinkram.topic.topic import topic
|
|
24
|
-
from kleinkram.user.user import user
|
|
25
|
-
from .helper import uploadFiles, expand_and_match, canUploadMission, promptForTags
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class CommandPanel(str, Enum):
|
|
29
|
-
CoreCommands = "CORE COMMANDS"
|
|
30
|
-
Commands = "COMMANDS"
|
|
31
|
-
AdditionalCommands = "ADDITIONAL COMMANDS"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def version_callback(value: bool):
|
|
35
|
-
if value:
|
|
36
|
-
try:
|
|
37
|
-
_version = importlib.metadata.version("kleinkram")
|
|
38
|
-
except importlib.metadata.PackageNotFoundError:
|
|
39
|
-
_version = "local"
|
|
40
|
-
typer.echo(f"CLI Version: {_version}")
|
|
41
|
-
raise typer.Exit()
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class OrderCommands(TyperGroup):
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
The following code snippet is taken from https://github.com/tiangolo/typer/discussions/855 (see comment
|
|
48
|
-
https://github.com/tiangolo/typer/discussions/855#discussioncomment-9824582) and adapted to our use case.
|
|
49
|
-
"""
|
|
50
|
-
|
|
51
|
-
def list_commands(self, _ctx: Context) -> List[str]:
|
|
52
|
-
order = list(CommandPanel)
|
|
53
|
-
grouped_commands = {
|
|
54
|
-
name: getattr(command, "rich_help_panel")
|
|
55
|
-
for name, command in sorted(self.commands.items())
|
|
56
|
-
if getattr(command, "rich_help_panel") in order
|
|
57
|
-
}
|
|
58
|
-
ungrouped_command_names = [
|
|
59
|
-
command.name
|
|
60
|
-
for command in self.commands.values()
|
|
61
|
-
if command.name not in grouped_commands
|
|
62
|
-
]
|
|
63
|
-
return [
|
|
64
|
-
name
|
|
65
|
-
for name, command in sorted(
|
|
66
|
-
grouped_commands.items(),
|
|
67
|
-
key=lambda item: order.index(item[1]),
|
|
68
|
-
)
|
|
69
|
-
] + sorted(ungrouped_command_names)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
app = ErrorHandledTyper(
|
|
73
|
-
context_settings={"help_option_names": ["-h", "--help"]},
|
|
74
|
-
no_args_is_help=True,
|
|
75
|
-
cls=OrderCommands,
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
@app.callback()
|
|
80
|
-
def version(
|
|
81
|
-
version: bool = typer.Option(
|
|
82
|
-
None,
|
|
83
|
-
"--version",
|
|
84
|
-
"-v",
|
|
85
|
-
callback=version_callback,
|
|
86
|
-
is_eager=True,
|
|
87
|
-
help="Print the version and exit",
|
|
88
|
-
)
|
|
89
|
-
):
|
|
90
|
-
pass
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
app.add_typer(project, rich_help_panel=CommandPanel.Commands)
|
|
94
|
-
app.add_typer(missionCommands, rich_help_panel=CommandPanel.Commands)
|
|
95
|
-
|
|
96
|
-
app.add_typer(topic, rich_help_panel=CommandPanel.Commands)
|
|
97
|
-
app.add_typer(file, rich_help_panel=CommandPanel.Commands)
|
|
98
|
-
app.add_typer(queue, rich_help_panel=CommandPanel.Commands)
|
|
99
|
-
app.add_typer(user, rich_help_panel=CommandPanel.Commands)
|
|
100
|
-
app.add_typer(tag, rich_help_panel=CommandPanel.Commands)
|
|
101
|
-
app.add_typer(endpoint, rich_help_panel=CommandPanel.AdditionalCommands)
|
|
102
|
-
|
|
103
|
-
app.command(rich_help_panel=CommandPanel.AdditionalCommands)(login)
|
|
104
|
-
app.command(rich_help_panel=CommandPanel.AdditionalCommands)(logout)
|
|
105
|
-
app.command(hidden=True)(setCliKey)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
@app.command("download", rich_help_panel=CommandPanel.CoreCommands)
|
|
109
|
-
def download():
|
|
110
|
-
raise NotImplementedError("Not implemented yet.")
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
@app.command("upload", rich_help_panel=CommandPanel.CoreCommands)
|
|
114
|
-
def upload(
|
|
115
|
-
path: Annotated[
|
|
116
|
-
List[str],
|
|
117
|
-
typer.Option(prompt=True, help="Path to files to upload, Regex supported"),
|
|
118
|
-
],
|
|
119
|
-
project: Annotated[str, typer.Option(prompt=True, help="Name of Project")],
|
|
120
|
-
mission: Annotated[
|
|
121
|
-
str, typer.Option(prompt=True, help="Name of Mission to create")
|
|
122
|
-
],
|
|
123
|
-
tags: Annotated[
|
|
124
|
-
Optional[List[str]],
|
|
125
|
-
typer.Option(prompt=False, help="Tags to add to the mission"),
|
|
126
|
-
] = None,
|
|
127
|
-
):
|
|
128
|
-
"""
|
|
129
|
-
Upload files matching the path to a mission in a project.
|
|
130
|
-
|
|
131
|
-
The mission name must be unique within the project and not yet created.\n
|
|
132
|
-
Multiple paths can be given by using the option multiple times.\n
|
|
133
|
-
Examples:\n
|
|
134
|
-
- 'klein upload --path "~/data/**/*.bag" --project "Project 1" --mission "Mission 1" --tags "0700946d-1d6a-4520-b263-0e177f49c35b:LEE-H" --tags "1565118d-593c-4517-8c2d-9658452d9319:Dodo"'\n
|
|
135
|
-
|
|
136
|
-
"""
|
|
137
|
-
files = []
|
|
138
|
-
for p in path:
|
|
139
|
-
files.extend(expand_and_match(p))
|
|
140
|
-
filenames = list(
|
|
141
|
-
map(lambda x: x.split("/")[-1], filter(lambda x: not os.path.isdir(x), files))
|
|
142
|
-
)
|
|
143
|
-
if not filenames:
|
|
144
|
-
raise ValueError("No files found matching the given path.")
|
|
145
|
-
|
|
146
|
-
print(
|
|
147
|
-
f"Uploading the following files to mission '{mission}' in project '{project}':"
|
|
148
|
-
)
|
|
149
|
-
filepaths = {}
|
|
150
|
-
for path in files:
|
|
151
|
-
if not os.path.isdir(path):
|
|
152
|
-
filepaths[path.split("/")[-1]] = path
|
|
153
|
-
typer.secho(f" - {path}", fg=typer.colors.RESET)
|
|
154
|
-
|
|
155
|
-
try:
|
|
156
|
-
client = AuthenticatedClient()
|
|
157
|
-
|
|
158
|
-
get_project_url = "/project/byName"
|
|
159
|
-
project_response = client.get(get_project_url, params={"name": project})
|
|
160
|
-
if project_response.status_code >= 400:
|
|
161
|
-
raise AccessDeniedException(
|
|
162
|
-
f"The project '{project}' does not exist or you do not have access to it.\n"
|
|
163
|
-
f"Consider using the following command to create a project: 'klein project create'\n",
|
|
164
|
-
f"{project_response.json()['message']} ({project_response.status_code})",
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
project_json = project_response.json()
|
|
168
|
-
if not project_json["uuid"]:
|
|
169
|
-
print(f"Project not found: '{project}'")
|
|
170
|
-
return
|
|
171
|
-
|
|
172
|
-
can_upload = canUploadMission(client, project_json["uuid"])
|
|
173
|
-
if not can_upload:
|
|
174
|
-
raise AccessDeniedException(
|
|
175
|
-
f"You do not have the required permissions to upload to project '{project}'\n",
|
|
176
|
-
"Access Denied",
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
if not tags:
|
|
180
|
-
tags = []
|
|
181
|
-
tags_dict = {item.split(":")[0]: item.split(":")[1] for item in tags}
|
|
182
|
-
|
|
183
|
-
promptForTags(tags_dict, project_json["requiredTags"])
|
|
184
|
-
|
|
185
|
-
get_mission_url = "/mission/byName"
|
|
186
|
-
mission_response = client.get(get_mission_url, params={"name": mission})
|
|
187
|
-
mission_response.raise_for_status()
|
|
188
|
-
if mission_response.content:
|
|
189
|
-
mission_json = mission_response.json()
|
|
190
|
-
if mission_json["uuid"]:
|
|
191
|
-
raise ValueError(
|
|
192
|
-
f"Mission {mission_json['name']} ({mission_json['uuid']}) already exists. Delete it or select "
|
|
193
|
-
f"another name."
|
|
194
|
-
)
|
|
195
|
-
raise Exception(f"Something failed, should not happen")
|
|
196
|
-
|
|
197
|
-
create_mission_url = "/mission/create"
|
|
198
|
-
new_mission = client.post(
|
|
199
|
-
create_mission_url,
|
|
200
|
-
json={
|
|
201
|
-
"name": mission,
|
|
202
|
-
"projectUUID": project_json["uuid"],
|
|
203
|
-
"tags": tags_dict,
|
|
204
|
-
},
|
|
205
|
-
)
|
|
206
|
-
if new_mission.status_code >= 400:
|
|
207
|
-
raise ValueError(
|
|
208
|
-
"Failed to create mission. Status Code: "
|
|
209
|
-
+ str(new_mission.status_code)
|
|
210
|
-
+ "\n"
|
|
211
|
-
+ new_mission.json()["message"]
|
|
212
|
-
)
|
|
213
|
-
new_mission_data = new_mission.json()
|
|
214
|
-
|
|
215
|
-
get_temporary_credentials = "/file/temporaryAccess"
|
|
216
|
-
response_2 = client.post(
|
|
217
|
-
get_temporary_credentials,
|
|
218
|
-
json={"filenames": filenames, "missionUUID": new_mission_data["uuid"]},
|
|
219
|
-
)
|
|
220
|
-
if response_2.status_code >= 400:
|
|
221
|
-
raise ValueError(
|
|
222
|
-
"Failed to get temporary credentials. Status Code: "
|
|
223
|
-
+ str(response_2.status_code)
|
|
224
|
-
+ "\n"
|
|
225
|
-
+ response_2.json()["message"]
|
|
226
|
-
)
|
|
227
|
-
temp_credentials = response_2.json()
|
|
228
|
-
credential = temp_credentials["credentials"]
|
|
229
|
-
confirmed_files = temp_credentials["files"]
|
|
230
|
-
for _file in filenames:
|
|
231
|
-
if not _file in confirmed_files.keys():
|
|
232
|
-
raise Exception(
|
|
233
|
-
"Could not upload File '" + _file + "'. Is the filename unique? "
|
|
234
|
-
)
|
|
235
|
-
confirmed_files[_file]["filepath"] = filepaths[_file]
|
|
236
|
-
if len(confirmed_files.keys()) > 0:
|
|
237
|
-
uploadFiles(confirmed_files, credential, 4)
|
|
238
|
-
|
|
239
|
-
except httpx.HTTPError as e:
|
|
240
|
-
print(e)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
@queue.command("list")
|
|
244
|
-
def list_queue():
|
|
245
|
-
"""List current Queue entities"""
|
|
246
|
-
try:
|
|
247
|
-
url = "/queue/active"
|
|
248
|
-
startDate = datetime.now().date() - timedelta(days=1)
|
|
249
|
-
client = AuthenticatedClient()
|
|
250
|
-
response = client.get(url, params={"startDate": startDate})
|
|
251
|
-
response.raise_for_status()
|
|
252
|
-
data = response.json()
|
|
253
|
-
table = Table("UUID", "filename", "mission", "state", "origin", "createdAt")
|
|
254
|
-
for topic in data:
|
|
255
|
-
table.add_row(
|
|
256
|
-
topic["uuid"],
|
|
257
|
-
topic["filename"],
|
|
258
|
-
topic["mission"]["name"],
|
|
259
|
-
topic["state"],
|
|
260
|
-
topic["location"],
|
|
261
|
-
topic["createdAt"],
|
|
262
|
-
)
|
|
263
|
-
print(table)
|
|
264
|
-
|
|
265
|
-
except httpx.HTTPError as e:
|
|
266
|
-
print(e)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
@app.command("claim", hidden=True)
|
|
270
|
-
def claim():
|
|
271
|
-
"""
|
|
272
|
-
Claim admin rights as the first user
|
|
273
|
-
|
|
274
|
-
Only works if no other user has claimed admin rights before.
|
|
275
|
-
"""
|
|
276
|
-
|
|
277
|
-
client = AuthenticatedClient()
|
|
278
|
-
response = client.post("/user/claimAdmin")
|
|
279
|
-
response.raise_for_status()
|
|
280
|
-
print("Admin claimed.")
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if __name__ == "__main__":
|
|
284
|
-
app()
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
from typing_extensions import Annotated
|
|
2
|
-
|
|
3
|
-
import httpx
|
|
4
|
-
import typer
|
|
5
|
-
|
|
6
|
-
from kleinkram.api_client import AuthenticatedClient
|
|
7
|
-
|
|
8
|
-
project = typer.Typer(
|
|
9
|
-
name="project",
|
|
10
|
-
help="Project operations",
|
|
11
|
-
no_args_is_help=True,
|
|
12
|
-
context_settings={"help_option_names": ["-h", "--help"]},
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@project.command("list")
|
|
17
|
-
def list_projects():
|
|
18
|
-
"""
|
|
19
|
-
List all projects.
|
|
20
|
-
"""
|
|
21
|
-
try:
|
|
22
|
-
client = AuthenticatedClient()
|
|
23
|
-
response = client.get("/project/filtered")
|
|
24
|
-
response.raise_for_status()
|
|
25
|
-
projects = response.json()[0]
|
|
26
|
-
print("Projects:")
|
|
27
|
-
for _project in projects:
|
|
28
|
-
print(f"- {_project['name']}")
|
|
29
|
-
|
|
30
|
-
except httpx.HTTPError as e:
|
|
31
|
-
print(f"Failed to fetch projects: {e}")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
@project.command("create")
|
|
35
|
-
def create_project(
|
|
36
|
-
name: Annotated[str, typer.Option(help="Name of Project")],
|
|
37
|
-
description: Annotated[str, typer.Option(help="Description of Project")],
|
|
38
|
-
):
|
|
39
|
-
"""
|
|
40
|
-
Create a new project
|
|
41
|
-
"""
|
|
42
|
-
# Todo add required tags as option.
|
|
43
|
-
try:
|
|
44
|
-
url = "/project/create"
|
|
45
|
-
client = AuthenticatedClient()
|
|
46
|
-
response = client.post(
|
|
47
|
-
url, json={"name": name, "description": description, "requiredTags": []}
|
|
48
|
-
) # TODO: Add required tags as option
|
|
49
|
-
if response.status_code >= 400:
|
|
50
|
-
response_json = response.json()
|
|
51
|
-
response_text = response_json["message"]
|
|
52
|
-
print(f"Failed to create project: {response_text}")
|
|
53
|
-
return
|
|
54
|
-
print("Project created")
|
|
55
|
-
|
|
56
|
-
except httpx.HTTPError as e:
|
|
57
|
-
print(f"Failed to create project: {e}")
|
|
58
|
-
raise e
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|