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.

Files changed (58) hide show
  1. kleinkram/__init__.py +33 -2
  2. kleinkram/api/client.py +21 -16
  3. kleinkram/api/deser.py +165 -0
  4. kleinkram/api/file_transfer.py +13 -24
  5. kleinkram/api/pagination.py +56 -0
  6. kleinkram/api/query.py +111 -0
  7. kleinkram/api/routes.py +266 -97
  8. kleinkram/auth.py +21 -20
  9. kleinkram/cli/__init__.py +0 -0
  10. kleinkram/{commands/download.py → cli/_download.py} +18 -44
  11. kleinkram/cli/_endpoint.py +58 -0
  12. kleinkram/{commands/list.py → cli/_list.py} +25 -38
  13. kleinkram/cli/_mission.py +153 -0
  14. kleinkram/cli/_project.py +99 -0
  15. kleinkram/cli/_upload.py +84 -0
  16. kleinkram/cli/_verify.py +56 -0
  17. kleinkram/{app.py → cli/app.py} +57 -25
  18. kleinkram/cli/error_handling.py +67 -0
  19. kleinkram/config.py +141 -107
  20. kleinkram/core.py +251 -3
  21. kleinkram/errors.py +13 -45
  22. kleinkram/main.py +1 -1
  23. kleinkram/models.py +48 -149
  24. kleinkram/printing.py +325 -0
  25. kleinkram/py.typed +0 -0
  26. kleinkram/types.py +9 -0
  27. kleinkram/utils.py +88 -29
  28. kleinkram/wrappers.py +401 -0
  29. {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/METADATA +3 -3
  30. kleinkram-0.38.1.dev20250207122632.dist-info/RECORD +49 -0
  31. {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/WHEEL +1 -1
  32. {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250207122632.dist-info}/top_level.txt +1 -0
  33. testing/__init__.py +0 -0
  34. testing/backend_fixtures.py +67 -0
  35. tests/conftest.py +7 -0
  36. tests/test_config.py +115 -0
  37. tests/test_core.py +165 -0
  38. tests/test_end_to_end.py +29 -39
  39. tests/test_error_handling.py +44 -0
  40. tests/test_fixtures.py +34 -0
  41. tests/test_printing.py +62 -0
  42. tests/test_query.py +138 -0
  43. tests/test_utils.py +46 -24
  44. tests/test_wrappers.py +71 -0
  45. kleinkram/api/parsing.py +0 -86
  46. kleinkram/commands/__init__.py +0 -1
  47. kleinkram/commands/endpoint.py +0 -62
  48. kleinkram/commands/mission.py +0 -69
  49. kleinkram/commands/project.py +0 -24
  50. kleinkram/commands/upload.py +0 -164
  51. kleinkram/commands/verify.py +0 -142
  52. kleinkram/consts.py +0 -8
  53. kleinkram/enums.py +0 -10
  54. kleinkram/resources.py +0 -158
  55. kleinkram-0.38.1.dev20241212075157.dist-info/LICENSE +0 -674
  56. kleinkram-0.38.1.dev20241212075157.dist-info/RECORD +0 -37
  57. tests/test_resources.py +0 -137
  58. {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.parsing import _parse_file
12
- from kleinkram.api.parsing import _parse_mission
13
- from kleinkram.api.parsing import _parse_project
14
- from kleinkram.config import Config
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.models import DataType
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
- resp = client.get(MISSIONS_BY_PROJECT, params=params)
74
+ FILE_ENDPOINT = "/file/many"
75
+ MISSION_ENDPOINT = "/mission/many"
76
+ PROJECT_ENDPOINT = "/project/many"
70
77
 
71
- if resp.status_code in (403, 404):
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
- return missions
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 _get_files_by_mission(client: AuthenticatedClient, mission: Mission) -> List[File]:
86
- params = {"uuid": str(mission.id), "take": MAX_PAGINATION}
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
- if resp.status_code in (403, 404):
91
- return []
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
- data = resp.json()
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
- return files
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 _get_mission_id_by_name(
105
- client: AuthenticatedClient, mission_name, project_id: UUID
106
- ) -> Optional[UUID]:
107
- params = {"name": mission_name, "projectUUID": str(project_id)}
108
- resp = client.get(MISSION_BY_NAME, params=params)
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
- # TODO: handle other status codes
114
- resp.raise_for_status()
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
- return UUID(data["uuid"], version=4)
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 _get_mission_id_by_name(client, mission_name, project_id) is not None:
139
- raise MissionExists(f"Mission with name: `{mission_name}` already exists")
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(MISSION_CREATE, json=payload)
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 _get_tag_type_by_name(
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[TagType]:
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
- tag_type = TagType(
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
- tag_type = _get_tag_type_by_name(client, key)
190
-
191
- if tag_type is None:
192
- print(f"tag: {key} not found")
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 _update_mission_metadata(
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(MISSION_UPDATE_METADATA, json=payload)
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 = 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"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: ")
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
- creds = Credentials(auth_token=auth_token, refresh_token=refresh_token)
36
- config.save_credentials(creds)
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 = Config(overwrite=True)
82
-
84
+ config = get_config()
83
85
  # use cli key login
84
86
  if key is not None:
85
- creds = Credentials(cli_key=key)
86
- config.save_credentials(creds)
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=url)
93
+ _browser_auth(url=oauth_url)
92
94
  else:
93
- headless_url = f"{url}-no-redirect"
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.file_transfer import download_files
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 spec
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
- project_spec = ProjectSpec(patterns=project_patterns, ids=project_ids)
64
- mission_spec = MissionSpec(
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
- project_spec=project_spec,
64
+ project_query=project_query,
68
65
  )
69
- file_spec = FileSpec(
70
- patterns=file_patterns, ids=file_ids, mission_spec=mission_spec
66
+ file_query = FileQuery(
67
+ patterns=file_patterns, ids=file_ids, mission_query=mission_query
71
68
  )
72
69
 
73
- client = AuthenticatedClient()
74
- parsed_files = get_files_by_spec(client, file_spec)
75
-
76
- if get_shared_state().verbose:
77
- table = files_to_table(parsed_files, title="downloading files...")
78
- Console().print(table)
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)