kleinkram 0.32.3.dev20241023090650__tar.gz → 0.33.0.dev20241024121528__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kleinkram might be problematic. Click here for more details.

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