kleinkram 0.4.0.dev20240808080202__tar.gz → 0.5.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 (23) hide show
  1. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/PKG-INFO +1 -1
  2. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/pyproject.toml +1 -1
  3. kleinkram-0.5.0/src/klein.py +9 -0
  4. kleinkram-0.5.0/src/kleinkram/api_client.py +72 -0
  5. {kleinkram-0.4.0.dev20240808080202/src/kleinkram → kleinkram-0.5.0/src/kleinkram/auth}/auth.py +15 -62
  6. kleinkram-0.5.0/src/kleinkram/file/file.py +109 -0
  7. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/src/kleinkram/helper.py +5 -5
  8. kleinkram-0.5.0/src/kleinkram/main.py +265 -0
  9. kleinkram-0.5.0/src/kleinkram/mission/mission.py +126 -0
  10. kleinkram-0.5.0/src/kleinkram/project/project.py +57 -0
  11. kleinkram-0.5.0/src/kleinkram/queue/queue.py +8 -0
  12. kleinkram-0.5.0/src/kleinkram/tag/tag.py +48 -0
  13. kleinkram-0.5.0/src/kleinkram/topic/topic.py +54 -0
  14. kleinkram-0.5.0/src/kleinkram/user/user.py +53 -0
  15. kleinkram-0.4.0.dev20240808080202/src/kleinkram/main.py +0 -566
  16. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/.gitignore +0 -0
  17. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/LICENSE +0 -0
  18. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/README.md +0 -0
  19. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/deploy.sh +0 -0
  20. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/dev.sh +0 -0
  21. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/requirements.txt +0 -0
  22. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/src/kleinkram/__init__.py +0 -0
  23. {kleinkram-0.4.0.dev20240808080202 → kleinkram-0.5.0}/src/kleinkram/consts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: kleinkram
3
- Version: 0.4.0.dev20240808080202
3
+ Version: 0.5.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.4.0-dev20240808080202"
7
+ version = "0.5.0"
8
8
  description = "A CLI for the ETH project kleinkram"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
@@ -0,0 +1,9 @@
1
+ from kleinkram.main import app
2
+
3
+
4
+ def main():
5
+ app()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1,72 @@
1
+ import httpx
2
+ from rich.console import Console
3
+ from rich.panel import Panel
4
+
5
+ from kleinkram.auth.auth import TokenFile, CLI_KEY, AUTH_TOKEN, REFRESH_TOKEN
6
+
7
+
8
+ class AuthenticatedClient(httpx.Client):
9
+ def __init__(self, *args, **kwargs):
10
+ super().__init__(*args, **kwargs)
11
+
12
+ try:
13
+ self.tokenfile = TokenFile()
14
+ self._load_cookies()
15
+ except Exception as e:
16
+
17
+ console = Console()
18
+ msg = f"You are not authenticated on endpoint '{self.tokenfile.endpoint}'. Please run 'klein login' to authenticate."
19
+
20
+ panel = Panel(
21
+ msg,
22
+ title="Not Authenticated",
23
+ style="yellow",
24
+ padding=(1, 2),
25
+ highlight=True,
26
+ )
27
+ print()
28
+ console.print(panel)
29
+ print()
30
+
31
+ def _load_cookies(self):
32
+ if self.tokenfile.isCliToken():
33
+ self.cookies.set(CLI_KEY, self.tokenfile.getCLIToken())
34
+ else:
35
+ self.cookies.set(AUTH_TOKEN, self.tokenfile.getAuthToken())
36
+
37
+ def refresh_token(self):
38
+ if self.tokenfile.isCliToken():
39
+ print("CLI key cannot be refreshed.")
40
+ return
41
+ refresh_token = self.tokenfile.getRefreshToken()
42
+ if not refresh_token:
43
+ print("No refresh token found. Please login again.")
44
+ raise Exception("No refresh token found.")
45
+ self.cookies.set(
46
+ REFRESH_TOKEN,
47
+ refresh_token,
48
+ )
49
+ response = self.post(
50
+ "/auth/refresh-token",
51
+ )
52
+ response.raise_for_status()
53
+ new_access_token = response.cookies.get(AUTH_TOKEN)
54
+ new_tokens = {AUTH_TOKEN: new_access_token, REFRESH_TOKEN: refresh_token}
55
+ self.tokenfile.saveTokens(new_tokens)
56
+ self.cookies.set(AUTH_TOKEN, new_access_token)
57
+
58
+ def request(self, method, url, *args, **kwargs):
59
+ response = super().request(
60
+ method, self.tokenfile.endpoint + url, *args, **kwargs
61
+ )
62
+ if (url == "/auth/refresh-token") and response.status_code == 401:
63
+ print("Refresh token expired. Please login again.")
64
+ response.status_code = 403
65
+ exit(1)
66
+ if response.status_code == 401:
67
+ print("Token expired, refreshing token...")
68
+ self.refresh_token()
69
+ response = super().request(
70
+ method, self.tokenfile.endpoint + url, *args, **kwargs
71
+ )
72
+ return response
@@ -3,17 +3,15 @@ import os
3
3
  import urllib.parse
