kleinkram 0.38.1.dev20241212075157__py3-none-any.whl → 0.38.1.dev20250113080249__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 (57) 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 +270 -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} +50 -22
  18. kleinkram/cli/error_handling.py +44 -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 +82 -27
  28. kleinkram/wrappers.py +401 -0
  29. {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250113080249.dist-info}/METADATA +3 -3
  30. kleinkram-0.38.1.dev20250113080249.dist-info/RECORD +48 -0
  31. {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250113080249.dist-info}/WHEEL +1 -1
  32. {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250113080249.dist-info}/top_level.txt +1 -0
  33. testing/__init__.py +0 -0
  34. testing/backend_fixtures.py +69 -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_fixtures.py +29 -0
  40. tests/test_printing.py +62 -0
  41. tests/test_query.py +138 -0
  42. tests/test_utils.py +34 -24
  43. tests/test_wrappers.py +71 -0
  44. kleinkram/api/parsing.py +0 -86
  45. kleinkram/commands/__init__.py +0 -1
  46. kleinkram/commands/endpoint.py +0 -62
  47. kleinkram/commands/mission.py +0 -69
  48. kleinkram/commands/project.py +0 -24
  49. kleinkram/commands/upload.py +0 -164
  50. kleinkram/commands/verify.py +0 -142
  51. kleinkram/consts.py +0 -8
  52. kleinkram/enums.py +0 -10
  53. kleinkram/resources.py +0 -158
  54. kleinkram-0.38.1.dev20241212075157.dist-info/LICENSE +0 -674
  55. kleinkram-0.38.1.dev20241212075157.dist-info/RECORD +0 -37
  56. tests/test_resources.py +0 -137
  57. {kleinkram-0.38.1.dev20241212075157.dist-info → kleinkram-0.38.1.dev20250113080249.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import OrderedDict
4
+ from typing import Any
5
+ from typing import Callable
6
+ from typing import Type
7
+
8
+ import typer
9
+ from click import ClickException
10
+
11
+ ExceptionHandler = Callable[[Exception], int]
12
+
13
+
14
+ class ErrorHandledTyper(typer.Typer):
15
+ """\
16
+ error handlers that are last added will be used first
17
+ """
18
+
19
+ _error_handlers: OrderedDict[Type[Exception], ExceptionHandler]
20
+
21
+ def error_handler(
22
+ self, exc: Type[Exception]
23
+ ) -> Callable[[ExceptionHandler], ExceptionHandler]:
24
+ def dec(func: ExceptionHandler) -> ExceptionHandler:
25
+ self._error_handlers[exc] = func
26
+ return func
27
+
28
+ return dec
29
+
30
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
31
+ super().__init__(*args, **kwargs)
32
+ self._error_handlers = OrderedDict()
33
+
34
+ def __call__(self, *args: Any, **kwargs: Any) -> int:
35
+ try:
36
+ return super().__call__(*args, **kwargs)
37
+ except Exception as e:
38
+ if isinstance(e, ClickException):
39
+ raise
40
+ for tp, handler in reversed(self._error_handlers.items()):
41
+ if isinstance(e, tp):
42
+ exit_code = handler(e)
43
+ raise SystemExit(exit_code)
44
+ raise
kleinkram/config.py CHANGED
@@ -1,18 +1,31 @@
1
+ """
2
+ this file contains a global config and a global state object
3
+
4
+ to get the config use `get_config()`
5
+ """
6
+
1
7
  from __future__ import annotations
2
8
 
3
9
  import json
10
+ import logging
4
11
  import os
5
12
  import tempfile
6
13
  from dataclasses import dataclass
14
+ from dataclasses import field
7
15
  from enum import Enum
8
16
  from pathlib import Path
17
+ from typing import Any
9
18
  from typing import Dict
10
19
  from typing import NamedTuple
11
20
  from typing import Optional
12
21
 
22
+ from rich.table import Table
23
+ from rich.text import Text
24
+
13
25
  from kleinkram._version import __local__
14
26
  from kleinkram._version import __version__
15
- from kleinkram.errors import InvalidConfigFile
27
+
28
+ logger = logging.getLogger(__name__)
16
29
 
17
30
  CONFIG_PATH = Path().home() / ".kleinkram.json"
18
31
 
@@ -23,13 +36,33 @@ class Environment(Enum):
23
36
  PROD = "prod"
24
37
 
25
38
 
26
- DEFAULT_API = {
27
- Environment.LOCAL: "http://localhost:3000",
28
- Environment.DEV: "https://api.datasets.dev.leggedrobotics.com",
29
- Environment.PROD: "https://api.datasets.leggedrobotics.com",
30
- }
39
+ class Endpoint(NamedTuple):
40
+ name: str
41
+ api: str
42
+ s3: str
43
+
44
+
45
+ class Credentials(NamedTuple):
46
+ auth_token: Optional[str] = None
47
+ refresh_token: Optional[str] = None
48
+ cli_key: Optional[str] = None
49
+
50
+
51
+ DEFAULT_LOCAL_API = "http://localhost:3000"
52
+ DEFAULT_LOCAL_S3 = "http://localhost:9000"
53
+
54
+ DEFAULT_DEV_API = "https://api.datasets.dev.leggedrobotics.com"
55
+ DEFAULT_DEV_S3 = "https://s3.datasets.dev.leggedrobotics.com"
56
+
57
+ DEFAULT_PROD_API = "https://api.datasets.leggedrobotics.com"
58
+ DEFAULT_PROD_S3 = "https://s3.datasets.leggedrobotics.com"
31
59
 
32
- LOCAL_S3 = "http://localhost:9000"
60
+
61
+ DEFAULT_ENDPOINTS = {
62
+ "local": Endpoint("local", DEFAULT_LOCAL_API, DEFAULT_LOCAL_S3),
63
+ "dev": Endpoint("dev", DEFAULT_DEV_API, DEFAULT_DEV_S3),
64
+ "prod": Endpoint("prod", DEFAULT_PROD_API, DEFAULT_PROD_S3),
65
+ }
33
66
 
34
67
 
35
68
  def get_env() -> Environment:
@@ -40,131 +73,132 @@ def get_env() -> Environment:
40
73
  return Environment.PROD
41
74
 
42
75
 
43
- def get_default_endpoints() -> str:
44
- env = get_env()
45
- return DEFAULT_API[env]
76
+ @dataclass
77
+ class Config:
78
+ version: str = __version__
79
+ selected_endpoint: str = field(default_factory=lambda: get_env().value)
80
+ endpoints: Dict[str, Endpoint] = field(
81
+ default_factory=lambda: DEFAULT_ENDPOINTS.copy()
82
+ )
83
+ endpoint_credentials: Dict[str, Credentials] = field(default_factory=dict)
84
+
85
+ @property
86
+ def endpoint(self) -> Endpoint:
87
+ return self.endpoints[self.selected_endpoint]
88
+
89
+ @endpoint.setter
90
+ def endpoint(self, value: Endpoint) -> None:
91
+ self.endpoints[self.selected_endpoint] = value
46
92
 
93
+ @property
94
+ def credentials(self) -> Optional[Credentials]:
95
+ return self.endpoint_credentials.get(self.selected_endpoint)
47
96
 
48
- class Credentials(NamedTuple):
49
- auth_token: Optional[str] = None
50
- refresh_token: Optional[str] = None
51
- cli_key: Optional[str] = None
97
+ @credentials.setter
98
+ def credentials(self, value: Credentials) -> None:
99
+ self.endpoint_credentials[self.selected_endpoint] = value
52
100
 
53
101
 
54
- JSON_ENDPOINT_KEY = "endpoint"
55
- JSON_CREDENTIALS_KEY = "credentials"
102
+ def _config_to_dict(config: Config) -> Dict[str, Any]:
103
+ return {
104
+ "version": config.version,
105
+ "endpoints": {key: value._asdict() for key, value in config.endpoints.items()},
106
+ "endpoint_credentials": {
107
+ key: value._asdict() for key, value in config.endpoint_credentials.items()
108
+ },
109
+ "selected_endpoint": config.endpoint.name,
110
+ }
56
111
 
57
112
 
58
- class Config:
59
- endpoint: str
60
- credentials: Dict[str, Credentials]
61
-
62
- def __init__(self, overwrite: bool = False) -> None:
63
- default_endpoint = get_default_endpoints()
64
-
65
- self.credentials = {}
66
- self.endpoint = default_endpoint
67
-
68
- if not CONFIG_PATH.exists():
69
- self.save()
70
-
71
- try:
72
- self._read_config()
73
- except InvalidConfigFile:
74
- if not overwrite:
75
- self.credentials = {}
76
- self.endpoint = default_endpoint
77
- self.save()
78
- else:
79
- raise
80
-
81
- def _read_config(self) -> None:
82
- with open(CONFIG_PATH, "r") as file:
83
- try:
84
- content = json.load(file)
85
- except Exception:
86
- raise InvalidConfigFile
87
-
88
- endpoint = content.get(JSON_ENDPOINT_KEY, None)
89
- if not isinstance(endpoint, str):
90
- raise InvalidConfigFile
91
-
92
- credentials = content.get(JSON_CREDENTIALS_KEY, None)
93
- if not isinstance(credentials, dict):
94
- raise InvalidConfigFile
95
-
96
- try:
97
- parsed_creds = {}
98
- for ep, creds in credentials.items():
99
- parsed_creds[ep] = Credentials(**creds)
100
- except Exception:
101
- raise InvalidConfigFile
102
-
103
- self.endpoint = endpoint
104
- self.credentials = parsed_creds
113
+ def _config_from_dict(dct: Dict[str, Any]) -> Config:
114
+ return Config(
115
+ dct["version"],
116
+ dct["selected_endpoint"],
117
+ {key: Endpoint(**value) for key, value in dct["endpoints"].items()},
118
+ {
119
+ key: Credentials(**value)
120
+ for key, value in dct["endpoint_credentials"].items()
121
+ },
122
+ )
105
123
 
106
- @property
107
- def has_cli_key(self) -> bool:
108
- if self.endpoint not in self.credentials:
109
- return False
110
- return self.credentials[self.endpoint].cli_key is not None
111
124
 
112
- @property
113
- def has_refresh_token(self) -> bool:
114
- if self.endpoint not in self.credentials:
115
- return False
116
- return self.credentials[self.endpoint].refresh_token is not None
125
+ def save_config(config: Config, path: Path = CONFIG_PATH) -> None:
126
+ fd, temp_path = tempfile.mkstemp()
127
+ with os.fdopen(fd, "w") as f:
128
+ json.dump(_config_to_dict(config), f)
129
+ os.replace(temp_path, path)
117
130
 
118
- @property
119
- def auth_token(self) -> Optional[str]:
120
- return self.credentials[self.endpoint].auth_token
121
131
 
122
- @property
123
- def refresh_token(self) -> Optional[str]:
124
- return self.credentials[self.endpoint].refresh_token
132
+ def _load_config(*, path: Path = CONFIG_PATH) -> Config:
133
+ if not path.exists():
134
+ return Config()
135
+ with open(path, "r") as f:
136
+ return _config_from_dict(json.load(f))
125
137
 
126
- @property
127
- def cli_key(self) -> Optional[str]:
128
- return self.credentials[self.endpoint].cli_key
129
138
 
130
- def save(self) -> None:
131
- serialized_tokens = {}
132
- for endpoint, auth in self.credentials.items():
133
- serialized_tokens[endpoint] = auth._asdict()
139
+ LOADED_CONFIGS: Dict[Path, Config] = {}
140
+
141
+
142
+ def get_config(path: Path = CONFIG_PATH) -> Config:
143
+ if path not in LOADED_CONFIGS:
144
+ LOADED_CONFIGS[path] = _load_config(path=path)
145
+ return LOADED_CONFIGS[path]
146
+
147
+
148
+ def select_endpoint(config: Config, name: str, path: Path = CONFIG_PATH) -> None:
149
+ if name not in config.endpoints:
150
+ raise ValueError(f"Endpoint {name} not found.")
151
+ config.selected_endpoint = name
152
+ save_config(config, path)
153
+
154
+
155
+ def add_endpoint(config: Config, endpoint: Endpoint, path: Path = CONFIG_PATH) -> None:
156
+ config.endpoints[endpoint.name] = endpoint
157
+ config.selected_endpoint = endpoint.name
158
+ save_config(config, path)
159
+
134
160
 
135
- data = {
136
- JSON_ENDPOINT_KEY: self.endpoint,
137
- JSON_CREDENTIALS_KEY: serialized_tokens,
138
- }
161
+ def check_config_compatibility(path: Path = CONFIG_PATH) -> bool:
162
+ """\
163
+ returns `False` if config file exists but is not compatible with the current version
139
164
 
140
- # atomically write to file
141
- fd, tmp_path = tempfile.mkstemp()
142
- with open(fd, "w") as file:
143
- json.dump(data, file)
165
+ TODO: add more sophisticated version checking
166
+ """
167
+ if not path.exists():
168
+ return True
169
+ try:
170
+ _ = _load_config(path=path)
171
+ except Exception as e:
172
+ logger.info(f"Error loading config: {e}")
173
+ return False
174
+ return True
144
175
 
145
- os.replace(tmp_path, CONFIG_PATH)
146
176
 
147
- def clear_credentials(self, all: bool = False) -> None:
148
- if all:
149
- self.credentials = {}
150
- elif self.endpoint in self.credentials:
151
- del self.credentials[self.endpoint]
152
- self.save()
177
+ def endpoint_table(config: Config) -> Table:
178
+ table = Table(title="Available Endpoints")
179
+ table.add_column("Name", style="cyan")
180
+ table.add_column("API", style="cyan")
181
+ table.add_column("S3", style="cyan")
153
182
 
154
- def save_credentials(self, creds: Credentials) -> None:
155
- self.credentials[self.endpoint] = creds
156
- self.save()
183
+ for name, endpoint in config.endpoints.items():
184
+ display_name = (
185
+ Text(name, style="bold yellow")
186
+ if name == config.selected_endpoint
187
+ else Text(name)
188
+ )
189
+ table.add_row(display_name, endpoint.api, endpoint.s3)
190
+ return table
157
191
 
158
192
 
159
193
  @dataclass
160
- class _SharedState:
194
+ class SharedState:
161
195
  log_file: Optional[Path] = None
162
196
  verbose: bool = True
163
197
  debug: bool = False
164
198
 
165
199
 
166
- SHARED_STATE = _SharedState()
200
+ SHARED_STATE = SharedState()
167
201
 
168
202
 
169
- def get_shared_state() -> _SharedState:
203
+ def get_shared_state() -> SharedState:
170
204
  return SHARED_STATE
kleinkram/core.py CHANGED
@@ -1,14 +1,262 @@
1
+ """
2
+ this file contains the main functionality of kleinkram cli
3
+
4
+ - download
5
+ - upload
6
+ - verify
7
+ - update_file
8
+ - update_mission
9
+ - update_project
10
+ - delete_files
11
+ - delete_mission
12
+ - delete_project
13
+ """
14
+
1
15
  from __future__ import annotations
2
16
 
3
17
  from pathlib import Path
18
+ from typing import Collection
19
+ from typing import Dict
4
20
  from typing import List
21
+ from typing import Optional
22
+ from typing import Sequence
5
23
  from uuid import UUID
6
24
 
25
+ from rich.console import Console
26
+ from tqdm import tqdm
27
+
28
+ import kleinkram.api.file_transfer
29
+ import kleinkram.api.routes
30
+ import kleinkram.errors
31
+ from kleinkram.api.client import AuthenticatedClient
32
+ from kleinkram.api.query import FileQuery
33
+ from kleinkram.api.query import MissionQuery
34
+ from kleinkram.api.query import ProjectQuery
35
+ from kleinkram.api.query import check_mission_query_is_creatable
36
+ from kleinkram.errors import MissionNotFound
37
+ from kleinkram.models import FileState
38
+ from kleinkram.models import FileVerificationStatus
39
+ from kleinkram.printing import files_to_table
40
+ from kleinkram.utils import b64_md5
41
+ from kleinkram.utils import check_file_paths
42
+ from kleinkram.utils import file_paths_from_files
43
+ from kleinkram.utils import get_filename_map
44
+
45
+
46
+ def download(
47
+ *,
48
+ client: AuthenticatedClient,
49
+ query: FileQuery,
50
+ base_dir: Path,
51
+ nested: bool = False,
52
+ overwrite: bool = False,
53
+ verbose: bool = False,
54
+ ) -> None:
55
+ """\
56
+ downloads files, asserts that the destition dir exists
57
+ returns the files that were downloaded
58
+
59
+ TODO: the above is a lie, at the moment we just return all files that were found
60
+ this might include some files that were skipped or not downloaded for some reason
61
+ we would need to modify the `download_files` function to return this in the future
62
+ """
63
+
64
+ if not base_dir.exists():
65
+ raise ValueError(f"Destination {base_dir.absolute()} does not exist")
66
+ if not base_dir.is_dir():
67
+ raise ValueError(f"Destination {base_dir.absolute()} is not a directory")
68
+
69
+ # retrive files and get the destination paths
70
+ files = list(kleinkram.api.routes.get_files(client, file_query=query))
71
+ paths = file_paths_from_files(files, dest=base_dir, allow_nested=nested)
72
+
73
+ if verbose:
74
+ table = files_to_table(files, title="downloading files...")
75
+ Console().print(table)
76
+
77
+ kleinkram.api.file_transfer.download_files(
78
+ client, paths, verbose=verbose, overwrite=overwrite
79
+ )
80
+
81
+
82
+ def upload(
83
+ *,
84
+ client: AuthenticatedClient,
85
+ query: MissionQuery,
86
+ file_paths: Sequence[Path],
87
+ create: bool = False,
88
+ metadata: Optional[Dict[str, str]] = None,
89
+ ignore_missing_metadata: bool = False,
90
+ verbose: bool = False,
91
+ ) -> None:
92
+ """\
93
+ uploads files to a mission
94
+
95
+ create a mission if it does not exist if `create` is True
96
+ in that case you can also specify `metadata` and `ignore_missing_metadata`
97
+ """
98
+ # check that file paths are for valid files and have valid suffixes
99
+ check_file_paths(file_paths)
100
+
101
+ try:
102
+ mission = kleinkram.api.routes.get_mission(client, query=query)
103
+ except MissionNotFound:
104
+ if not create:
105
+ raise
106
+ mission = None
107
+
108
+ if create and mission is None:
109
+ # check if project exists and get its id at the same time
110
+ project_id = kleinkram.api.routes.get_project(
111
+ client, query=query.project_query
112
+ ).id
113
+ mission_name = check_mission_query_is_creatable(query)
114
+ kleinkram.api.routes._create_mission(
115
+ client,
116
+ project_id,
117
+ mission_name,
118
+ metadata=metadata or {},
119
+ ignore_missing_tags=ignore_missing_metadata,
120
+ )
121
+ mission = kleinkram.api.routes.get_mission(client, query)
122
+
123
+ assert mission is not None, "unreachable"
124
+
125
+ filename_map = get_filename_map(file_paths)
126
+ kleinkram.api.file_transfer.upload_files(
127
+ client, filename_map, mission.id, verbose=verbose
128
+ )
129
+
130
+
131
+ def verify(
132
+ *,
133
+ client: AuthenticatedClient,
134
+ query: MissionQuery,
135
+ file_paths: Sequence[Path],
136
+ skip_hash: bool = False,
137
+ verbose: bool = False,
138
+ ) -> Dict[Path, FileVerificationStatus]:
139
+ # check that file paths are for valid files and have valid suffixes
140
+ check_file_paths(file_paths)
141
+
142
+ # check that the mission exists
143
+ _ = kleinkram.api.routes.get_mission(client, query)
144
+
145
+ remote_files = {
146
+ f.name: f
147
+ for f in kleinkram.api.routes.get_files(
148
+ client, file_query=FileQuery(mission_query=query)
149
+ )
150
+ }
151
+ filename_map = get_filename_map(file_paths)
152
+
153
+ # verify files
154
+ file_status: Dict[Path, FileVerificationStatus] = {}
155
+ for name, file in tqdm(
156
+ filename_map.items(),
157
+ desc="verifying files",
158
+ unit="file",
159
+ disable=not verbose,
160
+ ):
161
+ if name not in remote_files:
162
+ file_status[file] = FileVerificationStatus.MISSING
163
+ continue
164
+
165
+ remote_file = remote_files[name]
166
+
167
+ if remote_file.state == FileState.UPLOADING:
168
+ file_status[file] = FileVerificationStatus.UPLOADING
169
+ elif remote_file.state == FileState.OK:
170
+ if remote_file.hash is None:
171
+ file_status[file] = FileVerificationStatus.COMPUTING_HASH
172
+ elif skip_hash or remote_file.hash == b64_md5(file):
173
+ file_status[file] = FileVerificationStatus.UPLAODED
174
+ else:
175
+ file_status[file] = FileVerificationStatus.MISMATCHED_HASH
176
+ else:
177
+ file_status[file] = FileVerificationStatus.UNKNOWN
178
+ return file_status
179
+
180
+
181
+ def update_file(*, client: AuthenticatedClient, file_id: UUID) -> None:
182
+ """\
183
+ TODO: what should this even do
184
+ """
185
+ _ = client, file_id
186
+ raise NotImplementedError("if you have an idea what this should do, open an issue")
187
+
188
+
189
+ def update_mission(
190
+ *, client: AuthenticatedClient, mission_id: UUID, metadata: Dict[str, str]
191
+ ) -> None:
192
+ # TODO: this funciton will do more than just overwirte the metadata in the future
193
+ kleinkram.api.routes._update_mission(client, mission_id, metadata=metadata)
194
+
195
+
196
+ def update_project(
197
+ *,
198
+ client: AuthenticatedClient,
199
+ project_id: UUID,
200
+ description: Optional[str] = None,
201
+ new_name: Optional[str] = None,
202
+ ) -> None:
203
+ # TODO: this function should do more in the future
204
+ kleinkram.api.routes._update_project(
205
+ client, project_id, description=description, new_name=new_name
206
+ )
207
+
208
+
209
+ def delete_files(*, client: AuthenticatedClient, file_ids: Collection[UUID]) -> None:
210
+ """\
211
+ deletes multiple files accross multiple missions
212
+ """
213
+ files = list(kleinkram.api.routes.get_files(client, FileQuery(ids=list(file_ids))))
214
+
215
+ # check if all file_ids were actually found
216
+ found_ids = [f.id for f in files]
217
+ for file_id in file_ids:
218
+ if file_id not in found_ids:
219
+ raise kleinkram.errors.FileNotFound(
220
+ f"file {file_id} not found, did not delete any files"
221
+ )
222
+
223
+ # we can only batch delete files within the same mission
224
+ missions_to_files: Dict[UUID, List[UUID]] = {}
225
+ for file in files:
226
+ if file.mission_id not in missions_to_files:
227
+ missions_to_files[file.mission_id] = []
228
+ missions_to_files[file.mission_id].append(file.id)
229
+
230
+ for mission_id, ids_ in missions_to_files.items():
231
+ kleinkram.api.routes._delete_files(client, file_ids=ids_, mission_id=mission_id)
232
+
233
+
234
+ def delete_mission(*, client: AuthenticatedClient, mission_id: UUID) -> None:
235
+ mquery = MissionQuery(ids=[mission_id])
236
+ mission = kleinkram.api.routes.get_mission(client, mquery)
237
+ files = list(
238
+ kleinkram.api.routes.get_files(
239
+ client, file_query=FileQuery(mission_query=mquery)
240
+ )
241
+ )
7
242
 
8
- def upload() -> None: ...
243
+ # delete the files and then the mission
244
+ kleinkram.api.routes._delete_files(client, [f.id for f in files], mission.id)
245
+ kleinkram.api.routes._delete_mission(client, mission_id)
9
246
 
10
247
 
11
- def download() -> None: ...
248
+ def delete_project(*, client: AuthenticatedClient, project_id: UUID) -> None:
249
+ pquery = ProjectQuery(ids=[project_id])
250
+ _ = kleinkram.api.routes.get_project(client, pquery) # check if project exists
12
251
 
252
+ # delete all missions and files
253
+ missions = list(
254
+ kleinkram.api.routes.get_missions(
255
+ client, mission_query=MissionQuery(project_query=pquery)
256
+ )
257
+ )
258
+ for mission in missions:
259
+ delete_mission(client=client, mission_id=mission.id)
13
260
 
14
- def download_file(ids: List[UUID], dest: Path) -> None: ...
261
+ # delete the project
262
+ kleinkram.api.routes._delete_project(client, project_id)