kleinkram 0.36.3.dev20241113174857__py3-none-any.whl → 0.37.0__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 (48) hide show
  1. kleinkram/__init__.py +6 -0
  2. kleinkram/__main__.py +6 -0
  3. kleinkram/_version.py +6 -0
  4. kleinkram/api/__init__.py +0 -0
  5. kleinkram/api/client.py +65 -0
  6. kleinkram/api/file_transfer.py +337 -0
  7. kleinkram/api/routes.py +460 -0
  8. kleinkram/app.py +180 -0
  9. kleinkram/auth.py +96 -0
  10. kleinkram/commands/__init__.py +1 -0
  11. kleinkram/commands/download.py +103 -0
  12. kleinkram/commands/endpoint.py +62 -0
  13. kleinkram/commands/list.py +93 -0
  14. kleinkram/commands/mission.py +57 -0
  15. kleinkram/commands/project.py +24 -0
  16. kleinkram/commands/upload.py +138 -0
  17. kleinkram/commands/verify.py +117 -0
  18. kleinkram/config.py +171 -0
  19. kleinkram/consts.py +8 -1
  20. kleinkram/core.py +14 -0
  21. kleinkram/enums.py +10 -0
  22. kleinkram/errors.py +59 -0
  23. kleinkram/main.py +6 -489
  24. kleinkram/models.py +186 -0
  25. kleinkram/utils.py +179 -0
  26. {kleinkram-0.36.3.dev20241113174857.dist-info/licenses → kleinkram-0.37.0.dist-info}/LICENSE +1 -1
  27. kleinkram-0.37.0.dist-info/METADATA +113 -0
  28. kleinkram-0.37.0.dist-info/RECORD +33 -0
  29. {kleinkram-0.36.3.dev20241113174857.dist-info → kleinkram-0.37.0.dist-info}/WHEEL +2 -1
  30. kleinkram-0.37.0.dist-info/entry_points.txt +2 -0
  31. kleinkram-0.37.0.dist-info/top_level.txt +2 -0
  32. tests/__init__.py +0 -0
  33. tests/test_utils.py +153 -0
  34. kleinkram/api_client.py +0 -63
  35. kleinkram/auth/auth.py +0 -160
  36. kleinkram/endpoint/endpoint.py +0 -58
  37. kleinkram/error_handling.py +0 -177
  38. kleinkram/file/file.py +0 -144
  39. kleinkram/helper.py +0 -272
  40. kleinkram/mission/mission.py +0 -310
  41. kleinkram/project/project.py +0 -138
  42. kleinkram/queue/queue.py +0 -8
  43. kleinkram/tag/tag.py +0 -71
  44. kleinkram/topic/topic.py +0 -55
  45. kleinkram/user/user.py +0 -75
  46. kleinkram-0.36.3.dev20241113174857.dist-info/METADATA +0 -24
  47. kleinkram-0.36.3.dev20241113174857.dist-info/RECORD +0 -20
  48. kleinkram-0.36.3.dev20241113174857.dist-info/entry_points.txt +0 -2
kleinkram/main.py CHANGED
@@ -1,495 +1,12 @@
1
- import importlib.metadata
2
- import os
3
- from datetime import datetime, timedelta
4
- from enum import Enum
1
+ from __future__ import annotations
5
2
 
6
- import httpx
7
- import typer
8
- from rich import print
9
- from rich.table import Table
10
- from typer.core import TyperGroup
11
- from typer.models import Context
12
- from typing_extensions import Annotated, List, Optional
3
+ from kleinkram.app import app
13
4
 
