kleinkram 0.36.2__py3-none-any.whl → 0.36.2.dev20241118065826__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/__init__.py +6 -0
- kleinkram/__main__.py +6 -0
- kleinkram/_version.py +6 -0
- kleinkram/api/__init__.py +0 -0
- kleinkram/api/client.py +65 -0
- kleinkram/api/file_transfer.py +328 -0
- kleinkram/api/routes.py +460 -0
- kleinkram/app.py +180 -0
- kleinkram/auth.py +96 -0
- kleinkram/commands/__init__.py +1 -0
- kleinkram/commands/download.py +103 -0
- kleinkram/commands/endpoint.py +62 -0
- kleinkram/commands/list.py +93 -0
- kleinkram/commands/mission.py +57 -0
- kleinkram/commands/project.py +24 -0
- kleinkram/commands/upload.py +138 -0
- kleinkram/commands/verify.py +117 -0
- kleinkram/config.py +171 -0
- kleinkram/consts.py +8 -1
- kleinkram/core.py +14 -0
- kleinkram/enums.py +10 -0
- kleinkram/errors.py +59 -0
- kleinkram/main.py +6 -484
- kleinkram/models.py +186 -0
- kleinkram/utils.py +179 -0
- {kleinkram-0.36.2.dist-info/licenses → kleinkram-0.36.2.dev20241118065826.dist-info}/LICENSE +1 -1
- kleinkram-0.36.2.dev20241118065826.dist-info/METADATA +113 -0
- kleinkram-0.36.2.dev20241118065826.dist-info/RECORD +33 -0
- {kleinkram-0.36.2.dist-info → kleinkram-0.36.2.dev20241118065826.dist-info}/WHEEL +2 -1
- kleinkram-0.36.2.dev20241118065826.dist-info/entry_points.txt +2 -0
- kleinkram-0.36.2.dev20241118065826.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_utils.py +153 -0
- kleinkram/api_client.py +0 -63
- kleinkram/auth/auth.py +0 -160
- kleinkram/endpoint/endpoint.py +0 -58
- kleinkram/error_handling.py +0 -177
- kleinkram/file/file.py +0 -144
- kleinkram/helper.py +0 -272
- kleinkram/mission/mission.py +0 -310
- kleinkram/project/project.py +0 -138
- kleinkram/queue/queue.py +0 -8
- kleinkram/tag/tag.py +0 -71
- kleinkram/topic/topic.py +0 -55
- kleinkram/user/user.py +0 -75
- kleinkram-0.36.2.dist-info/METADATA +0 -25
- kleinkram-0.36.2.dist-info/RECORD +0 -20
- kleinkram-0.36.2.dist-info/entry_points.txt +0 -2
kleinkram/api/routes.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from typing import cast
|
|
5
|
+
from typing import Dict
|
|
6
|
+
from typing import List
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from typing import Tuple
|
|
9
|
+
from typing import Union
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from kleinkram.api.client import AuthenticatedClient
|
|
14
|
+
from kleinkram.config import Config
|
|
15
|
+
from kleinkram.errors import MissionDoesNotExist
|
|
16
|
+
from kleinkram.errors import MissionExists
|
|
17
|
+
from kleinkram.errors import NoPermission
|
|
18
|
+
from kleinkram.models import DataType
|
|
19
|
+
from kleinkram.models import File
|
|
20
|
+
from kleinkram.models import FilesById
|
|
21
|
+
from kleinkram.models import FilesByMission
|
|
22
|
+
from kleinkram.models import FileState
|
|
23
|
+
from kleinkram.models import Mission
|
|
24
|
+
from kleinkram.models import MissionById
|
|
25
|
+
from kleinkram.models import MissionByName
|
|
26
|
+
from kleinkram.models import Project
|
|
27
|
+
from kleinkram.models import TagType
|
|
28
|
+
from kleinkram.utils import is_valid_uuid4
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# TODO: change to 10000
|
|
32
|
+
MAX_PAGINATION = 1_000
|
|
33
|
+
|
|
34
|
+
TEMP_CREDS = "/file/temporaryAccess"
|
|
35
|
+
CLAIM_ADMIN = "/user/claimAdmin"
|
|
36
|
+
|
|
37
|
+
PROJECT_BY_NAME = "/project/byName"
|
|
38
|
+
PROJECT_BY_ID = "/project/one"
|
|
39
|
+
PROJECT_CREATE = "/project/create"
|
|
40
|
+
PROJECT_ALL = "/project/filtered"
|
|
41
|
+
|
|
42
|
+
MISSION_BY_NAME = "/mission/byName"
|
|
43
|
+
MISSION_BY_ID = "/mission/one"
|
|
44
|
+
MISSION_CREATE = "/mission/create"
|
|
45
|
+
MISSION_BY_PROJECT_NAME = "/mission/filteredByProjectName"
|
|
46
|
+
MISSION_UPDATE_METADATA = "/mission/tags"
|
|
47
|
+
|
|
48
|
+
ALL_USERS = "/user/all"
|
|
49
|
+
USER_INFO = "/user/me"
|
|
50
|
+
PROMOTE_USER = "/user/promote"
|
|
51
|
+
DEMOTE_USER = "/user/demote"
|
|
52
|
+
|
|
53
|
+
FILE_DOWNLOAD = "/file/download"
|
|
54
|
+
FILE_QUERY = "/file/filteredByNames"
|
|
55
|
+
FILE_ONE = "/file/one"
|
|
56
|
+
FILE_OF_MISSION = "/file/ofMission"
|
|
57
|
+
|
|
58
|
+
TAG_TYPE_BY_NAME = "/tag/filtered"
|
|
59
|
+
|
|
60
|
+
GET_STATUS = "/user/me"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def claim_admin(client: AuthenticatedClient) -> None:
|
|
64
|
+
"""\
|
|
65
|
+
the first user on the system could call this
|
|
66
|
+
"""
|
|
67
|
+
response = client.post(CLAIM_ADMIN)
|
|
68
|
+
response.raise_for_status()
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_project(
|
|
73
|
+
client: AuthenticatedClient, identifier: Union[str, UUID]
|
|
74
|
+
) -> Union[tuple[UUID, Dict[str, Any]], tuple[None, None]]:
|
|
75
|
+
|
|
76
|
+
if isinstance(identifier, UUID):
|
|
77
|
+
params = {"uuid": str(identifier)}
|
|
78
|
+
else:
|
|
79
|
+
params = {"name": identifier}
|
|
80
|
+
|
|
81
|
+
resp = client.get("/missions", params=params)
|
|
82
|
+
|
|
83
|
+
if resp.status_code in (403, 404):
|
|
84
|
+
return None, None
|
|
85
|
+
|
|
86
|
+
# TODO: handle other status codes
|
|
87
|
+
resp.raise_for_status()
|
|
88
|
+
|
|
89
|
+
details = resp.json()
|
|
90
|
+
return UUID(details["uuid"], version=4), details
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def create_project(
|
|
94
|
+
client: AuthenticatedClient,
|
|
95
|
+
project_name: str,
|
|
96
|
+
*,
|
|
97
|
+
description: str | None = None,
|
|
98
|
+
check_exists: bool = False,
|
|
99
|
+
) -> UUID:
|
|
100
|
+
"""\
|
|
101
|
+
creates a new mission with the given name and project_id
|
|
102
|
+
|
|
103
|
+
if check_exists is True, the function will return the existing mission_id,
|
|
104
|
+
otherwise if the mission already exists an error will be raised
|
|
105
|
+
"""
|
|
106
|
+
if description is None:
|
|
107
|
+
description = "autogenerated by CLI"
|
|
108
|
+
|
|
109
|
+
if check_exists:
|
|
110
|
+
project_id, _ = get_project(client, project_name)
|
|
111
|
+
if project_id is not None:
|
|
112
|
+
return project_id
|
|
113
|
+
|
|
114
|
+
if is_valid_uuid4(project_name):
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Project name: `{project_name}` is a valid UUIDv4, "
|
|
117
|
+
"project names must not be valid UUIDv4's"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
resp = client.post(
|
|
121
|
+
MISSION_CREATE,
|
|
122
|
+
json={
|
|
123
|
+
"name": project_name,
|
|
124
|
+
"description": description,
|
|
125
|
+
"requiredTags": [],
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if resp.status_code >= 400:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"Failed to create project. Status Code: "
|
|
132
|
+
f"{str(resp.status_code)}\n"
|
|
133
|
+
f"{resp.json()['message'][0]}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return UUID(resp.json()["uuid"], version=4)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_mission_id_by_name(
|
|
140
|
+
client: AuthenticatedClient, mission_name, project_id: UUID
|
|
141
|
+
) -> Optional[UUID]:
|
|
142
|
+
params = {"name": mission_name, "projectUUID": str(project_id)}
|
|
143
|
+
resp = client.get(MISSION_BY_NAME, params=params)
|
|
144
|
+
|
|
145
|
+
if resp.status_code in (403, 404):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
# TODO: handle other status codes
|
|
149
|
+
resp.raise_for_status()
|
|
150
|
+
|
|
151
|
+
data = resp.json()
|
|
152
|
+
|
|
153
|
+
return UUID(data["uuid"], version=4)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_mission_by_id(
|
|
157
|
+
client: AuthenticatedClient, mission_id: UUID
|
|
158
|
+
) -> Optional[Mission]:
|
|
159
|
+
params = {"uuid": str(mission_id), "take": MAX_PAGINATION}
|
|
160
|
+
resp = client.get(FILE_OF_MISSION, params=params)
|
|
161
|
+
|
|
162
|
+
if resp.status_code in (403, 404):
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
resp.raise_for_status()
|
|
166
|
+
data = resp.json()[0]
|
|
167
|
+
files = [_parse_file(file) for file in data]
|
|
168
|
+
|
|
169
|
+
resp = client.get(MISSION_BY_ID, params={"uuid": str(mission_id)})
|
|
170
|
+
resp.raise_for_status()
|
|
171
|
+
|
|
172
|
+
mission_data = resp.json()
|
|
173
|
+
mission = Mission(
|
|
174
|
+
id=mission_id,
|
|
175
|
+
name=mission_data["name"],
|
|
176
|
+
project_id=UUID(mission_data["project"]["uuid"], version=4),
|
|
177
|
+
project_name=mission_data["project"]["name"],
|
|
178
|
+
files=files,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return mission
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_project_id_by_name(
|
|
185
|
+
client: AuthenticatedClient, project_name: str
|
|
186
|
+
) -> Optional[UUID]:
|
|
187
|
+
params = {"name": project_name}
|
|
188
|
+
resp = client.get(PROJECT_BY_NAME, params=params)
|
|
189
|
+
|
|
190
|
+
if resp.status_code in (403, 404):
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
resp.raise_for_status()
|
|
194
|
+
|
|
195
|
+
return UUID(resp.json()["uuid"], version=4)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def create_mission(
|
|
199
|
+
client: AuthenticatedClient,
|
|
200
|
+
project_id: UUID,
|
|
201
|
+
mission_name: str,
|
|
202
|
+
*,
|
|
203
|
+
tags: Optional[Dict[UUID, str]] = None,
|
|
204
|
+
ignore_missing_tags: bool = False,
|
|
205
|
+
) -> UUID:
|
|
206
|
+
"""\
|
|
207
|
+
creates a new mission with the given name and project_id
|
|
208
|
+
|
|
209
|
+
if check_exists is True, the function will return the existing mission_id,
|
|
210
|
+
otherwise if the mission already exists an error will be raised
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
if get_mission_id_by_name(client, mission_name, project_id) is not None:
|
|
214
|
+
raise MissionExists(f"Mission with name: `{mission_name}` already exists")
|
|
215
|
+
|
|
216
|
+
if is_valid_uuid4(mission_name):
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"Mission name: `{mission_name}` is a valid UUIDv4, "
|
|
219
|
+
"mission names must not be valid UUIDv4's"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
payload = {
|
|
223
|
+
"name": mission_name,
|
|
224
|
+
"projectUUID": str(project_id),
|
|
225
|
+
"tags": {str(k): v for k, v in tags.items()} if tags else {},
|
|
226
|
+
"ignoreTags": ignore_missing_tags,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
resp = client.post(MISSION_CREATE, json=payload)
|
|
230
|
+
resp.raise_for_status()
|
|
231
|
+
|
|
232
|
+
return UUID(resp.json()["uuid"], version=4)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_project_permission_level(client: AuthenticatedClient, project_id: UUID) -> int:
|
|
236
|
+
"""\
|
|
237
|
+
we need this to check if a user has the permissions to
|
|
238
|
+
create a mission in an existing project
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
resp = client.get("/user/permissions")
|
|
242
|
+
resp.raise_for_status()
|
|
243
|
+
|
|
244
|
+
project_group: List[Dict[str, Union[str, int]]] = resp.json().get("projects", [])
|
|
245
|
+
filtered_by_id = filter(lambda x: x.get("uuid") == str(project_id), project_group)
|
|
246
|
+
|
|
247
|
+
# it is possilbe that a user has access to a project via multiple groups
|
|
248
|
+
# in this case we take the highest permission level
|
|
249
|
+
return cast(int, max(map(lambda x: x.get("access", 0), filtered_by_id)))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _parse_file(file: Dict[str, Any]) -> File:
|
|
253
|
+
project_id = UUID(file["mission"]["project"]["uuid"], version=4)
|
|
254
|
+
project_name = file["mission"]["project"]["name"]
|
|
255
|
+
|
|
256
|
+
mission_id = UUID(file["mission"]["uuid"], version=4)
|
|
257
|
+
mission_name = file["mission"]["name"]
|
|
258
|
+
|
|
259
|
+
filename = file["filename"]
|
|
260
|
+
file_id = UUID(file["uuid"], version=4)
|
|
261
|
+
file_size = file["size"]
|
|
262
|
+
file_hash = file["hash"]
|
|
263
|
+
|
|
264
|
+
parsed = File(
|
|
265
|
+
id=file_id,
|
|
266
|
+
name=filename,
|
|
267
|
+
size=file_size,
|
|
268
|
+
hash=file_hash,
|
|
269
|
+
project_id=project_id,
|
|
270
|
+
project_name=project_name,
|
|
271
|
+
mission_id=mission_id,
|
|
272
|
+
mission_name=mission_name,
|
|
273
|
+
state=FileState(file["state"]),
|
|
274
|
+
)
|
|
275
|
+
return parsed
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def get_file(client: AuthenticatedClient, id: UUID) -> File:
|
|
279
|
+
resp = client.get(FILE_ONE, params={"uuid": str(id)})
|
|
280
|
+
resp.raise_for_status()
|
|
281
|
+
|
|
282
|
+
return _parse_file(resp.json())
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def get_files(
|
|
286
|
+
client: AuthenticatedClient,
|
|
287
|
+
name: Optional[str] = None,
|
|
288
|
+
project: Optional[str] = None,
|
|
289
|
+
mission: Optional[str] = None,
|
|
290
|
+
topics: Optional[List[str]] = None,
|
|
291
|
+
tags: Optional[Dict[str, str]] = None,
|
|
292
|
+
) -> List[File]:
|
|
293
|
+
# TODO: allow to search by id
|
|
294
|
+
|
|
295
|
+
params: Dict[str, Any] = {"take": MAX_PAGINATION}
|
|
296
|
+
if name is not None:
|
|
297
|
+
params["name"] = name
|
|
298
|
+
if project is not None:
|
|
299
|
+
params["projectName"] = project
|
|
300
|
+
if mission is not None:
|
|
301
|
+
params["missionName"] = mission
|
|
302
|
+
if topics:
|
|
303
|
+
params["topics"] = ",".join(topics)
|
|
304
|
+
if tags:
|
|
305
|
+
params["tags"] = tags
|
|
306
|
+
|
|
307
|
+
resp = client.get(FILE_QUERY, params=params)
|
|
308
|
+
resp.raise_for_status()
|
|
309
|
+
|
|
310
|
+
files = []
|
|
311
|
+
data = resp.json()
|
|
312
|
+
|
|
313
|
+
for file in data:
|
|
314
|
+
try:
|
|
315
|
+
parsed = _parse_file(file)
|
|
316
|
+
files.append(parsed)
|
|
317
|
+
except Exception:
|
|
318
|
+
print(f"Error parsing file: {file}")
|
|
319
|
+
return files
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def get_missions(
|
|
323
|
+
client: AuthenticatedClient,
|
|
324
|
+
project: Optional[str] = None,
|
|
325
|
+
tags: Optional[Dict[str, str]] = None,
|
|
326
|
+
) -> list[Mission]:
|
|
327
|
+
# TODO: use a better endpoint once this exists
|
|
328
|
+
matching_files = get_files(client, project=project, tags=tags)
|
|
329
|
+
|
|
330
|
+
ret = {}
|
|
331
|
+
for file in matching_files:
|
|
332
|
+
ret[file.mission_id] = Mission(
|
|
333
|
+
id=file.mission_id,
|
|
334
|
+
name=file.mission_name,
|
|
335
|
+
project_id=file.project_id,
|
|
336
|
+
project_name=file.project_name,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return list(ret.values())
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def get_projects(client: AuthenticatedClient) -> list[Project]:
|
|
343
|
+
resp = client.get(PROJECT_ALL)
|
|
344
|
+
resp.raise_for_status()
|
|
345
|
+
|
|
346
|
+
ret = []
|
|
347
|
+
for pr in resp.json()[0]:
|
|
348
|
+
id = UUID(pr["uuid"], version=4)
|
|
349
|
+
name = pr["name"]
|
|
350
|
+
description = pr["description"]
|
|
351
|
+
ret.append(Project(id=id, name=name, description=description))
|
|
352
|
+
|
|
353
|
+
return ret
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def get_mission_by_spec(
|
|
357
|
+
client: AuthenticatedClient, spec: Union[MissionById, MissionByName]
|
|
358
|
+
) -> Optional[Mission]:
|
|
359
|
+
if isinstance(spec, MissionById):
|
|
360
|
+
return get_mission_by_id(client, spec.id)
|
|
361
|
+
|
|
362
|
+
if isinstance(spec.project, UUID):
|
|
363
|
+
project_id = spec.project
|
|
364
|
+
else:
|
|
365
|
+
project_id = get_project_id_by_name(client, spec.project)
|
|
366
|
+
if project_id is None:
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
mission_id = get_mission_id_by_name(client, spec.name, project_id)
|
|
370
|
+
if mission_id is None:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
return get_mission_by_id(client, mission_id)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def get_files_by_file_spec(
|
|
377
|
+
client: AuthenticatedClient, spec: Union[FilesByMission, FilesById]
|
|
378
|
+
) -> List[File]:
|
|
379
|
+
if isinstance(spec, FilesById):
|
|
380
|
+
return [get_file(client, file_id) for file_id in spec.ids]
|
|
381
|
+
|
|
382
|
+
parsed_mission = get_mission_by_spec(client, spec.mission)
|
|
383
|
+
if parsed_mission is None:
|
|
384
|
+
raise ValueError("mission not found")
|
|
385
|
+
|
|
386
|
+
if spec.files:
|
|
387
|
+
filtered = [
|
|
388
|
+
f
|
|
389
|
+
for f in parsed_mission.files
|
|
390
|
+
if f.id in spec.files or f.name in spec.files
|
|
391
|
+
]
|
|
392
|
+
return filtered
|
|
393
|
+
|
|
394
|
+
return parsed_mission.files
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def get_tag_type_by_name(
|
|
398
|
+
client: AuthenticatedClient, tag_name: str
|
|
399
|
+
) -> Optional[TagType]:
|
|
400
|
+
resp = client.get(TAG_TYPE_BY_NAME, params={"name": tag_name, "take": 1})
|
|
401
|
+
|
|
402
|
+
if resp.status_code in (403, 404):
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
resp.raise_for_status()
|
|
406
|
+
|
|
407
|
+
data = resp.json()[0]
|
|
408
|
+
tag_type = TagType(
|
|
409
|
+
name=data["name"],
|
|
410
|
+
id=UUID(data["uuid"], version=4),
|
|
411
|
+
data_type=DataType(data["datatype"]),
|
|
412
|
+
description=data["description"],
|
|
413
|
+
)
|
|
414
|
+
return tag_type
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def get_tags_map(
|
|
418
|
+
client: AuthenticatedClient, metadata: Dict[str, str]
|
|
419
|
+
) -> Dict[UUID, str]:
|
|
420
|
+
# TODO: this needs a better endpoint
|
|
421
|
+
ret = {}
|
|
422
|
+
for key, val in metadata.items():
|
|
423
|
+
tag_type = get_tag_type_by_name(client, key)
|
|
424
|
+
|
|
425
|
+
if tag_type is None:
|
|
426
|
+
print(f"tag: {key} not found")
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
ret[tag_type.id] = val
|
|
430
|
+
|
|
431
|
+
return ret
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def update_mission_metadata(
|
|
435
|
+
client: AuthenticatedClient, mission_id: UUID, metadata: Dict[str, str]
|
|
436
|
+
) -> None:
|
|
437
|
+
tags_dct = get_tags_map(client, metadata)
|
|
438
|
+
payload = {
|
|
439
|
+
"missionUUID": str(mission_id),
|
|
440
|
+
"tags": {str(k): v for k, v in tags_dct.items()},
|
|
441
|
+
}
|
|
442
|
+
resp = client.post(MISSION_UPDATE_METADATA, json=payload)
|
|
443
|
+
|
|
444
|
+
if resp.status_code == 404:
|
|
445
|
+
raise MissionDoesNotExist
|
|
446
|
+
|
|
447
|
+
if resp.status_code == 403:
|
|
448
|
+
raise NoPermission
|
|
449
|
+
|
|
450
|
+
resp.raise_for_status()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def get_api_version() -> Tuple[int, int, int]:
|
|
454
|
+
config = Config()
|
|
455
|
+
client = httpx.Client()
|
|
456
|
+
|
|
457
|
+
resp = client.get(f"{config.endpoint}{GET_STATUS}")
|
|
458
|
+
vers = resp.headers["kleinkram-version"].split(".")
|
|
459
|
+
|
|
460
|
+
return tuple(map(int, vers)) # type: ignore
|
kleinkram/app.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import Callable
|
|
7
|
+
from typing import List
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from typing import Type
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from click import Context
|
|
13
|
+
from kleinkram._version import __version__
|
|
14
|
+
from kleinkram.api.client import AuthenticatedClient
|
|
15
|
+
from kleinkram.api.routes import claim_admin
|
|
16
|
+
from kleinkram.api.routes import get_api_version
|
|
17
|
+
from kleinkram.auth import login_flow
|
|
18
|
+
from kleinkram.commands.download import download_typer
|
|
19
|
+
from kleinkram.commands.endpoint import endpoint_typer
|
|
20
|
+
from kleinkram.commands.list import list_typer
|
|
21
|
+
from kleinkram.commands.mission import mission_typer
|
|
22
|
+
from kleinkram.commands.project import project_typer
|
|
23
|
+
from kleinkram.commands.upload import upload_typer
|
|
24
|
+
from kleinkram.commands.verify import verify_typer
|
|
25
|
+
from kleinkram.config import Config
|
|
26
|
+
from kleinkram.config import get_shared_state
|
|
27
|
+
from kleinkram.errors import InvalidCLIVersion
|
|
28
|
+
from kleinkram.utils import get_supported_api_version
|
|
29
|
+
from rich.console import Console
|
|
30
|
+
from typer.core import TyperGroup
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CLI_HELP = """\
|
|
34
|
+
Kleinkram CLI
|
|
35
|
+
|
|
36
|
+
The Kleinkram CLI is a command line interface for Kleinkram.
|
|
37
|
+
For a list of available commands, run `klein --help` or visit \
|
|
38
|
+
https://docs.datasets.leggedrobotics.com/usage/cli/cli-getting-started.html \
|
|
39
|
+
for more information.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CommandTypes(str, Enum):
|
|
44
|
+
AUTH = "Authentication Commands"
|
|
45
|
+
CORE = "Core Commands"
|
|
46
|
+
CRUD = "Create Update Delete Commands"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OrderCommands(TyperGroup):
|
|
50
|
+
def list_commands(self, ctx: Context) -> List[str]:
|
|
51
|
+
_ = ctx # suppress unused variable warning
|
|
52
|
+
return list(self.commands)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
ExceptionHandler = Callable[[Exception], int]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ErrorHandledTyper(typer.Typer):
|
|
59
|
+
"""\
|
|
60
|
+
error handlers that are last added will be used first
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
_error_handlers: OrderedDict[Type[Exception], ExceptionHandler]
|
|
64
|
+
|
|
65
|
+
def error_handler(
|
|
66
|
+
self, exc: type[Exception]
|
|
67
|
+
) -> Callable[[ExceptionHandler], ExceptionHandler]:
|
|
68
|
+
def dec(func: ExceptionHandler) -> ExceptionHandler:
|
|
69
|
+
self._error_handlers[exc] = func
|
|
70
|
+
return func
|
|
71
|
+
|
|
72
|
+
return dec
|
|
73
|
+
|
|
74
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
75
|
+
super().__init__(*args, **kwargs)
|
|
76
|
+
self._error_handlers = OrderedDict()
|
|
77
|
+
|
|
78
|
+
def __call__(self, *args: Any, **kwargs: Any) -> int:
|
|
79
|
+
try:
|
|
80
|
+
return super().__call__(*args, **kwargs)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
for tp, handler in reversed(self._error_handlers.items()):
|
|
83
|
+
if isinstance(e, tp):
|
|
84
|
+
exit_code = handler(e)
|
|
85
|
+
raise SystemExit(exit_code)
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
app = ErrorHandledTyper(
|
|
90
|
+
cls=OrderCommands,
|
|
91
|
+
help=CLI_HELP,
|
|
92
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
93
|
+
no_args_is_help=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.error_handler(Exception)
|
|
98
|
+
def base_handler(exc: Exception) -> int:
|
|
99
|
+
if not get_shared_state().debug:
|
|
100
|
+
console = Console()
|
|
101
|
+
console.print(f"{type(exc).__name__}: {exc}", style="red")
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
raise exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
app.add_typer(download_typer, name="download", rich_help_panel=CommandTypes.CORE)
|
|
108
|
+
app.add_typer(upload_typer, name="upload", rich_help_panel=CommandTypes.CORE)
|
|
109
|
+
app.add_typer(verify_typer, name="verify", rich_help_panel=CommandTypes.CORE)
|
|
110
|
+
app.add_typer(list_typer, name="list", rich_help_panel=CommandTypes.CORE)
|
|
111
|
+
app.add_typer(endpoint_typer, name="endpoint", rich_help_panel=CommandTypes.AUTH)
|
|
112
|
+
app.add_typer(mission_typer, name="mission", rich_help_panel=CommandTypes.CRUD)
|
|
113
|
+
app.add_typer(project_typer, name="project", rich_help_panel=CommandTypes.CRUD)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command(rich_help_panel=CommandTypes.AUTH)
|
|
117
|
+
def login(
|
|
118
|
+
key: Optional[str] = typer.Option(None, help="CLI key"),
|
|
119
|
+
headless: bool = typer.Option(False),
|
|
120
|
+
) -> None:
|
|
121
|
+
login_flow(key=key, headless=headless)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command(rich_help_panel=CommandTypes.AUTH)
|
|
125
|
+
def logout(all: bool = typer.Option(False, help="logout on all enpoints")) -> None:
|
|
126
|
+
config = Config()
|
|
127
|
+
config.clear_credentials(all=all)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command(hidden=True)
|
|
131
|
+
def claim():
|
|
132
|
+
client = AuthenticatedClient()
|
|
133
|
+
claim_admin(client)
|
|
134
|
+
print("admin rights claimed successfully.")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _version_cb(value: bool) -> None:
|
|
138
|
+
if value:
|
|
139
|
+
typer.echo(__version__)
|
|
140
|
+
raise typer.Exit()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def check_version_compatiblity() -> None:
|
|
144
|
+
cli_version = get_supported_api_version()
|
|
145
|
+
api_version = get_api_version()
|
|
146
|
+
api_vers_str = ".".join(map(str, api_version))
|
|
147
|
+
|
|
148
|
+
if cli_version[0] != api_version[0]:
|
|
149
|
+
raise InvalidCLIVersion(
|
|
150
|
+
f"CLI version {__version__} is not compatible with API version {api_vers_str}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if cli_version[1] != api_version[1]:
|
|
154
|
+
console = Console()
|
|
155
|
+
console.print(
|
|
156
|
+
f"CLI version {__version__} might not be compatible with API version {api_vers_str}",
|
|
157
|
+
style="red",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.callback()
|
|
162
|
+
def cli(
|
|
163
|
+
verbose: bool = typer.Option(True, help="Enable verbose mode."),
|
|
164
|
+
debug: bool = typer.Option(False, help="Enable debug mode."),
|
|
165
|
+
version: Optional[bool] = typer.Option(
|
|
166
|
+
None, "--version", "-v", callback=_version_cb
|
|
167
|
+
),
|
|
168
|
+
):
|
|
169
|
+
_ = version # suppress unused variable warning
|
|
170
|
+
shared_state = get_shared_state()
|
|
171
|
+
shared_state.verbose = verbose
|
|
172
|
+
shared_state.debug = debug
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
check_version_compatiblity()
|
|
176
|
+
except InvalidCLIVersion:
|
|
177
|
+
raise
|
|
178
|
+
except Exception:
|
|
179
|
+
console = Console()
|
|
180
|
+
console.print("failed to check version compatibility", style="yellow")
|