kleinkram 0.0.116__py3-none-any.whl → 0.0.118__py3-none-any.whl

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

Potentially problematic release.


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

kleinkram/auth.py CHANGED
@@ -3,6 +3,8 @@ import os
3
3
  import urllib.parse
4
4
  import webbrowser
5
5
  from http.server import BaseHTTPRequestHandler, HTTPServer
6
+ from typing import Optional
7
+
6
8
  from typing_extensions import Annotated
7
9
 
8
10
  from pathlib import Path
@@ -72,6 +74,7 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
72
74
  self.end_headers()
73
75
  self.wfile.write(b"Authentication successful. You can close this window.")
74
76
  return
77
+ print("here")
75
78
 
76
79
  def log_message(self, format, *args):
77
80
  pass
@@ -90,8 +93,8 @@ class AuthenticatedClient(httpx.Client):
90
93
  try:
91
94
  self.tokenfile = TokenFile()
92
95
  self._load_cookies()
93
- except:
94
- print("Not authenticated. Please run 'GTD login'.")
96
+ except Exception as e:
97
+ print(f"{self.tokenfile.endpoint} is not authenticated. Please run 'klein login'.")
95
98
 
96
99
  def _load_cookies(self):
97
100
  if self.tokenfile.isCliToken():
@@ -107,7 +110,7 @@ class AuthenticatedClient(httpx.Client):
107
110
  if not refresh_token:
108
111
  print("No refresh token found. Please login again.")
109
112
  raise Exception("No refresh token found.")
110
- self.cookies.set(REFRESH_TOKEN, refresh_token,)
113
+ self.cookies.set(REFRESH_TOKEN, refresh_token, )
111
114
  response = self.post(
112
115
  "/auth/refresh-token",
113
116
  )
@@ -122,7 +125,7 @@ class AuthenticatedClient(httpx.Client):
122
125
  method, self.tokenfile.endpoint + url, *args, **kwargs
123
126
  )
124
127
  if (
125
- url == "/auth/refresh-token"
128
+ url == "/auth/refresh-token"
126
129
  ) and response.status_code == 401:
127
130
  print("Refresh token expired. Please login again.")
128
131
  response.status_code = 403
@@ -137,32 +140,95 @@ class AuthenticatedClient(httpx.Client):
137
140
  client = AuthenticatedClient()
138
141
 
139
142
 
140
- def login(key: Annotated[str, typer.Option()] = None):
143
+ def login(
144
+ key: Optional[str] = typer.Option(None, help="CLI Key", hidden=True),
145
+ open_browser: Optional[bool] = typer.Option(True, help="Open browser for authentication"),
146
+ ):
147
+ """
148
+ Login into the currently set endpoint.\n
149
+ By default, it will open the browser for authentication. On machines without a browser, you can manually open the URL provided and paste the tokens back.
150
+ """
141
151
  tokenfile = TokenFile()
142
152
  if key:
143
153
  tokenfile.saveTokens(key)
144
154
  else:
145
- print("Opening browser for authentication...")
146
- webbrowser.open(tokenfile.endpoint + "/auth/google?state=cli")
155
+ url = tokenfile.endpoint + "/auth/google?state=cli"
147
156
 
148
- print("Waiting for authentication to complete...")
149
- auth_tokens = get_auth_tokens()
157
+ has_browser = True
158
+ try:
159
+ browser_available = webbrowser.get()
160
+ if not browser_available:
161
+ raise Exception("No web browser available.")
162
+ except Exception as e:
163
+ has_browser = False
150
164
 
151
- if not auth_tokens:
152
- print("Failed to get authentication tokens.")
153
- return
165
+ if has_browser and open_browser:
166
+ webbrowser.open(url)
167
+ auth_tokens = get_auth_tokens()
154
168
 
155
- tokenfile.saveTokens(auth_tokens)
169
+ if not auth_tokens:
170
+ print("Failed to get authentication tokens.")
171
+ return
156
172
 
157
- print("Authentication complete. Tokens saved to tokens.json.")
173
+ tokenfile.saveTokens(auth_tokens)
174
+ print("Authentication complete. Tokens saved to ~/.kleinkram.json.")
158
175
 
176
+ return
159
177
 