14
- from kleinkram.api_client import AuthenticatedClient
15
- from kleinkram.auth.auth import login, setCliKey, logout
16
- from kleinkram.endpoint.endpoint import endpoint
17
- from kleinkram.error_handling import (
18
- ErrorHandledTyper,
19
- AccessDeniedException,
20
- )
21
- from kleinkram.file.file import file
22
- from kleinkram.mission.mission import missionCommands
23
- from kleinkram.project.project import project
24
- from kleinkram.queue.queue import queue
25
- from kleinkram.tag.tag import tag
26
- from kleinkram.topic.topic import topic
27
- from kleinkram.user.user import user
28
- from .helper import (
29
- is_valid_UUIDv4,
30
- canUploadMission,
31
- promptForTags,
32
- expand_and_match,
33
- uploadFiles,
34
- )
35
5
 
36
-
37
- class CommandPanel(str, Enum):
38
- CoreCommands = "CORE COMMANDS"
39
- Commands = "COMMANDS"
40
- AdditionalCommands = "ADDITIONAL COMMANDS"
41
-
42
-
43
- def version_callback(value: bool):
44
- if value:
45
- try:
46
- _version = importlib.metadata.version("kleinkram")
47
- except importlib.metadata.PackageNotFoundError:
48
- _version = "local"
49
- typer.echo(f"CLI Version: {_version}")
50
- raise typer.Exit()
51
-
52
-
53
- class OrderCommands(TyperGroup):
54
- """
55
-
56
- The following code snippet is taken from https://github.com/tiangolo/typer/discussions/855 (see comment
57
- https://github.com/tiangolo/typer/discussions/855#discussioncomment-9824582) and adapted to our use case.
58
- """
59
-
60
- def list_commands(self, _ctx: Context) -> List[str]:
61
- order = list(CommandPanel)
62
- grouped_commands = {
63
- name: getattr(command, "rich_help_panel")
64
- for name, command in sorted(self.commands.items())
65
- if getattr(command, "rich_help_panel") in order
66
- }
67
- ungrouped_command_names = [
68
- command.name
69
- for command in self.commands.values()
70
- if command.name not in grouped_commands
71
- ]
72
- return [
73
- name
74
- for name, command in sorted(
75
- grouped_commands.items(),
76
- key=lambda item: order.index(item[1]),
77
- )
78
- ] + sorted(ungrouped_command_names)
79
-
80
-
81
- app = ErrorHandledTyper(
82
- context_settings={"help_option_names": ["-h", "--help"]},
83
- no_args_is_help=True,
84
- cls=OrderCommands,
85
- help=f"Kleinkram CLI\n\nThe Kleinkram CLI is a command line interface for Kleinkram. "
86
- f"For a list of available commands, run 'klein --help' or visit "
87
- f"https://docs.datasets.leggedrobotics.com/usage/cli/cli-getting-started.html for more information.",
88
- )
89
-
90
-
91
- @app.callback()
92
- def version(
93
- version: bool = typer.Option(
94
- None,
95
- "--version",
96
- "-v",
97
- callback=version_callback,
98
- is_eager=True,
99
- help="Print the version and exit",
100
- )
101
- ):
102
- pass
103
-
104
-
105
- app.add_typer(project, rich_help_panel=CommandPanel.Commands)
106
- app.add_typer(missionCommands, rich_help_panel=CommandPanel.Commands)
107
-
108
- app.add_typer(topic, rich_help_panel=CommandPanel.Commands)
109
- app.add_typer(file, rich_help_panel=CommandPanel.Commands)
110
- app.add_typer(queue, rich_help_panel=CommandPanel.Commands)
111
- app.add_typer(user, rich_help_panel=CommandPanel.Commands)
112
- app.add_typer(tag, rich_help_panel=CommandPanel.Commands)
113
- app.add_typer(endpoint, rich_help_panel=CommandPanel.AdditionalCommands)
114
-
115
- app.command(rich_help_panel=CommandPanel.AdditionalCommands)(login)
116
- app.command(rich_help_panel=CommandPanel.AdditionalCommands)(logout)
117
- app.command(hidden=True)(setCliKey)
118
-
119
-
120
- @app.command("download", rich_help_panel=CommandPanel.CoreCommands)
121
- def download():
122
- print(
123
- "Not implemented yet. Consider using the 'klein file download' or 'klein mission download' commands."
124
- )
125
-
126
-
127
- @app.command("upload", rich_help_panel=CommandPanel.CoreCommands, no_args_is_help=True)
128
- def upload(
129
- path: Annotated[
130
- List[str],
131
- typer.Option(help="Path to files to upload, Regex supported"),
132
- ],
133
- project: Annotated[str, typer.Option(help="Name or UUID of a Project")],
134
- mission: Annotated[str, typer.Option(help="Name of UUID Mission to create")],
135
- tags: Annotated[
136
- Optional[List[str]],
137
- typer.Option(help="Tags to add to the mission"),
138
- ] = None,
139
- fix_filenames: Annotated[
140
- bool,
141
- typer.Option(help="Automatically fix filenames such that they are valid"),
142
- ] = False,
143
- create_project: Annotated[
144
- bool,
145
- typer.Option(help="Allows adding files to an existing mission"),
146
- ] = False,
147
- create_mission: Annotated[
148
- bool,
149
- typer.Option(help="Allows adding files to an existing mission"),
150
- ] = False,
151
- overwrite: Annotated[
152
- bool,
153
- typer.Option(
154
- help="Overwrite files with the same name.\n\n*WARNING:* This cannot be undone! This command will NOT delete"
155
- "converted files, i.g. if the file is of type 'some-name.bag' the converted 'some-name.mcap' file will not "
156
- "be deleted."
157
- ),
158
- ] = False,
159
- overwrite_all: Annotated[
160
- bool,
161
- typer.Option(
162
- help="Overwrite files with the same name.\n\n*WARNING:* This cannot be undone! This command WILL "
163
- "automatically delete converted files, i.g. if the file is of type 'some-name.bag' the converted "
164
- "'some-name.mcap' file will be deleted."
165
- ),
166
- ] = False,
167
- ignore_tags: Annotated[
168
- bool,
169
- typer.Option(help="Ignore required tags for the mission."),
170
- ] = False,
171
- ):
172
- """
173
- Upload files matching the path to a mission in a project.
174
-
175
- The mission name must be unique within the project and not yet created.\n
176
- Multiple paths can be given by using the option multiple times.\n
177
- Examples:\n
178
- - 'klein upload --path "~/data/**/*.bag" --project "Project_1" --mission "Mission_1" --tags "0700946d-1d6a-4520-b263-0e177f49c35b:LEE-H" --tags "1565118d-593c-4517-8c2d-9658452d9319:Dodo"'\n
179
-
180
- """
181
-
182
- client = AuthenticatedClient()
183
-
184
- ##############################
185
- # Check if project exists
186
- ##############################
187
- if is_valid_UUIDv4(project):
188
- get_project_url = "/project/one"
189
- project_response = client.get(get_project_url, params={"uuid": project})
190
- else:
191
- get_project_url = "/project/byName"
192
- project_response = client.get(get_project_url, params={"name": project})
193
-
194
- if project_response.status_code >= 400:
195
- if not create_project and not is_valid_UUIDv4(project):
196
- raise AccessDeniedException(
197
- f"The project '{project}' does not exist or you do not have access to it.\n"
198
- f"Consider using the following command to create a project: 'klein project create' "
199
- f"or consider passing the flag '--create-project' to create the project automatically.",
200
- f"{project_response.json()['message']} ({project_response.status_code})",
201
- )
202
- elif is_valid_UUIDv4(project):
203
- raise ValueError(
204
- f"Project '{project}' does not exist. UUIDs cannot be used to create projects.\n"
205
- f"Please provide a valid project name or consider creating the project using the"
206
- f" following command: 'klein project create'"
207
- )
208
- else:
209
- print(f"Project '{project}' does not exist. Creating it now.")
210
- create_project_url = "/project/create"
211
- project_response = client.post(
212
- create_project_url,
213
- json={
214
- "name": project,
215
- "description": "autogenerated with kleinkram CLI",
216
- "requiredTags": [],
217
- },
218
- )
219
- if project_response.status_code >= 400:
220
- msg = str(project_response.json()["message"])
221
- raise ValueError(
222
- f"Failed to create project. Status Code: "
223
- f"{str(project_response.status_code)}\n"
224
- f"{msg}"
225
- )
226
- print("Project created successfully.")
227
-
228
- project_json = project_response.json()
229
- if not project_json["uuid"]:
230
- print(f"Project not found: '{project}'")
231
- return
232
-
233
- can_upload = canUploadMission(client, project_json["uuid"])
234
- if not can_upload:
235
- raise AccessDeniedException(
236
- f"You do not have the required permissions to upload to project '{project}'\n",
237
- "Access Denied",
238
- )
239
-
240
- ##############################
241
- # Check if mission exists
242
- ##############################
243
- if is_valid_UUIDv4(mission):
244
- get_mission_url = "/mission/one"
245
- mission_response = client.get(get_mission_url, params={"uuid": mission})
246
- else:
247
- get_mission_url = "/mission/byName"
248
- mission_response = client.get(
249
- get_mission_url,
250
- params={"name": mission, "projectUUID": project_json["uuid"]},
251
- )
252
-
253
- if mission_response.status_code >= 400:
254
- if not create_mission:
255
- raise AccessDeniedException(
256
- f"The mission '{mission}' does not exist or you do not have access to it.\n"
257
- f"Consider using the following command to create a mission: 'klein mission create' "
258
- f"or consider passing the flag '--create-mission' to create the mission automatically.",
259
- f"{mission_response.json()['message']} ({mission_response.status_code})",
260
- )
261
- else:
262
- print(f"Mission '{mission}' does not exist. Creating it now.")
263
- create_mission_url = "/mission/create"
264
- if not tags:
265
- tags = []
266
- tags_dict = {item.split(":")[0]: item.split(":")[1] for item in tags}
267
- required_tags = (
268
- project_json["requiredTags"] if "requiredTags" in project_json else []
269
- )
270
- missing_tags = [
271
- tag_key
272
- for tag_key in required_tags
273
- if (tag_key["uuid"] not in tags_dict)
274
- ]
275
- if not ignore_tags:
276
- if missing_tags and not ignore_tags:
277
- promptForTags(tags_dict, required_tags)
278
- else:
279
- print("Ignoring required tags for the mission:")
280
- for tag_key in missing_tags:
281
- print(f" - {tag_key}")
282
-
283
- mission_response = client.post(
284
- create_mission_url,
285
- json={
286
- "name": mission,
287
- "projectUUID": project_json["uuid"],
288
- "tags": tags_dict,
289
- "ignoreTags": ignore_tags,
290
- },
291
- )
292
- if mission_response.status_code >= 400:
293
- raise ValueError(
294
- f"Failed to create mission. Status Code: "
295
- f"{str(mission_response.status_code)}\n"
296
- f"{mission_response.json()['message'][0]}"
297
- )
298
-
299
- mission_json = mission_response.json()
300
-
301
- files = []
302
- for p in path:
303
- files.extend(expand_and_match(p))
304
-
305
- print(
306
- f"Uploading the following files to mission '{mission_json['name']}' in project '{project_json['name']}':"
307
- )
308
- filename_filepaths_map = {}
309
- for path in files:
310
- if not os.path.isdir(path):
311
-
312
- filename = path.split("/")[-1]
313
- filename_without_extension, extension = os.path.splitext(filename)
314
- if fix_filenames:
315
-
316
- # replace all non-alphanumeric characters with underscores
317
- filename_without_extension = "".join(
318
- char if char.isalnum() else "_"
319
- for char in filename_without_extension
320
- )
321
-
322
- # trim filename to 40 characters
323
- filename_without_extension = filename_without_extension[:40]
324
- filename = f"{filename_without_extension}{extension}"
325
-
326
- if (
327
- not filename.replace(".", "")
328
- .replace("_", "")
329
- .replace("-", "")
330
- .isalnum()
331
- ):
332
- raise ValueError(
333
- f"Filename '{filename}' is not valid. It must only contain alphanumeric characters, underscores and "
334
- f"hyphens. Consider using the '--fix-filenames' option to automatically fix the filenames."
335
- )
336
-
337
- if not 3 <= len(filename_without_extension) <= 50:
338
- raise ValueError(
339
- f"Filename '{filename}' is not valid. It must be between 3 and 40 characters long. Consider using "
340
- f"the '--fix-filenames' option to automatically fix the filenames."
341
- )
342
-
343
- filename_filepaths_map[filename] = path
344
- typer.secho(f" - {filename}", fg=typer.colors.RESET)
345
- print("\n\n")
346
-
347
- filenames = list(filename_filepaths_map.keys())
348
-
349
- if not filenames:
350
- raise ValueError("No files found matching the given path.")
351
-
352
- # validate filenames
353
- if len(filenames) != len(set(filenames)):
354
- raise ValueError(
355
- "Filenames must be unique. Please check the files you are trying to upload. This can happen if you have "
356
- "multiple files with the same name in different directories or use the '--fix-filenames' option."
357
- )
358
-
359
- # check if files already exist
360
- get_files_url = "/file/ofMission"
361
- response = client.get(
362
- get_files_url,
363
- params={"uuid": mission_json["uuid"]},
364
- )
365
- if response.status_code >= 400:
366
- raise ValueError(
367
- "Failed to check for existing files. Status Code: "
368
- + str(response.status_code)
369
- + "\n"
370
- + response.json()["message"]
371
- )
372
-
373
- existing_files = response.json()[0]
374
- conflicting_files = [
375
- file for file in existing_files if file["filename"] in filenames
376
- ]
377
-
378
- if conflicting_files and len(conflicting_files):
379
- print("The following files already exist in the mission:")
380
- for file in conflicting_files:
381
- typer.secho(f" - {file['filename']}", fg=typer.colors.RED, nl=False)
382
- if overwrite or overwrite_all:
383
- # delete existing files
384
- delete_files_url = f"/file/{file['uuid']}"
385
- response = client.delete(delete_files_url)
386
- if response.status_code >= 400:
387
- raise ValueError(
388
- "Failed to delete existing files. Status Code: "
389
- + str(response.status_code)
390
- + "\n"
391
- + response.json()["message"]
392
- )
393
- print(" » deleted")
394
-
395
- # check if converted files exist
396
- mcap_file = file["filename"].replace(".bag", ".mcap")
397
-
398
- if mcap_file == file["filename"]:
399
- continue
400
-
401
- mcap_uuid = next(
402
- (
403
- file["uuid"]
404
- for file in existing_files
405
- if file["filename"] == mcap_file
406
- ),
407
- None,
408
- )
409
-
410
- if mcap_uuid and overwrite_all:
411
- typer.secho(f" {mcap_file}", fg=typer.colors.RED, nl=False)
412
- delete_files_url = f"/file/{mcap_uuid}"
413
- response = client.delete(delete_files_url)
414
- if response.status_code >= 400:
415
- raise ValueError(
416
- "Failed to delete existing files. Status Code: "
417
- + str(response.status_code)
418
- + "\n"
419
- + response.json()["message"]
420
- )
421
- print(" » deleted")
422
- elif mcap_uuid and not overwrite_all:
423
- print(
424
- f" {mcap_file} » skipped (consider using '--overwrite-all' to delete this file)"
425
- )
426
- else:
427
- print(" » not found")
428
-
429
- else:
430
- print("")
431
-
432
- if not overwrite and not overwrite_all:
433
- print(
434
- "\nYou may use the '--overwrite' or '--overwrite-all' flag to overwrite existing files."
435
- )
436
- print("")
437
-
438
- get_temporary_credentials = "/file/temporaryAccess"
439
- response = client.post(
440
- get_temporary_credentials,
441
- json={"filenames": filenames, "missionUUID": mission_json["uuid"]},
442
- )
443
- if response.status_code >= 400:
444
- raise ValueError(
445
- "Failed to upload data. Status Code: "
446
- + str(response.status_code)
447
- + "\n"
448
- + response.json()["message"][0]
449
- )
450
-
451
- uploadFiles(response.json(), filename_filepaths_map, 4)
452
-
453
-
454
- @queue.command("list")
455
- def list_queue():
456
- """List current Queue entities"""
457
- try:
458
- url = "/queue/active"
459
- startDate = datetime.now().date() - timedelta(days=1)
460
- client = AuthenticatedClient()
461
- response = client.get(url, params={"startDate": startDate})
462
- response.raise_for_status()
463
- data = response.json()
464
- table = Table("UUID", "filename", "mission", "state", "origin", "createdAt")
465
- for topic in data:
466
- table.add_row(
467
- topic["uuid"],
468
- topic["filename"],
469
- topic["mission"]["name"],
470
- topic["state"],
471
- topic["location"],
472
- topic["createdAt"],
473
- )
474
- print(table)
475
-
476
- except httpx.HTTPError as e:
477
- print(e)
478
-
479
-
480
- @app.command("claim", hidden=True)
481
- def claim():
482
- """
483
- Claim admin rights as the first user
484
-
485
- Only works if no other user has claimed admin rights before.
486
- """
487
-
488
- client = AuthenticatedClient()
489
- response = client.post("/user/claimAdmin")
490
- response.raise_for_status()
491
- print("Admin claimed.")
6
+ def main() -> int:
7
+ app()
8
+ return 0
492
9
 
