kleinkram 0.38.1.dev20241212075157__py3-none-any.whl → 0.38.1.dev20250207122632__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 +33 -2
- kleinkram/api/client.py +21 -16
- kleinkram/api/deser.py +165 -0
- kleinkram/api/file_transfer.py +13 -24
- kleinkram/api/pagination.py +56 -0
- kleinkram/api/query.py +111 -0
- kleinkram/api/routes.py +266 -97
- kleinkram/auth.py +21 -20
- kleinkram/cli/__init__.py +0 -0
- kleinkram/{commands/download.py → cli/_download.py} +18 -44
- kleinkram/cli/_endpoint.py +58 -0
- kleinkram/{commands/list.py → cli/_list.py} +25 -38
- kleinkram/cli/_mission.py +153 -0
- kleinkram/cli/_project.py +99 -0
- kleinkram/cli/_upload.py +84 -0
- kleinkram/cli/_verify.py +56 -0
- kleinkram/{app.py → cli/app.py} +57 -25
- kleinkram/cli/error_handling.py +67 -0
- kleinkram/config.py +141 -107
- kleinkram/core.py +251 -3
- kleinkram/errors.py +13 -45
- kleinkram/main.py +1 -1
- kleinkram/models.py +48 -149
- kleinkram/printing.py +325 -0
- kleinkram/py.typed +0 -0
- kleinkram/types.py +9 -0
- kleinkram/utils.py +88 -29
- kleinkram/wrappers.py +401 -0
- {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/METADATA +3 -3
- kleinkram-0.38.1.dev20250207122632.dist-info/RECORD +49 -0
- {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/WHEEL +1 -1
- {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/top_level.txt +1 -0
- testing/__init__.py +0 -0
- testing/backend_fixtures.py +67 -0
- tests/conftest.py +7 -0
- tests/test_config.py +115 -0
- tests/test_core.py +165 -0
- tests/test_end_to_end.py +29 -39
- tests/test_error_handling.py +44 -0
- tests/test_fixtures.py +34 -0
- tests/test_printing.py +62 -0
- tests/test_query.py +138 -0
- tests/test_utils.py +46 -24
- tests/test_wrappers.py +71 -0
- kleinkram/api/parsing.py +0 -86
- kleinkram/commands/__init__.py +0 -1
- kleinkram/commands/endpoint.py +0 -62
- kleinkram/commands/mission.py +0 -69
- kleinkram/commands/project.py +0 -24
- kleinkram/commands/upload.py +0 -164
- kleinkram/commands/verify.py +0 -142
- kleinkram/consts.py +0 -8
- kleinkram/enums.py +0 -10
- kleinkram/resources.py +0 -158
- kleinkram-0.38.1.dev20241212075157.dist-info/LICENSE +0 -674
- kleinkram-0.38.1.dev20241212075157.dist-info/RECORD +0 -37
- tests/test_resources.py +0 -137
- {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/entry_points.txt +0 -0
kleinkram/api/routes.py
CHANGED
|
@@ -1,121 +1,230 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
3
6
|
from typing import Dict
|
|
7
|
+
from typing import Generator
|
|
4
8
|
from typing import List
|
|
5
9
|
from typing import Optional
|
|
10
|
+
from typing import Sequence
|
|
6
11
|
from typing import Tuple
|
|
7
12
|
from uuid import UUID
|
|
8
13
|
|
|
9
14
|
import httpx
|
|
15
|
+
|
|
16
|
+
import kleinkram.errors
|
|
10
17
|
from kleinkram.api.client import AuthenticatedClient
|
|
11
|
-
from kleinkram.api.
|
|
12
|
-
from kleinkram.api.
|
|
13
|
-
from kleinkram.api.
|
|
14
|
-
from kleinkram.
|
|
18
|
+
from kleinkram.api.deser import FileObject
|
|
19
|
+
from kleinkram.api.deser import MissionObject
|
|
20
|
+
from kleinkram.api.deser import ProjectObject
|
|
21
|
+
from kleinkram.api.deser import _parse_file
|
|
22
|
+
from kleinkram.api.deser import _parse_mission
|
|
23
|
+
from kleinkram.api.deser import _parse_project
|
|
24
|
+
from kleinkram.api.pagination import paginated_request
|
|
25
|
+
from kleinkram.api.query import FileQuery
|
|
26
|
+
from kleinkram.api.query import MissionQuery
|
|
27
|
+
from kleinkram.api.query import ProjectQuery
|
|
28
|
+
from kleinkram.api.query import file_query_is_unique
|
|
29
|
+
from kleinkram.api.query import mission_query_is_unique
|
|
30
|
+
from kleinkram.api.query import project_query_is_unique
|
|
31
|
+
from kleinkram.config import get_config
|
|
15
32
|
from kleinkram.errors import AccessDenied
|
|
33
|
+
from kleinkram.errors import InvalidFileQuery
|
|
34
|
+
from kleinkram.errors import InvalidMissionMetadata
|
|
35
|
+
from kleinkram.errors import InvalidMissionQuery
|
|
36
|
+
from kleinkram.errors import InvalidProjectQuery
|
|
16
37
|
from kleinkram.errors import MissionExists
|
|
17
38
|
from kleinkram.errors import MissionNotFound
|
|
18
|
-
from kleinkram.
|
|
39
|
+
from kleinkram.errors import ProjectExists
|
|
40
|
+
from kleinkram.errors import ProjectNotFound
|
|
19
41
|
from kleinkram.models import File
|
|
20
42
|
from kleinkram.models import Mission
|
|
21
43
|
from kleinkram.models import Project
|
|
22
|
-
from kleinkram.models import TagType
|
|
23
44
|
from kleinkram.utils import is_valid_uuid4
|
|
24
45
|
|
|
25
46
|
__all__ = [
|
|
26
|
-
"_get_projects",
|
|
27
|
-
"_get_missions_by_project",
|
|
28
|
-
"_get_files_by_mission",
|
|
29
|
-
"_create_mission",
|
|
30
|
-
"_update_mission_metadata",
|
|
31
47
|
"_get_api_version",
|
|
32
48
|
"_claim_admin",
|
|
49
|
+
"_create_mission",
|
|
50
|
+
"_create_project",
|
|
51
|
+
"_update_mission",
|
|
52
|
+
"_update_project",
|
|
53
|
+
"_delete_files",
|
|
54
|
+
"_delete_mission",
|
|
55
|
+
"_delete_project",
|
|
56
|
+
"get_files",
|
|
57
|
+
"get_missions",
|
|
58
|
+
"get_projects",
|
|
59
|
+
"get_project",
|
|
60
|
+
"get_mission",
|
|
61
|
+
"get_file",
|
|
33
62
|
]
|
|
34
63
|
|
|
35
64
|
|
|
36
|
-
MAX_PAGINATION = 10_000
|
|
37
|
-
|
|
38
65
|
CLAIM_ADMIN = "/user/claimAdmin"
|
|
39
|
-
PROJECT_ALL = "/project/filtered"
|
|
40
|
-
MISSIONS_BY_PROJECT = "/mission/filtered"
|
|
41
|
-
MISSION_BY_NAME = "/mission/byName"
|
|
42
|
-
MISSION_CREATE = "/mission/create"
|
|
43
|
-
MISSION_UPDATE_METADATA = "/mission/tags"
|
|
44
|
-
FILE_OF_MISSION = "/file/ofMission"
|
|
45
|
-
TAG_TYPE_BY_NAME = "/tag/filtered"
|
|
46
66
|
GET_STATUS = "/user/me"
|
|
47
67
|
|
|
68
|
+
UPDATE_PROJECT = "/project"
|
|
69
|
+
UPDATE_MISSION = "/mission/tags" # TODO: just metadata for now
|
|
70
|
+
CREATE_MISSION = "/mission/create"
|
|
71
|
+
CREATE_PROJECT = "/project/create"
|
|
48
72
|
|
|
49
|
-
def _get_projects(client: AuthenticatedClient) -> list[Project]:
|
|
50
|
-
resp = client.get(PROJECT_ALL)
|
|
51
|
-
|
|
52
|
-
if resp.status_code in (403, 404):
|
|
53
|
-
return []
|
|
54
|
-
|
|
55
|
-
resp.raise_for_status()
|
|
56
|
-
|
|
57
|
-
ret = []
|
|
58
|
-
for pr in resp.json()[0]:
|
|
59
|
-
ret.append(_parse_project(pr))
|
|
60
|
-
|
|
61
|
-
return ret
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _get_missions_by_project(
|
|
65
|
-
client: AuthenticatedClient, project: Project
|
|
66
|
-
) -> List[Mission]:
|
|
67
|
-
params = {"uuid": str(project.id), "take": MAX_PAGINATION}
|
|
68
73
|
|
|
69
|
-
|
|
74
|
+
FILE_ENDPOINT = "/file/many"
|
|
75
|
+
MISSION_ENDPOINT = "/mission/many"
|
|
76
|
+
PROJECT_ENDPOINT = "/project/many"
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
return []
|
|
73
|
-
|
|
74
|
-
resp.raise_for_status()
|
|
75
|
-
|
|
76
|
-
data = resp.json()
|
|
77
|
-
missions = []
|
|
78
|
+
TAG_TYPE_BY_NAME = "/tag/filtered"
|
|
78
79
|
|
|
79
|
-
for mission in data[0]:
|
|
80
|
-
missions.append(_parse_mission(mission, project))
|
|
81
80
|
|
|
82
|
-
|
|
81
|
+
class Params(str, Enum):
|
|
82
|
+
FILE_PATTERNS = "filePatterns"
|
|
83
|
+
FILE_IDS = "fileUuids"
|
|
84
|
+
MISSION_PATTERNS = "missionPatterns"
|
|
85
|
+
MISSION_IDS = "missionUuids"
|
|
86
|
+
PROJECT_PATTERNS = "projectPatterns"
|
|
87
|
+
PROJECT_IDS = "projectUuids"
|
|
83
88
|
|
|
84
89
|
|
|
85
|
-
def
|
|
86
|
-
|
|
90
|
+
def _handle_list_params(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
91
|
+
"""
|
|
92
|
+
json dumps lists
|
|
93
|
+
"""
|
|
94
|
+
new_params = {}
|
|
95
|
+
for k, v in params.items():
|
|
96
|
+
if not isinstance(v, list):
|
|
97
|
+
new_params[k] = v
|
|
98
|
+
else:
|
|
99
|
+
new_params[k] = json.dumps(v)
|
|
100
|
+
return new_params
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _project_query_to_params(
|
|
104
|
+
project_query: ProjectQuery,
|
|
105
|
+
) -> Dict[str, List[str]]:
|
|
106
|
+
params = {}
|
|
107
|
+
if project_query.patterns:
|
|
108
|
+
params[Params.PROJECT_PATTERNS.value] = project_query.patterns
|
|
109
|
+
if project_query.ids:
|
|
110
|
+
params[Params.PROJECT_IDS.value] = list(map(str, project_query.ids))
|
|
111
|
+
return params
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _mission_query_to_params(mission_query: MissionQuery) -> Dict[str, List[str]]:
|
|
115
|
+
params = _project_query_to_params(mission_query.project_query)
|
|
116
|
+
if mission_query.patterns:
|
|
117
|
+
params[Params.MISSION_PATTERNS.value] = mission_query.patterns
|
|
118
|
+
if mission_query.ids:
|
|
119
|
+
params[Params.MISSION_IDS.value] = list(map(str, mission_query.ids))
|
|
120
|
+
return params
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _file_query_to_params(file_query: FileQuery) -> Dict[str, List[str]]:
|
|
124
|
+
params = _mission_query_to_params(file_query.mission_query)
|
|
125
|
+
if file_query.patterns:
|
|
126
|
+
params[Params.FILE_PATTERNS.value] = list(file_query.patterns)
|
|
127
|
+
if file_query.ids:
|
|
128
|
+
params[Params.FILE_IDS.value] = list(map(str, file_query.ids))
|
|
129
|
+
return params
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_files(
|
|
133
|
+
client: AuthenticatedClient,
|
|
134
|
+
file_query: FileQuery,
|
|
135
|
+
max_entries: Optional[int] = None,
|
|
136
|
+
) -> Generator[File, None, None]:
|
|
137
|
+
params = _file_query_to_params(file_query)
|
|
138
|
+
response_stream = paginated_request(
|
|
139
|
+
client, FILE_ENDPOINT, params=params, max_entries=max_entries
|
|
140
|
+
)
|
|
141
|
+
yield from map(lambda f: _parse_file(FileObject(f)), response_stream)
|
|
87
142
|
|
|
88
|
-
resp = client.get(FILE_OF_MISSION, params=params)
|
|
89
143
|
|
|
90
|
-
|
|
91
|
-
|
|
144
|
+
def get_missions(
|
|
145
|
+
client: AuthenticatedClient,
|
|
146
|
+
mission_query: MissionQuery,
|
|
147
|
+
max_entries: Optional[int] = None,
|
|
148
|
+
) -> Generator[Mission, None, None]:
|
|
149
|
+
params = _mission_query_to_params(mission_query)
|
|
150
|
+
response_stream = paginated_request(
|
|
151
|
+
client, MISSION_ENDPOINT, params=params, max_entries=max_entries
|
|
152
|
+
)
|
|
153
|
+
yield from map(lambda m: _parse_mission(MissionObject(m)), response_stream)
|
|
92
154
|
|
|
93
|
-
resp.raise_for_status()
|
|
94
155
|
|
|
95
|
-
|
|
156
|
+
def get_projects(
|
|
157
|
+
client: AuthenticatedClient,
|
|
158
|
+
project_query: ProjectQuery,
|
|
159
|
+
max_entries: Optional[int] = None,
|
|
160
|
+
) -> Generator[Project, None, None]:
|
|
161
|
+
params = _project_query_to_params(project_query)
|
|
162
|
+
response_stream = paginated_request(
|
|
163
|
+
client, PROJECT_ENDPOINT, params=params, max_entries=max_entries
|
|
164
|
+
)
|
|
165
|
+
yield from map(lambda p: _parse_project(ProjectObject(p)), response_stream)
|
|
96
166
|
|
|
97
|
-
files = []
|
|
98
|
-
for file in data[0]:
|
|
99
|
-
files.append(_parse_file(file, mission))
|
|
100
167
|
|
|
101
|
-
|
|
168
|
+
def get_project(client: AuthenticatedClient, query: ProjectQuery) -> Project:
|
|
169
|
+
"""\
|
|
170
|
+
get a unique project by specifying a project spec
|
|
171
|
+
"""
|
|
172
|
+
if not project_query_is_unique(query):
|
|
173
|
+
raise InvalidProjectQuery(
|
|
174
|
+
f"Project query does not uniquely determine project: {query}"
|
|
175
|
+
)
|
|
176
|
+
try:
|
|
177
|
+
return next(get_projects(client, query))
|
|
178
|
+
except StopIteration:
|
|
179
|
+
raise ProjectNotFound(f"Project not found: {query}")
|
|
102
180
|
|
|
103
181
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
182
|
+
def get_mission(client: AuthenticatedClient, query: MissionQuery) -> Mission:
|
|
183
|
+
"""\
|
|
184
|
+
get a unique mission by specifying a mission query
|
|
185
|
+
"""
|
|
186
|
+
if not mission_query_is_unique(query):
|
|
187
|
+
raise InvalidMissionQuery(
|
|
188
|
+
f"Mission query does not uniquely determine mission: {query}"
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
return next(get_missions(client, query))
|
|
192
|
+
except StopIteration:
|
|
193
|
+
raise MissionNotFound(f"Mission not found: {query}")
|
|
109
194
|
|
|
110
|
-
if resp.status_code in (403, 404):
|
|
111
|
-
return None
|
|
112
195
|
|
|
113
|
-
|
|
114
|
-
|
|
196
|
+
def get_file(client: AuthenticatedClient, query: FileQuery) -> File:
|
|
197
|
+
"""\
|
|
198
|
+
get a unique file by specifying a file query
|
|
199
|
+
"""
|
|
200
|
+
if not file_query_is_unique(query):
|
|
201
|
+
raise InvalidFileQuery(f"File query does not uniquely determine file: {query}")
|
|
202
|
+
try:
|
|
203
|
+
return next(get_files(client, query))
|
|
204
|
+
except StopIteration:
|
|
205
|
+
raise kleinkram.errors.FileNotFound(f"File not found: {query}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _mission_name_is_available(
|
|
209
|
+
client: AuthenticatedClient, mission_name: str, project_id: UUID
|
|
210
|
+
) -> bool:
|
|
211
|
+
mission_query = MissionQuery(
|
|
212
|
+
patterns=[mission_name], project_query=ProjectQuery(ids=[project_id])
|
|
213
|
+
)
|
|
214
|
+
try:
|
|
215
|
+
_ = get_mission(client, mission_query)
|
|
216
|
+
except MissionNotFound:
|
|
217
|
+
return True
|
|
218
|
+
return False
|
|
115
219
|
|
|
116
|
-
data = resp.json()
|
|
117
220
|
|
|
118
|
-
|
|
221
|
+
def _project_name_is_available(client: AuthenticatedClient, project_name: str) -> bool:
|
|
222
|
+
project_query = ProjectQuery(patterns=[project_name])
|
|
223
|
+
try:
|
|
224
|
+
_ = get_project(client, project_query)
|
|
225
|
+
except ProjectNotFound:
|
|
226
|
+
return True
|
|
227
|
+
return False
|
|
119
228
|
|
|
120
229
|
|
|
121
230
|
def _create_mission(
|
|
@@ -135,8 +244,11 @@ def _create_mission(
|
|
|
135
244
|
if metadata is None:
|
|
136
245
|
metadata = {}
|
|
137
246
|
|
|
138
|
-
if
|
|
139
|
-
raise MissionExists(
|
|
247
|
+
if not _mission_name_is_available(client, mission_name, project_id):
|
|
248
|
+
raise MissionExists(
|
|
249
|
+
f"Mission with name: `{mission_name}` already exists"
|
|
250
|
+
f" in project: {project_id}"
|
|
251
|
+
)
|
|
140
252
|
|
|
141
253
|
if is_valid_uuid4(mission_name):
|
|
142
254
|
raise ValueError(
|
|
@@ -154,15 +266,29 @@ def _create_mission(
|
|
|
154
266
|
"ignoreTags": ignore_missing_tags,
|
|
155
267
|
}
|
|
156
268
|
|
|
157
|
-
resp = client.post(
|
|
269
|
+
resp = client.post(CREATE_MISSION, json=payload)
|
|
158
270
|
resp.raise_for_status()
|
|
159
271
|
|
|
160
272
|
return UUID(resp.json()["uuid"], version=4)
|
|
161
273
|
|
|
162
274
|
|
|
163
|
-
def
|
|
275
|
+
def _create_project(
|
|
276
|
+
client: AuthenticatedClient, project_name: str, description: str
|
|
277
|
+
) -> UUID:
|
|
278
|
+
if not _project_name_is_available(client, project_name):
|
|
279
|
+
raise ProjectExists(f"Project with name: `{project_name}` already exists")
|
|
280
|
+
|
|
281
|
+
# TODO: check name and description are valid
|
|
282
|
+
payload = {"name": project_name, "description": description}
|
|
283
|
+
resp = client.post(CREATE_PROJECT, json=payload)
|
|
284
|
+
resp.raise_for_status()
|
|
285
|
+
|
|
286
|
+
return UUID(resp.json()["uuid"], version=4)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _get_metadata_type_id_by_name(
|
|
164
290
|
client: AuthenticatedClient, tag_name: str
|
|
165
|
-
) -> Optional[
|
|
291
|
+
) -> Optional[UUID]:
|
|
166
292
|
resp = client.get(TAG_TYPE_BY_NAME, params={"name": tag_name, "take": 1})
|
|
167
293
|
|
|
168
294
|
if resp.status_code in (403, 404):
|
|
@@ -171,33 +297,24 @@ def _get_tag_type_by_name(
|
|
|
171
297
|
resp.raise_for_status()
|
|
172
298
|
|
|
173
299
|
data = resp.json()[0]
|
|
174
|
-
|
|
175
|
-
name=data["name"],
|
|
176
|
-
id=UUID(data["uuid"], version=4),
|
|
177
|
-
data_type=DataType(data["datatype"]),
|
|
178
|
-
description=data["description"],
|
|
179
|
-
)
|
|
180
|
-
return tag_type
|
|
300
|
+
return UUID(data["uuid"], version=4)
|
|
181
301
|
|
|
182
302
|
|
|
183
303
|
def _get_tags_map(
|
|
184
304
|
client: AuthenticatedClient, metadata: Dict[str, str]
|
|
185
305
|
) -> Dict[UUID, str]:
|
|
186
306
|
# TODO: this needs a better endpoint
|
|
307
|
+
# why are we using metadata type ids as keys???
|
|
187
308
|
ret = {}
|
|
188
309
|
for key, val in metadata.items():
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
continue
|
|
194
|
-
|
|
195
|
-
ret[tag_type.id] = val
|
|
196
|
-
|
|
310
|
+
metadata_type_id = _get_metadata_type_id_by_name(client, key)
|
|
311
|
+
if metadata_type_id is None:
|
|
312
|
+
raise InvalidMissionMetadata(f"metadata field: {key} does not exist")
|
|
313
|
+
ret[metadata_type_id] = val
|
|
197
314
|
return ret
|
|
198
315
|
|
|
199
316
|
|
|
200
|
-
def
|
|
317
|
+
def _update_mission(
|
|
201
318
|
client: AuthenticatedClient, mission_id: UUID, *, metadata: Dict[str, str]
|
|
202
319
|
) -> None:
|
|
203
320
|
tags_dct = _get_tags_map(client, metadata)
|
|
@@ -205,22 +322,40 @@ def _update_mission_metadata(
|
|
|
205
322
|
"missionUUID": str(mission_id),
|
|
206
323
|
"tags": {str(k): v for k, v in tags_dct.items()},
|
|
207
324
|
}
|
|
208
|
-
resp = client.post(
|
|
325
|
+
resp = client.post(UPDATE_MISSION, json=payload)
|
|
209
326
|
|
|
210
327
|
if resp.status_code == 404:
|
|
211
328
|
raise MissionNotFound
|
|
212
|
-
|
|
213
329
|
if resp.status_code == 403:
|
|
214
330
|
raise AccessDenied(f"cannot update mission: {mission_id}")
|
|
215
331
|
|
|
216
332
|
resp.raise_for_status()
|
|
217
333
|
|
|
218
334
|
|
|
335
|
+
def _update_project(
|
|
336
|
+
client: AuthenticatedClient,
|
|
337
|
+
project_id: UUID,
|
|
338
|
+
*,
|
|
339
|
+
description: Optional[str] = None,
|
|
340
|
+
new_name: Optional[str] = None,
|
|
341
|
+
) -> None:
|
|
342
|
+
if description is None and new_name is None:
|
|
343
|
+
raise ValueError("either description or new_name must be provided")
|
|
344
|
+
|
|
345
|
+
body = {}
|
|
346
|
+
if description is not None:
|
|
347
|
+
body["description"] = description
|
|
348
|
+
if new_name is not None:
|
|
349
|
+
body["name"] = new_name
|
|
350
|
+
resp = client.put(f"{UPDATE_PROJECT}/{project_id}", json=body)
|
|
351
|
+
resp.raise_for_status()
|
|
352
|
+
|
|
353
|
+
|
|
219
354
|
def _get_api_version() -> Tuple[int, int, int]:
|
|
220
|
-
config =
|
|
355
|
+
config = get_config()
|
|
221
356
|
client = httpx.Client()
|
|
222
357
|
|
|
223
|
-
resp = client.get(f"{config.endpoint}{GET_STATUS}")
|
|
358
|
+
resp = client.get(f"{config.endpoint.api}{GET_STATUS}")
|
|
224
359
|
vers = resp.headers["kleinkram-version"].split(".")
|
|
225
360
|
|
|
226
361
|
return tuple(map(int, vers)) # type: ignore
|
|
@@ -233,3 +368,37 @@ def _claim_admin(client: AuthenticatedClient) -> None:
|
|
|
233
368
|
response = client.post(CLAIM_ADMIN)
|
|
234
369
|
response.raise_for_status()
|
|
235
370
|
return
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
FILE_DELETE_MANY = "/file/deleteMultiple"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _delete_files(
|
|
377
|
+
client: AuthenticatedClient, file_ids: Sequence[UUID], mission_id: UUID
|
|
378
|
+
) -> None:
|
|
379
|
+
payload = {
|
|
380
|
+
"uuids": [str(file_id) for file_id in file_ids],
|
|
381
|
+
"missionUUID": str(mission_id),
|
|
382
|
+
}
|
|
383
|
+
resp = client.post(FILE_DELETE_MANY, json=payload)
|
|
384
|
+
resp.raise_for_status()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
MISSION_DELETE_ONE = "/mission/{}"
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _delete_mission(client: AuthenticatedClient, mission_id: UUID) -> None:
|
|
391
|
+
resp = client.delete(MISSION_DELETE_ONE.format(mission_id))
|
|
392
|
+
|
|
393
|
+
# 409 is returned if the mission has files
|
|
394
|
+
# 403 is returned if the mission does not exist / user cant delete
|
|
395
|
+
|
|
396
|
+
resp.raise_for_status()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
PROJECT_DELETE_ONE = "/project/{}"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _delete_project(client: AuthenticatedClient, project_id: UUID) -> None:
|
|
403
|
+
resp = client.delete(PROJECT_DELETE_ONE.format(project_id))
|
|
404
|
+
resp.raise_for_status()
|
kleinkram/auth.py
CHANGED
|
@@ -7,9 +7,10 @@ from http.server import BaseHTTPRequestHandler
|
|
|
7
7
|
from http.server import HTTPServer
|
|
8
8
|
from typing import Optional
|
|
9
9
|
|
|
10
|
-
from kleinkram.config import Config
|
|
11
10
|
from kleinkram.config import CONFIG_PATH
|
|
12
11
|
from kleinkram.config import Credentials
|
|
12
|
+
from kleinkram.config import get_config
|
|
13
|
+
from kleinkram.config import save_config
|
|
13
14
|
|
|
14
15
|
CLI_CALLBACK_ENDPOINT = "/cli/callback"
|
|
15
16
|
OAUTH_SLUG = "/auth/google?state=cli"
|
|
@@ -24,16 +25,18 @@ def _has_browser() -> bool:
|
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def _headless_auth(*, url: str) -> None:
|
|
27
|
-
config = Config()
|
|
28
28
|
|
|
29
|
-
print(f"
|
|
30
|
-
print("
|
|
31
|
-
auth_token = getpass("
|
|
32
|
-
refresh_token = getpass("
|
|
29
|
+
print(f"please open the following URL manually to authenticate: {url}")
|
|
30
|
+
print("enter the authentication token provided after logging in:")
|
|
31
|
+
auth_token = getpass("authentication token: ")
|
|
32
|
+
refresh_token = getpass("refresh token: ")
|
|
33
33
|
|
|
34
34
|
if auth_token and refresh_token:
|
|
35
|
-
|
|
36
|
-
config.
|
|
35
|
+
config = get_config()
|
|
36
|
+
config.credentials = Credentials(
|
|
37
|
+
auth_token=auth_token, refresh_token=refresh_token
|
|
38
|
+
)
|
|
39
|
+
save_config(config)
|
|
37
40
|
print(f"Authentication complete. Tokens saved to {CONFIG_PATH}.")
|
|
38
41
|
else:
|
|
39
42
|
raise ValueError("Please provided tokens.")
|
|
@@ -50,12 +53,12 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
|
50
53
|
auth_token=params.get("authtoken")[0], # type: ignore
|
|
51
54
|
refresh_token=params.get("refreshtoken")[0], # type: ignore
|
|
52
55
|
)
|
|
56
|
+
config = get_config()
|
|
57
|
+
config.credentials = creds
|
|
58
|
+
save_config(config)
|
|
53
59
|
except Exception:
|
|
54
60
|
raise RuntimeError("Failed to fetch authentication tokens.")
|
|
55
61
|
|
|
56
|
-
config = Config()
|
|
57
|
-
config.save_credentials(creds)
|
|
58
|
-
|
|
59
62
|
self.send_response(200)
|
|
60
63
|
self.send_header("Content-type", "text/html")
|
|
61
64
|
self.end_headers()
|
|
@@ -78,17 +81,15 @@ def _browser_auth(*, url: str) -> None:
|
|
|
78
81
|
|
|
79
82
|
|
|
80
83
|
def login_flow(*, key: Optional[str] = None, headless: bool = False) -> None:
|
|
81
|
-
config =
|
|
82
|
-
|
|
84
|
+
config = get_config()
|
|
83
85
|
# use cli key login
|
|
84
86
|
if key is not None:
|
|
85
|
-
|
|
86
|
-
config
|
|
87
|
-
|
|
88
|
-
url = f"{config.endpoint}{OAUTH_SLUG}"
|
|
87
|
+
config.credentials = Credentials(cli_key=key)
|
|
88
|
+
save_config(config)
|
|
89
|
+
return
|
|
89
90
|
|
|
91
|
+
oauth_url = f"{config.endpoint.api}{OAUTH_SLUG}"
|
|
90
92
|
if not headless and _has_browser():
|
|
91
|
-
_browser_auth(url=
|
|
93
|
+
_browser_auth(url=oauth_url)
|
|
92
94
|
else:
|
|
93
|
-
|
|
94
|
-
_headless_auth(url=headless_url)
|
|
95
|
+
_headless_auth(url=f"{oauth_url}-no-redirect")
|
|
File without changes
|
|
@@ -6,17 +6,14 @@ from typing import List
|
|
|
6
6
|
from typing import Optional
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
|
+
|
|
10
|
+
import kleinkram.core
|
|
9
11
|
from kleinkram.api.client import AuthenticatedClient
|
|
10
|
-
from kleinkram.api.
|
|
12
|
+
from kleinkram.api.query import FileQuery
|
|
13
|
+
from kleinkram.api.query import MissionQuery
|
|
14
|
+
from kleinkram.api.query import ProjectQuery
|
|
11
15
|
from kleinkram.config import get_shared_state
|
|
12
|
-
from kleinkram.models import files_to_table
|
|
13
|
-
from kleinkram.resources import FileSpec
|
|
14
|
-
from kleinkram.resources import get_files_by_spec
|
|
15
|
-
from kleinkram.resources import MissionSpec
|
|
16
|
-
from kleinkram.resources import ProjectSpec
|
|
17
16
|
from kleinkram.utils import split_args
|
|
18
|
-
from rich.console import Console
|
|
19
|
-
|
|
20
17
|
|
|
21
18
|
logger = logging.getLogger(__name__)
|
|
22
19
|
|
|
@@ -55,49 +52,26 @@ def download(
|
|
|
55
52
|
typer.confirm(f"Destination {dest_dir} does not exist. Create it?", abort=True)
|
|
56
53
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
57
54
|
|
|
58
|
-
# get file
|
|
55
|
+
# get file query
|
|
59
56
|
file_ids, file_patterns = split_args(files or [])
|
|
60
57
|
mission_ids, mission_patterns = split_args(missions or [])
|
|
61
58
|
project_ids, project_patterns = split_args(projects or [])
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
project_query = ProjectQuery(patterns=project_patterns, ids=project_ids)
|
|
61
|
+
mission_query = MissionQuery(
|
|
65
62
|
patterns=mission_patterns,
|
|
66
63
|
ids=mission_ids,
|
|
67
|
-
|
|
64
|
+
project_query=project_query,
|
|
68
65
|
)
|
|
69
|
-
|
|
70
|
-
patterns=file_patterns, ids=file_ids,
|
|
66
|
+
file_query = FileQuery(
|
|
67
|
+
patterns=file_patterns, ids=file_ids, mission_query=mission_query
|
|
71
68
|
)
|
|
72
69
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
# get paths to files map
|
|
81
|
-
if (
|
|
82
|
-
len(set([(file.project_id, file.mission_id) for file in parsed_files])) > 1
|
|
83
|
-
and not nested
|
|
84
|
-
):
|
|
85
|
-
raise ValueError(
|
|
86
|
-
"files from multiple missions were selected, consider using `--nested`"
|
|
87
|
-
)
|
|
88
|
-
elif not nested:
|
|
89
|
-
# flat structure
|
|
90
|
-
paths_to_files = {dest_dir / file.name: file for file in parsed_files}
|
|
91
|
-
else:
|
|
92
|
-
# allow for nested directories
|
|
93
|
-
paths_to_files = {}
|
|
94
|
-
for file in parsed_files:
|
|
95
|
-
paths_to_files[
|
|
96
|
-
dest_dir / file.project_name / file.mission_name / file.name
|
|
97
|
-
] = file
|
|
98
|
-
|
|
99
|
-
# download files
|
|
100
|
-
logger.info(f"downloading {paths_to_files} files to {dest_dir}")
|
|
101
|
-
download_files(
|
|
102
|
-
client, paths_to_files, verbose=get_shared_state().verbose, overwrite=overwrite
|
|
70
|
+
kleinkram.core.download(
|
|
71
|
+
client=AuthenticatedClient(),
|
|
72
|
+
query=file_query,
|
|
73
|
+
base_dir=dest_dir,
|
|
74
|
+
nested=nested,
|
|
75
|
+
overwrite=overwrite,
|
|
76
|
+
verbose=get_shared_state().verbose,
|
|
103
77
|
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
at the moment the endpoint command lets you specify the api and s3 endpoints
|
|
3
|
+
eventually it will be sufficient to just specify the api endpoint and the s3 endpoint will
|
|
4
|
+
be provided by the api
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from kleinkram.config import Endpoint
|
|
15
|
+
from kleinkram.config import add_endpoint
|
|
16
|
+
from kleinkram.config import endpoint_table
|
|
17
|
+
from kleinkram.config import get_config
|
|
18
|
+
from kleinkram.config import select_endpoint
|
|
19
|
+
|
|
20
|
+
HELP = """\
|
|
21
|
+
Get or set the current endpoint.
|
|
22
|
+
|
|
23
|
+
The endpoint is used to determine the API server to connect to\
|
|
24
|
+
(default is the API server of https://datasets.leggedrobotics.com).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
endpoint_typer = typer.Typer(
|
|
28
|
+
name="endpoint",
|
|
29
|
+
help=HELP,
|
|
30
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
31
|
+
invoke_without_command=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@endpoint_typer.callback()
|
|
36
|
+
def endpoint(
|
|
37
|
+
name: Optional[str] = typer.Argument(None, help="Name of the endpoint to use"),
|
|
38
|
+
api: Optional[str] = typer.Argument(None, help="API endpoint to use"),
|
|
39
|
+
s3: Optional[str] = typer.Argument(None, help="S3 endpoint to use"),
|
|
40
|
+
) -> None:
|
|
41
|
+
config = get_config()
|
|
42
|
+
console = Console()
|
|
43
|
+
|
|
44
|
+
if not any([name, api, s3]):
|
|
45
|
+
console.print(endpoint_table(config))
|
|
46
|
+
elif name is not None and not any([api, s3]):
|
|
47
|
+
try:
|
|
48
|
+
select_endpoint(config, name)
|
|
49
|
+
except ValueError:
|
|
50
|
+
console.print(f"Endpoint {name} not found.\n", style="red")
|
|
51
|
+
console.print(endpoint_table(config))
|
|
52
|
+
elif not (name and api and s3):
|
|
53
|
+
raise typer.BadParameter(
|
|
54
|
+
"to add a new endpoint you must specify the api and s3 endpoints"
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
new_endpoint = Endpoint(name, api, s3)
|
|
58
|
+
add_endpoint(config, new_endpoint)
|