4
4
  import webbrowser
5
5
  from http.server import BaseHTTPRequestHandler, HTTPServer
6
+ from pathlib import Path
6
7
  from typing import Optional
7
8
 
9
+ import typer
8
10
  from typing_extensions import Annotated
9
11
 
10
- from pathlib import Path
11
-
12
- import httpx
13
- import typer
12
+ from kleinkram.consts import API_URL
14
13
 
15
14
  app = typer.Typer()
16
- from .consts import API_URL
17
15
 
18
16
  TOKEN_FILE = Path(os.path.expanduser("~/.kleinkram.json"))
19
17
  REFRESH_TOKEN = "refreshtoken"
@@ -87,62 +85,14 @@ def get_auth_tokens():
87
85
  return httpd.tokens
88
86
 
89
87
 
90
- class AuthenticatedClient(httpx.Client):
91
- def __init__(self, *args, **kwargs):
92
- super().__init__(*args, **kwargs)
93
- try:
94
- self.tokenfile = TokenFile()
95
- self._load_cookies()
96
- except Exception as e:
97
- print(
98
- f"{self.tokenfile.endpoint} is not authenticated. Please run 'klein login'."
99
- )
100
-
101
- def _load_cookies(self):
102
- if self.tokenfile.isCliToken():
103
- self.cookies.set(CLI_KEY, self.tokenfile.getCLIToken())
104
- else:
105
- self.cookies.set(AUTH_TOKEN, self.tokenfile.getAuthToken())
106
-
107
- def refresh_token(self):
108
- if self.tokenfile.isCliToken():
109
- print("CLI key cannot be refreshed.")
110
- return
111
- refresh_token = self.tokenfile.getRefreshToken()
112
- if not refresh_token:
113
- print("No refresh token found. Please login again.")
114
- raise Exception("No refresh token found.")
115
- self.cookies.set(
116
- REFRESH_TOKEN,
117
- refresh_token,
118
- )
119
- response = self.post(
120
- "/auth/refresh-token",
121
- )
122
- response.raise_for_status()
123
- new_access_token = response.cookies.get(AUTH_TOKEN)
124
- new_tokens = {AUTH_TOKEN: new_access_token, REFRESH_TOKEN: refresh_token}
125
- self.tokenfile.saveTokens(new_tokens)
126
- self.cookies.set(AUTH_TOKEN, new_access_token)
127
-
128
- def request(self, method, url, *args, **kwargs):
129
- response = super().request(
130
- method, self.tokenfile.endpoint + url, *args, **kwargs
131
- )
132
- if (url == "/auth/refresh-token") and response.status_code == 401:
133
- print("Refresh token expired. Please login again.")
134
- response.status_code = 403
135
- exit(1)
136
- if response.status_code == 401:
137
- print("Token expired, refreshing token...")
138
- self.refresh_token()
139
- response = super().request(
140
- method, self.tokenfile.endpoint + url, *args, **kwargs
141
- )
142
- return response
143
-
144
-
145
- client = AuthenticatedClient()
88
+ def logout():
89
+ """
90
+ Logout from the currently set endpoint.
91
+ """
92
+ tokenfile = TokenFile()
93
+ tokenfile.tokens[tokenfile.endpoint] = {}
94
+ tokenfile.writeToFile()
95
+ print("Logged out.")
146
96
 