160
- def endpoint(endpoint: Annotated[str, typer.Argument()]):
178
+ print(f"Please open the following URL manually in your browser to authenticate: {url + '-no-redirect'}")
179
+ print("Enter the authentication token provided after logging in:")
180
+ manual_auth_token = input("Authentication Token: ")
181
+ manual_refresh_token = input("Refresh Token: ")
182
+ if manual_auth_token:
183
+ tokenfile.saveTokens({AUTH_TOKEN: manual_auth_token, REFRESH_TOKEN: manual_refresh_token})
184
+ print("Authentication complete. Tokens saved to tokens.json.")
185
+ else:
186
+ print("No authentication token provided.")
187
+ return
188
+
189
+
190
+ def setEndpoint(
191
+ endpoint: Optional[str] = typer.Argument(None, help="API endpoint to use")
192
+ ):
193
+ """
194
+ Set the current endpoint
195
+
196
+ Use this command to switch between different API endpoints.\n
197
+ Standard endpoints are:\n
198
+ - http://localhost:3000\n
199
+ - https://api.datasets.leggedrobotics.com\n
200
+ - https://api.datasets.dev.leggedrobotics.com
201
+ """
161
202
  tokenfile = TokenFile()
162
203
  tokenfile.endpoint = endpoint
163
204
  tokenfile.writeToFile()
205
+ print("Endpoint set to: " + endpoint)
206
+ if tokenfile.endpoint not in tokenfile.tokens:
207
+ print("No tokens found for this endpoint.")
208
+
209
+
210
+ def endpoint():
211
+ """
212
+ Get the current endpoint
213
+
214
+ Also displays all endpoints with saved tokens.
215
+ """
216
+ tokenfile = TokenFile()
217
+ print("Current: " + tokenfile.endpoint)
218
+ print("Saved Tokens found for:")
219
+ for _endpoint, _ in tokenfile.tokens.items():
220
+ print("- " + _endpoint)
221
+
222
+
223
+ def setCliKey(
224
+ key: Annotated[str, typer.Argument(help="CLI Key")]
225
+ ):
226
+ """
227
+ Set the CLI key (Actions Only)
164
228
 
165
- def setCliKey(key: Annotated[str, typer.Argument()]):
229
+ Same as login with the --key option.
230
+ Should never be used by the user, only in docker containers launched from within Kleinkram.
231
+ """
166
232
  tokenfile = TokenFile()
167
233
  if not tokenfile.endpoint in tokenfile.tokens:
168
234
  tokenfile.tokens[tokenfile.endpoint] = {}
kleinkram/main.py CHANGED
@@ -1,3 +1,4 @@
1
+ import sys
1
2
  from datetime import datetime, timedelta
2
3
  import os
3
4
 
@@ -10,17 +11,17 @@ from rich.table import Table
10
11
 
11
12
  from .helper import uploadFiles, expand_and_match
12
13
 
13
- from .auth import login, client, endpoint, setCliKey
14
+ from .auth import login, client, endpoint, setCliKey, setEndpoint
14
15
 
15
16
  app = typer.Typer()
16
- projects = typer.Typer(name="projects")
17
- missions = typer.Typer(name="missions")
18
- files = typer.Typer(name="files")
19
- topics = typer.Typer(name="topics")
20
- queue = typer.Typer(name="queue")
21
- user = typer.Typer(name="users")
22
- tagtypes = typer.Typer(name="tagtypes")
23
- tag = typer.Typer(name="tag")
17
+ projects = typer.Typer(name="projects", help="Project operations")
18
+ missions = typer.Typer(name="missions", help="Mission operations")
19
+ files = typer.Typer(name="files", help="File operations")
20
+ topics = typer.Typer(name="topics", help="Topic operations")
21
+ queue = typer.Typer(name="queue", help="Queue operations")
22
+ user = typer.Typer(name="users", help="User operations")
23
+ tagtypes = typer.Typer(name="tagtypes", help="TagType operations")
24
+ tag = typer.Typer(name="tag", help="Tag operations")
24
25
 
25
26
 
26
27
  app.add_typer(projects)
@@ -33,27 +34,39 @@ app.add_typer(tagtypes)
33
34
  app.add_typer(tag)
34
35
  app.command()(login)
35
36
  app.command()(endpoint)
36
- app.command()(setCliKey)
37
+ app.command()(setEndpoint)
38
+ app.command(hidden=True)(setCliKey)
37
39
 
38
40
 
39
41
  @files.command("list")
40
42
  def list_files(
41
- project: Annotated[str, typer.Option()] = None,
42
- mission: Annotated[str, typer.Option()] = None,
43
- topics: Annotated[List[str], typer.Option()] = None,
43
+ project: Optional[str] = typer.Option(None, help="Name of Project"),
44
+ mission: Optional[str] = typer.Option(None, help="Name of Mission"),
45
+ topics: Optional[List[str]] = typer.Option(None, help="Comma separated list of topics")
44
46
  ):
