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.

Files changed (26) hide show
  1. {kleinkram-0.32.3 → kleinkram-0.33.0}/PKG-INFO +1 -1
  2. {kleinkram-0.32.3 → kleinkram-0.33.0}/pyproject.toml +1 -1
  3. {kleinkram-0.32.3 → kleinkram-0.33.0}/requirements.txt +2 -1
  4. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/endpoint/endpoint.py +2 -1
  5. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/error_handling.py +29 -2
  6. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/helper.py +71 -41
  7. kleinkram-0.33.0/src/kleinkram/main.py +381 -0
  8. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/mission/mission.py +56 -17
  9. kleinkram-0.33.0/src/kleinkram/project/project.py +138 -0
  10. kleinkram-0.32.3/src/kleinkram/main.py +0 -284
  11. kleinkram-0.32.3/src/kleinkram/project/project.py +0 -58
  12. {kleinkram-0.32.3 → kleinkram-0.33.0}/.gitignore +0 -0
  13. {kleinkram-0.32.3 → kleinkram-0.33.0}/LICENSE +0 -0
  14. {kleinkram-0.32.3 → kleinkram-0.33.0}/README.md +0 -0
  15. {kleinkram-0.32.3 → kleinkram-0.33.0}/deploy.sh +0 -0
  16. {kleinkram-0.32.3 → kleinkram-0.33.0}/dev.sh +0 -0
  17. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/klein.py +0 -0
  18. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/__init__.py +0 -0
  19. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/api_client.py +0 -0
  20. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/auth/auth.py +0 -0
  21. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/consts.py +0 -0
  22. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/file/file.py +0 -0
  23. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/queue/queue.py +0 -0
  24. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/tag/tag.py +0 -0
  25. {kleinkram-0.32.3 → kleinkram-0.33.0}/src/kleinkram/topic/topic.py +0 -0
  26. {kleinkram-0.32.3 → kleinkram-0.33.0}/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
3
+ Version: 0.33.0
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"
7
+ version = "0.33.0"
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
@@ -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
- "This feature is not yet implemented, please check for updates.",
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
- typer.secho(str(e), fg=typer.colors.RED)
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.client import BaseClient
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(files: Dict[str, str], credentials: Dict[str, str], nrThreads: int):
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 file in files.items():
128
- _queue.put(file)
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
- transferCallback = TransferCallback()
131
- failed_uploads = []
126
+ transfer_callback = TransferCallback()
132
127
 
133
128
  for i in range(nrThreads):
134
129
  thread = threading.Thread(
135
- target=uploadFile, args=(_queue, s3, transferCallback, failed_uploads)
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
- s3: BaseClient,
150
- transferCallback: TransferCallback,
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
- 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
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
- transferCallback.add_file(filename, file_size)
167
- callback_function = create_transfer_callback(transferCallback, filename)
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
- target_location,
171
- Config=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": queueUUID, "md5": md5_checksum},
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(f"Error uploading {filename}: {e}")
186
- failed_uploads.append(filepath)
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
- else:
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
- for file in data["files"]:
152
- table.add_row(file["filename"], f"{file['size']}", file["date"])
153
- console = Console()
154
- console.print(table)
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
- raise ValueError(f"Local path '{local_path}' is not empty, but must be empty.")
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 * 100 # 100 KB chunks, adjust size if needed
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 response.iter_content(chunk_size=chunk_size):
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