147
97
 
148
98
  def login(
@@ -157,7 +107,10 @@ def login(
157
107
  """
158
108
  tokenfile = TokenFile()
159
109
  if key:
160
- tokenfile.saveTokens(key)
110
+ token = {}
111
+ token[CLI_KEY] = key
112
+ tokenfile.saveTokens(token)
113
+
161
114
  else:
162
115
  url = tokenfile.endpoint + "/auth/google?state=cli"
163
116
 
@@ -0,0 +1,109 @@
1
+ from typing import Optional, Annotated
2
+
3
+ import httpx
4
+ import typer
5
+
6
+ from kleinkram.api_client import AuthenticatedClient
7
+
8
+ file = typer.Typer(
9
+ name="file",
10
+ help="File operations",
11
+ no_args_is_help=True,
12
+ context_settings={"help_option_names": ["-h", "--help"]},
13
+ )
14
+
15
+
16
+ @file.command("list")
17
+ def list_files(
18
+ project: Optional[str] = typer.Option(None, help="Name of Project"),
19
+ mission: Optional[str] = typer.Option(None, help="Name of Mission"),
20
+ topics: Optional[str] = typer.Option(None, help="Comma separated list of topics"),
21
+ tags: Optional[str] = typer.Option(
22
+ None, help="Comma separated list of tagtype:tagvalue pairs"
23
+ ),
24
+ ):
25
+ """
26
+ List all files with optional filters for project, mission, or topics.
27
+
28
+ Can list files of a project, mission, or with specific topics (Logical AND).
29
+ Examples:\n
30
+ - 'klein filelist'\n
31
+ - 'klein file list --project "Project 1"'\n
32
+ - 'klein file list --mission "Mission 1"'\n
33
+ - 'klein file list --topics "/elevation_mapping/semantic_map,/elevation_mapping/elevation_map_raw"'\n
34
+ - 'klein file list --topics "/elevation_mapping/semantic_map,/elevation_mapping/elevation_map_raw" --mission "Mission A"'
35
+ """
36
+ try:
37
+ url = f"/file/filteredByNames"
38
+ params = {}
39
+ if project:
40
+ params["projectName"] = project
41
+ if mission:
42
+ params["missionName"] = mission
43
+ if topics:
44
+ params["topics"] = topics
45
+ if tags:
46
+ params["tags"] = {}
47
+ for tag in tags.split(","):
48
+ tagtype, tagvalue = tag.split("§")
49
+ params["tags"][tagtype] = tagvalue
50
+
51
+ client = AuthenticatedClient()
52
+ response = client.get(
53
+ url,
54
+ params=params,
55
+ )
56
+ response.raise_for_status()
57
+ data = response.json()
58
+ missions_by_project_uuid = {}
59
+ files_by_mission_uuid = {}
60
+ for file in data:
61
+ mission_uuid = file["mission"]["uuid"]
62
+ project_uuid = file["mission"]["project"]["uuid"]
63
+ if project_uuid not in missions_by_project_uuid:
64
+ missions_by_project_uuid[project_uuid] = []
65
+ if mission_uuid not in missions_by_project_uuid[project_uuid]:
66
+ missions_by_project_uuid[project_uuid].append(mission_uuid)
67
+ if mission_uuid not in files_by_mission_uuid:
68
+ files_by_mission_uuid[mission_uuid] = []
69
+ files_by_mission_uuid[mission_uuid].append(file)
70
+
71
+ print("Files by mission & Project:")
72
+ for project_uuid, missions in missions_by_project_uuid.items():
73
+ first_file = files_by_mission_uuid[missions[0]][0]
74
+ print(f"* {first_file['mission']['project']['name']}")
75
+ for mission in missions:
76
+ print(f" - {files_by_mission_uuid[mission][0]['mission']['name']}")
77
+ for file in files_by_mission_uuid[mission]:
78
+ print(f" - '{file['filename']}'")
79
+
80
+ except httpx.HTTPError as e:
81
+ print(f"Failed to fetch missions: {e}")
82
+
83
+
84
+ @file.command("download")
85
+ def download(
86
+ missionuuid: Annotated[str, typer.Argument()],
87
+ ):
88
+ """Download file"""
89
+ try:
90
+ client = AuthenticatedClient()
91
+ response = client.get("/file/downloadWithToken", params={"uuid": missionuuid})
92
+ response.raise_for_status()
93
+ print(response.json())
94
+ except:
95
+ print("Failed to download file")
96
+
97
+
98
+ @file.command("clear")
99
+ def clear_queue():
100
+ """Clear queue"""
101
+ # Prompt the user for confirmation
102
+ confirmation = typer.prompt("Are you sure you want to clear the Files? (y/n)")
103
+ if confirmation.lower() == "y":
104
+ client = AuthenticatedClient()
105
+ response = client.delete("/file/clear")
106
+ response.raise_for_status()
107
+ print("Files cleared.")
108
+ else:
109
+ print("Operation cancelled.")
@@ -1,15 +1,14 @@
1
+ import glob
1
2
  import os
3
+ import queue
2
4
  import threading
3
- import glob
5
+ from typing import Dict
4
6
 
5
7
  import httpx
6
8
  import tqdm
7
9
  from rich import print
8
- import queue
9
-
10
- from typing import Dict
11
10
 
12
- from .auth import client
11
+ from kleinkram.api_client import AuthenticatedClient
13
12
 
14
13
 
15
14
  def expand_and_match(path_pattern):
@@ -54,6 +53,7 @@ def uploadFile(_queue: queue.Queue, paths: Dict[str, str], pbar: tqdm):
54
53
  response = cli.put(url, content=f, headers=headers)
55
54
  if response.status_code == 200:
56
55
  pbar.update(100) # Update progress for each file
56
+ client = AuthenticatedClient()
57
57
  client.post("/queue/confirmUpload", json={"uuid": uuid})
58
58
  else:
59
59
  print(
@@ -0,0 +1,265 @@
1
+ import os
2
+ from datetime import datetime, timedelta
3
+ from enum import Enum
4
+
5
+ import httpx
6
+ import typer
7
+ from rich import print
8
+ from rich.table import Table
9
+ from typer.core import TyperGroup
10
+ from typer.models import Context
11
+ from typing_extensions import Annotated
12
+
13
+ from kleinkram.api_client import AuthenticatedClient
14
+ from kleinkram.auth.auth import login, endpoint, setEndpoint, setCliKey, logout
15
+ from kleinkram.file.file import file
16
+ from kleinkram.mission.mission import mission
17
+ from kleinkram.project.project import project
18
+ from kleinkram.queue.queue import queue
19
+ from kleinkram.tag.tag import tag
20
+ from kleinkram.topic.topic import topic
21
+ from kleinkram.user.user import user
22
+ from .helper import uploadFiles, expand_and_match
23
+
24
+
25
+ class Panel(str, Enum):
26
+ CoreCommands = "CORE COMMANDS"
27
+ Commands = "COMMANDS"
28
+ AdditionalCommands = "ADDITIONAL COMMANDS"
29
+
30
+
31
+ class OrderCommands(TyperGroup):
32
+ """
33
+
34
+ The following code snippet is taken from https://github.com/tiangolo/typer/discussions/855 (see comment
35
+ https://github.com/tiangolo/typer/discussions/855#discussioncomment-9824582) and adapted to our use case.
36
+ """
37
+
38
+ def list_commands(self, _ctx: Context) -> list[str]:
39
+ order = list(Panel)
40
+ grouped_commands = {
41
+ name: getattr(command, "rich_help_panel")
42
+ for name, command in sorted(self.commands.items())
43
+ if getattr(command, "rich_help_panel") in order
44
+ }
45
+ ungrouped_command_names = [
46
+ command.name
47
+ for command in self.commands.values()
48
+ if command.name not in grouped_commands
49
+ ]
50
+ return [
51
+ name
52
+ for name, command in sorted(
53
+ grouped_commands.items(),
54
+ key=lambda item: order.index(item[1]),
55
+ )
56
+ ] + sorted(ungrouped_command_names)
57
+
58
+
59
+ app = typer.Typer(
60
+ context_settings={"help_option_names": ["-h", "--help"]},
61
+ no_args_is_help=True,
62
+ cls=OrderCommands,
63
+ )
64
+
65
+ app.add_typer(project, rich_help_panel=Panel.Commands)
66
+ app.add_typer(mission, rich_help_panel=Panel.Commands)
67
+
68
+ app.add_typer(topic, rich_help_panel=Panel.Commands)
69
+ app.add_typer(file, rich_help_panel=Panel.Commands)
70
+ app.add_typer(queue, rich_help_panel=Panel.Commands)
71
+ app.add_typer(user, rich_help_panel=Panel.Commands)
72
+ app.add_typer(tag, rich_help_panel=Panel.Commands)
73
+
74
+ app.command(rich_help_panel=Panel.CoreCommands)(login)
75
+ app.command(rich_help_panel=Panel.CoreCommands)(logout)
76
+ app.command(rich_help_panel=Panel.AdditionalCommands)(endpoint)
77
+ app.command(rich_help_panel=Panel.AdditionalCommands)(setEndpoint)
78
+ app.command(hidden=True)(setCliKey)
79
+
80
+
81
+ @app.command("download", rich_help_panel=Panel.CoreCommands)
82
+ def download():
83
+ raise NotImplementedError("Not implemented yet.")
84
+
85
+
86
+ @app.command("upload", rich_help_panel=Panel.CoreCommands)
87
+ def upload(
88
+ path: Annotated[
89
+ str, typer.Option(prompt=True, help="Path to files to upload, Regex supported")
90
+ ],
91
+ project: Annotated[str, typer.Option(prompt=True, help="Name of Project")],
92
+ mission: Annotated[
93
+ str, typer.Option(prompt=True, help="Name of Mission to create")
94
+ ],
95
+ ):
96
+ """
97
+ Upload files matching the path to a mission in a project.
98
+
99
+ The mission name must be unique within the project and not yet created.\n
100
+ Examples:\n
101
+ - 'klein upload --path "~/data/**/*.bag" --project "Project 1" --mission "Mission 1"'\n
102
+
103
+ """
104
+ files = expand_and_match(path)
105
+ filenames = list(
106
+ map(lambda x: x.split("/")[-1], filter(lambda x: not os.path.isdir(x), files))
107
+ )
108
+ if not filenames:
109
+ print("No files found")
110
+ return
111
+ filepaths = {}
112
+ for path in files:
113
+ if not os.path.isdir(path):
114
+ filepaths[path.split("/")[-1]] = path
115
+ print(f" - {path}")
116
+ try:
117
+ client = AuthenticatedClient()
118
+
119
+ get_project_url = "/project/byName"
120
+ project_response = client.get(get_project_url, params={"name": project})
121
+ if project_response.status_code >= 400:
122
+ print(f"Failed to fetch project: {project_response.text}")
123
+ return
124
+ project_json = project_response.json()
125
+ if not project_json["uuid"]:
126
+ print(f"Project not found: {project}")
127
+ return
128
+
129
+ get_mission_url = "/mission/byName"
130
+ mission_response = client.get(get_mission_url, params={"name": mission})
131
+ mission_response.raise_for_status()
132
+ if mission_response.content:
133
+ mission_json = mission_response.json()
134
+ if mission_json["uuid"]:
135
+ print(
136
+ f"mission: {mission_json['uuid']} already exists. Delete it or select another name."
137
+ )
138
+ return
139
+ print(f"Something failed, should not happen")
140
+ return
141
+
142
+ create_mission_url = "/mission/create"
143
+ new_mission = client.post(
144
+ create_mission_url,
145
+ json={"name": mission, "projectUUID": project_json["uuid"], "tags": []},
146
+ )
147
+ new_mission.raise_for_status()
148
+ new_mission_data = new_mission.json()
149
+ print(f"Created mission: {new_mission_data['name']}")
150
+
151
+ get_presigned_url = "/queue/createPreSignedURLS"
152
+
153
+ response_2 = client.post(
154
+ get_presigned_url,
155
+ json={"filenames": filenames, "missionUUID": new_mission_data["uuid"]},
156
+ )
157
+ response_2.raise_for_status()
158
+ presigned_urls = response_2.json()
159
+ for file in filenames:
160
+ if not file in presigned_urls.keys():
161
+ print("Could not upload File '" + file + "'. Is the filename unique? ")
162
+ if len(presigned_urls) > 0:
163
+ uploadFiles(presigned_urls, filepaths, 4)
164
+
165
+ except httpx.HTTPError as e:
166
+ print(e)
167
+
168
+
169
+ @queue.command("clear")
170
+ def clear_queue():
171
+ """Clear queue"""
172
+ # Prompt the user for confirmation
173
+ confirmation = typer.prompt("Are you sure you want to clear the queue? (y/n)")
174
+ if confirmation.lower() == "y":
175
+ client = AuthenticatedClient()
176
+ response = client.delete("/queue/clear")
177
+ response.raise_for_status()
178
+ print("Queue cleared.")
179
+ else:
180
+ print("Operation cancelled.")
181
+
182
+
183
+ @queue.command("list")
184
+ def list_queue():
185
+ """List current Queue entities"""
186
+ try:
187
+ url = "/queue/active"
188
+ startDate = datetime.now().date() - timedelta(days=1)
189
+ client = AuthenticatedClient()
190
+ response = client.get(url, params={"startDate": startDate})
191
+ response.raise_for_status()
192
+ data = response.json()
193
+ table = Table("UUID", "filename", "mission", "state", "origin", "createdAt")
194
+ for topic in data:
195
+ table.add_row(
196
+ topic["uuid"],
197
+ topic["filename"],
198
+ topic["mission"]["name"],
199
+ topic["state"],
200
+ topic["location"],
201
+ topic["createdAt"],
202
+ )
203
+ print(table)
204
+
205
+ except httpx.HTTPError as e:
206
+ print(e)
207
+
208
+
209
+ @app.command("wipe", hidden=True)
210
+ def wipe():
211
+ """Wipe all data"""
212
+ # Prompt the user for confirmation
213
+ confirmation = typer.prompt("Are you sure you want to wipe all data? (y/n)")
214
+ if confirmation.lower() == "y":
215
+ second_confirmation = typer.prompt(
216
+ "This action is irreversible. Are you really sure? (y/n)"
217
+ )
218
+ if second_confirmation.lower() != "y":
219
+ print("Operation cancelled.")
220
+ return
221
+
222
+ client = AuthenticatedClient()
223
+ response_queue = client.delete("/queue/clear")
224
+ response_file = client.delete("/file/clear")
225
+ response_analysis = client.delete("/analysis/clear")
226
+ response_mission = client.delete("/mission/clear")
227
+ response_project = client.delete("/project/clear")
228
+
229
+ if response_queue.status_code >= 400:
230
+ print("Failed to clear queue.")
231
+ print(response_queue.text)
232
+ elif response_file.status_code >= 400:
233
+ print("Failed to clear files.")
234
+ print(response_file.text)
235
+ elif response_analysis.status_code >= 400:
236
+ print("Failed to clear analysis.")
237
+ print(response_analysis.text)
238
+ elif response_mission.status_code >= 400:
239
+ print("Failed to clear missions.")
240
+ print(response_mission.text)
241
+ elif response_project.status_code >= 400:
242
+ print("Failed to clear projects.")
243
+ print(response_project.text)
244
+ else:
245
+ print("Data wiped.")
246
+ else:
247
+ print("Operation cancelled.")
248
+
249
+
250
+ @app.command("claim", hidden=True)
251
+ def claim():
252
+ """
253
+ Claim admin rights as the first user
254
+
255
+ Only works if no other user has claimed admin rights before.
256
+ """
257
+
258
+ client = AuthenticatedClient()
259
+ response = client.post("/user/claimAdmin")
260
+ response.raise_for_status()
261
+ print("Admin claimed.")
262
+
263
+
264
+ if __name__ == "__main__":
265
+ app()