493
10
 
494
11
  if __name__ == "__main__":
495
- app()
12
+ raise SystemExit(main())
kleinkram/models.py ADDED
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from dataclasses import field
5
+ from enum import Enum
6
+ from typing import List
7
+ from typing import NamedTuple
8
+ from typing import Optional
9
+ from typing import Tuple
10
+ from typing import Union
11
+ from uuid import UUID
12
+
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+
17
+ @dataclass(frozen=True, eq=True)
18
+ class Project:
19
+ id: UUID
20
+ name: str
21
+ description: str
22
+ missions: List[Mission] = field(default_factory=list)
23
+
24
+
25
+ @dataclass(frozen=True, eq=True)
26
+ class Mission:
27
+ id: UUID
28
+ name: str
29
+ project_id: UUID
30
+ project_name: str
31
+ files: List[File] = field(default_factory=list)
32
+
33
+
34
+ class FileState(str, Enum):
35
+ OK = "OK"
36
+ CORRUPTED = "CORRUPTED"
37
+ UPLOADING = "UPLOADING"
38
+ ERROR = "ERROR"
39
+ CONVERSION_ERROR = "CONVERSION_ERROR"
40
+ LOST = "LOST"
41
+ FOUND = "FOUND"
42
+
43
+
44
+ FILE_STATE_COLOR = {
45
+ FileState.OK: "green",
46
+ FileState.CORRUPTED: "red",
47
+ FileState.UPLOADING: "yellow",
48
+ FileState.ERROR: "red",
49
+ FileState.CONVERSION_ERROR: "red",
50
+ FileState.LOST: "bold red",
51
+ FileState.FOUND: "yellow",
52
+ }
53
+
54
+
55
+ @dataclass(frozen=True, eq=True)
56
+ class File:
57
+ id: UUID
58
+ name: str
59
+ hash: str
60
+ size: int
61
+ mission_id: UUID
62
+ mission_name: str
63
+ project_id: UUID
64
+ project_name: str
65
+ state: FileState = FileState.OK
66
+
67
+
68
+ class DataType(str, Enum):
69
+ LOCATION = "LOCATION"
70
+ STRING = "STRING"
71
+ LINK = "LINK"
72
+ BOOLEAN = "BOOLEAN"
73
+ NUMBER = "NUMBER"
74
+ DATE = "DATE"
75
+
76
+
77
+ @dataclass(frozen=True, eq=True)
78
+ class TagType:
79
+ name: str
80
+ id: UUID
81
+ data_type: DataType
82
+ description: Optional[str]
83
+
84
+
85
+ def delimiter_row(
86
+ *lengths: int, delimiter: str = "-", cols: list[int] | None = None
87
+ ) -> List[str]:
88
+ ret = []
89
+ for i, col_len in enumerate(lengths):
90
+ if cols is None or i in cols:
91
+ ret.append(delimiter * col_len)
92
+ else:
93
+ ret.append("")
94
+ return ret
95
+
96
+
97
+ def projects_to_table(projects: List[Project]) -> Table:
98
+ table = Table(title="projects")
99
+ table.add_column("id")
100
+ table.add_column("name")
101
+ table.add_column("description")
102
+
103
+ for project in projects:
104
+ table.add_row(str(project.id), project.name, project.description)
105
+
106
+ return table
107
+
108
+
109
+ def missions_to_table(missions: List[Mission]) -> Table:
110
+ table = Table(title="missions")
111
+ table.add_column("project")
112
+ table.add_column("name")
113
+ table.add_column("id")
114
+
115
+ # order by project, name
116
+ missions_tp: List[Tuple[str, str, Mission]] = []
117
+ for mission in missions:
118
+ missions_tp.append((mission.project_name, mission.name, mission))
119
+ missions_tp.sort()
120
+
121
+ if not missions_tp:
122
+ return table
123
+ last_project: Optional[str] = None
124
+ for project, _, mission in missions_tp:
125
+ # add delimiter row if project changes
126
+ if last_project is not None and last_project != project:
127
+ table.add_row()
128
+ last_project = project
129
+
130
+ table.add_row(mission.project_name, mission.name, str(mission.id))
131
+
132
+ return table
133
+
134
+
135
+ def files_to_table(
136
+ files: List[File], *, title: str = "files", delimiters: bool = True
137
+ ) -> Table:
138
+ table = Table(title=title)
139
+ table.add_column("project")
140
+ table.add_column("mission")
141
+ table.add_column("name")
142
+ table.add_column("id")
143
+ table.add_column("state")
144
+
145
+ # order by project, mission, name
146
+ files_tp: List[Tuple[str, str, str, File]] = []
147
+ for file in files:
148
+ files_tp.append((file.project_name, file.mission_name, file.name, file))
149
+ files_tp.sort()
150
+
151
+ if not files_tp:
152
+ return table
153
+
154
+ last_mission: Optional[str] = None
155
+ for _, mission, _, file in files_tp:
156
+ if last_mission is not None and last_mission != mission and delimiters:
157
+ table.add_row()
158
+ last_mission = mission
159
+
160
+ table.add_row(
161
+ file.project_name,
162
+ file.mission_name,
163
+ file.name,
164
+ Text(str(file.id), style="green"),
165
+ Text(file.state.value, style=FILE_STATE_COLOR[file.state]),
166
+ )
167
+
168
+ return table
169
+
170
+
171
+ class FilesById(NamedTuple):
172
+ ids: List[UUID]
173
+
174
+
175
+ class FilesByMission(NamedTuple):
176
+ mission: MissionById | MissionByName
177
+ files: List[Union[str, UUID]]
178
+
179
+
180
+ class MissionById(NamedTuple):
181
+ id: UUID
182
+
183
+
184
+ class MissionByName(NamedTuple):
185
+ name: str
186
+ project: Union[str, UUID]