45
47
  """
46
48
  List all files with optional filters for project, mission, or topics.
49
+
50
+ Can list files of a project, mission, or with specific topics (Logical AND).
51
+ Examples:\n
52
+ - 'klein files list'\n
53
+ - 'klein files list --project "Project 1"'\n
54
+ - 'klein files list --mission "Mission 1"'\n
55
+ - 'klein files list --topics "/elevation_mapping/semantic_map,/elevation_mapping/elevation_map_raw"'\n
56
+ - 'klein files list --topics "/elevation_mapping/semantic_map,/elevation_mapping/elevation_map_raw" --mission "Mission A"'
47
57
  """
48
58
  try:
49
59
  url = f"/file/filteredByNames"
60
+ params = {}
61
+ if project:
62
+ params["projectName"] = project
63
+ if mission:
64
+ params["missionName"] = mission
65
+ if topics:
66
+ params["topics"] = topics
50
67
  response = client.get(
51
68
  url,
52
- params={
53
- "projectName": project,
54
- "missionName": mission,
55
- "topics": topics,
56
- },
69
+ params=params,
57
70
  )
58
71
  response.raise_for_status()
59
72
  data = response.json()
@@ -102,11 +115,12 @@ def list_projects():
102
115
 
103
116
  @missions.command("list")
104
117
  def list_missions(
105
- project: Annotated[str, typer.Option()] = None,
106
- verbose: Annotated[bool, typer.Option()] = False,
118
+ project: Optional[str] = typer.Option(None, help="Name of Project"),
119
+ verbose: Optional[bool] = typer.Option(False, help="Outputs a table with more information"),
107
120
  ):
108
121
  """
109
122
  List all missions with optional filter for project.
123
+
110
124
  """
111
125
  try:
112
126
  url = "/mission"
@@ -150,26 +164,47 @@ def list_missions(
150
164
  @missions.command("byUUID")
151
165
  def mission_by_uuid(
152
166
  uuid: Annotated[str, typer.Argument()],
167
+ json: Optional[bool] = typer.Option(False, help="Output as JSON"),
153
168
  ):
169
+ """
170
+ Get mission name, project name, creator and table of its files given a Mission UUID
171
+
172
+ Use the JSON flag to output the full JSON response instead.
173
+
174
+ Can be run with API Key or with login.
175
+ """
154
176
  try:
155
177
  url = "/mission/byUUID"
156
178
  response = client.get(url, params={"uuid": uuid})
157
179
  response.raise_for_status()
158
180
  data = response.json()
159
- print(f"mission: {data['name']}")
160
- print(f"Creator: {data['creator']['name']}")
161
- table = Table("Filename", "Size", "date")
162
- for file in data["files"]:
163
- table.add_row(file["filename"], f"{file['size']}", file["date"])
181
+ if json:
182
+ print(data)
183
+ else:
184
+ print(f"mission: {data['name']}")
185
+ print(f"Creator: {data['creator']['name']}")
186
+ print("Project: " + data["project"]["name"])
187
+ table = Table("Filename", "Size", "date")
188
+ for file in data["files"]:
189
+ table.add_row(file["filename"], f"{file['size']}", file["date"])
190
+ print(table)
164
191
  except httpx.HTTPError as e:
165
192
  print(f"Failed to fetch missions: {e}")
166
193
 
167
194
 
168
195
  @topics.command("list")
169
196
  def topics(
170
- file: Annotated[str, typer.Option()] = None,
171
- full: Annotated[bool, typer.Option()] = False,
197
+ file: Annotated[str, typer.Option(help="Name of File")],
198
+ full: Annotated[bool, typer.Option(help="As a table with additional parameters")] = False,
199
+ # Todo add mission / project as optional argument as filenames are not unique
172
200
  ):
201
+ """
202
+ List topics for a file
203
+
204
+ Only makes sense with MCAP files as we don't associate topics with BAGs.
205
+ """
206
+ if file.endswith(".bag"):
207
+ print("BAG files generally do not have topics")
173
208
  try:
174
209
  url = "/file/byName"
175
210
  response = client.get(url, params={"name": file})
@@ -195,11 +230,19 @@ def topics(
195
230
 
196
231
 
197
232
  @projects.command("create")
198
- def create_project(name: Annotated[str, typer.Option()]):
233
+ def create_project(
234
+ name: Annotated[str, typer.Option(help="Name of Project")],
235
+ description: Annotated[str, typer.Option(help="Description of Project")],
236
+ ):
237
+ """
238
+ Create a new project
239
+ """
199
240
  try:
200
241
  url = "/project/create"
201
- response = client.post(url, json={"name": name})
202
- response.raise_for_status()
242
+ response = client.post(url, json={"name": name, "description": description, "requiredTags": []}) # TODO: Add required tags as option
243
+ if response.status_code >= 400:
244
+ print(f"Failed to create project: {response.json()["message"]}")
245
+ return
203
246
  print("Project created")
204
247
 
205
248
  except httpx.HTTPError as e:
@@ -208,12 +251,23 @@ def create_project(name: Annotated[str, typer.Option()]):
208
251
 
209
252
  @app.command("upload")
210
253
  def upload(
211
- path: Annotated[str, typer.Option(prompt=True)],
212
- project: Annotated[str, typer.Option(prompt=True)],
213
- mission: Annotated[str, typer.Option(prompt=True)],
254
+ path: Annotated[str, typer.Option(prompt=True, help="Path to files to upload, Regex supported")],
255
+ project: Annotated[str, typer.Option(prompt=True, help="Name of Project")],
256
+ mission: Annotated[str, typer.Option(prompt=True, help="Name of Mission to create")],
214
257
  ):
258
+ """
259
+ Upload files matching the path to a mission in a project.
260
+
261
+ The mission name must be unique within the project and not yet created.\n
262
+ Examples:\n
263
+ - 'klein upload --path "~/data/**/*.bag" --project "Project 1" --mission "Mission 1"'\n
264
+
265
+ """
215
266
  files = expand_and_match(path)
216
267
  filenames = list(map(lambda x: x.split("/")[-1], filter(lambda x: not os.path.isdir(x),files)))
268
+ if not filenames:
269
+ print("No files found")
270
+ return
217
271
  filepaths = {}
218
272
  for path in files:
219
273
  if not os.path.isdir(path):
@@ -245,7 +299,7 @@ def upload(
245
299
 
246
300
  create_mission_url = "/mission/create"
247
301
  new_mission = client.post(
248
- create_mission_url, json={"name": mission, "projectUUID": project_json["uuid"]}
302
+ create_mission_url, json={"name": mission, "projectUUID": project_json["uuid"], "tags": []}
249
303
  )
250
304
  new_mission.raise_for_status()
251
305
  new_mission_data = new_mission.json()
@@ -320,7 +374,7 @@ def clear_queue():
320
374
  print("Operation cancelled.")
321
375
 
322
376
 
323
- @app.command("wipe")
377
+ @app.command("wipe", hidden=True)
324
378
  def wipe():
325
379
  """Wipe all data"""
326
380
  # Prompt the user for confirmation
@@ -360,8 +414,13 @@ def wipe():
360
414
  print("Operation cancelled.")
361
415
 
362
416
 
363
- @app.command("claim")
417
+ @app.command("claim", hidden=True)
364
418
  def claim():
419
+ """
420
+ Claim admin rights as the first user
421
+
422
+ Only works if no other user has claimed admin rights before.
423
+ """
365
424
  response = client.post("/user/claimAdmin")
366
425
  response.raise_for_status()
367
426
  print("Admin claimed.")
@@ -369,6 +428,7 @@ def claim():
369
428
 
370
429
  @user.command("list")
371
430
  def users():
431
+ """List all users"""
372
432
  response = client.get("/user/all")
373
433
  response.raise_for_status()
374
434
  data = response.json()
@@ -380,6 +440,7 @@ def users():
380
440
 
381
441
  @user.command("info")
382
442
  def user_info():
443
+ """Get logged in user info"""
383
444
  response = client.get("/user/me")
384
445
  response.raise_for_status()
385
446
  data = response.json()
@@ -388,6 +449,7 @@ def user_info():
388
449
 
389
450
  @user.command("promote")
390
451
  def promote(email: Annotated[str, typer.Option()]):
452
+ """Promote another user to admin"""
391
453
  response = client.post("/user/promote", json={"email": email})
392
454
  response.raise_for_status()
393
455
  print("User promoted.")
@@ -395,6 +457,7 @@ def promote(email: Annotated[str, typer.Option()]):
395
457
 
396
458
  @user.command("demote")
397
459
  def demote(email: Annotated[str, typer.Option()]):
460
+ """Demote another user from admin"""
398
461
  response = client.post("/user/demote", json={"email": email})
399
462
  response.raise_for_status()
400
463
  print("User demoted.")
@@ -404,6 +467,7 @@ def demote(email: Annotated[str, typer.Option()]):
404
467
  def download(
405
468
  missionuuid: Annotated[str, typer.Argument()],
406
469
  ):
470
+ """Download file"""
407
471
  try:
408
472
  response = client.get("/file/downloadWithToken", params={"uuid": missionuuid})
409
473
  response.raise_for_status()
@@ -417,6 +481,7 @@ def addTag(
417
481
  tagtypeuuid: Annotated[str, typer.Argument()],
418
482
  value: Annotated[str, typer.Argument()],
419
483
  ):
484
+ """Tag a mission"""
420
485
  try:
421
486
  response = client.post("/tag/addTag", json={"mission": missionuuid, "tagType": tagtypeuuid, "value": value})
422
487
  if response.status_code < 400:
@@ -426,13 +491,15 @@ def addTag(
426
491
  print("Failed to tag mission")
427
492
  except httpx.HTTPError as e:
428
493
  print(e)
429
- print("Failed to tag mission"
430
- )
494
+ print("Failed to tag mission")
495
+ sys.exit(1)
496
+
431
497
 
432
498
  @tagtypes.command('list')
433
499
  def tagTypes(
434
500
  verbose: Annotated[bool, typer.Option()] = False,
435
501
  ):
502
+ """List all tagtypes"""
436
503
  try:
437
504
  response = client.get("/tag/all")
438
505
  response.raise_for_status()
@@ -453,6 +520,7 @@ def tagTypes(
453
520
  def deleteTag(
454
521
  taguuid: Annotated[str, typer.Argument()],
455
522
  ):
523
+ """Delete a tag"""
456
524
  try:
457
525
  response = client.delete("/tag/deleteTag", params={"uuid": taguuid})
458
526
  if response.status_code < 400:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: kleinkram
3
- Version: 0.0.116
3
+ Version: 0.0.118
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
@@ -0,0 +1,10 @@
1
+ kleinkram/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ kleinkram/auth.py,sha256=s6Lek1vLRs2cXSwfGmEWuuzmszMhaTAJbh9icxWPSkQ,7628
3
+ kleinkram/consts.py,sha256=8kCOeSdMqEyB89hT55w5yd1fksUwbj52a2nYkZTZI_0,88
4
+ kleinkram/helper.py,sha256=mP6AohHWU8kckAi1Oevgs0gfGPH1Qzkp0f7jiQF9u5I,2198
5
+ kleinkram/main.py,sha256=O1VOT8YFOc6YcyLhd723iOScvO1ILUG2LW1ggkUZVJY,18071
6
+ kleinkram-0.0.118.dist-info/METADATA,sha256=8eKAgwE0t4W6ecNaQ4r3eOnzgbCa-6ArE_aetX_otHk,733
7
+ kleinkram-0.0.118.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
8
+ kleinkram-0.0.118.dist-info/entry_points.txt,sha256=RHXtRzcreVHImatgjhQwZQ6GdJThElYjHEWcR1BPXUI,45
9
+ kleinkram-0.0.118.dist-info/licenses/LICENSE,sha256=ixuiBLtpoK3iv89l7ylKkg9rs2GzF9ukPH7ynZYzK5s,35148
10
+ kleinkram-0.0.118.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- kleinkram/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- kleinkram/auth.py,sha256=0pu0RQQrtTFoW07Rgni7FAMJS3e0BZ9Pz9QrFPsbfxg,5343
3
- kleinkram/consts.py,sha256=8kCOeSdMqEyB89hT55w5yd1fksUwbj52a2nYkZTZI_0,88
4
- kleinkram/helper.py,sha256=mP6AohHWU8kckAi1Oevgs0gfGPH1Qzkp0f7jiQF9u5I,2198
5
- kleinkram/main.py,sha256=dS0gOy_KRqrzbX5xynROi6cnbDb_9Aww2ER4U4bpfU4,15106
6
- kleinkram-0.0.116.dist-info/METADATA,sha256=ANiDjnZ0f-h4P6d6pUmUaeBDOroCDfojKMpDL_3d5Yk,733
7
- kleinkram-0.0.116.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
8
- kleinkram-0.0.116.dist-info/entry_points.txt,sha256=RHXtRzcreVHImatgjhQwZQ6GdJThElYjHEWcR1BPXUI,45
9
- kleinkram-0.0.116.dist-info/licenses/LICENSE,sha256=ixuiBLtpoK3iv89l7ylKkg9rs2GzF9ukPH7ynZYzK5s,35148
10
- kleinkram-0.0.116.dist-info/RECORD,,