kleinkram 0.36.3.dev20241113174857__py3-none-any.whl → 0.37.0.dev20241118070559__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 +328 -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.dev20241118070559.dist-info}/LICENSE +1 -1
  27. kleinkram-0.37.0.dev20241118070559.dist-info/METADATA +113 -0
  28. kleinkram-0.37.0.dev20241118070559.dist-info/RECORD +33 -0
  29. {kleinkram-0.36.3.dev20241113174857.dist-info → kleinkram-0.37.0.dev20241118070559.dist-info}/WHEEL +2 -1
  30. kleinkram-0.37.0.dev20241118070559.dist-info/entry_points.txt +2 -0
  31. kleinkram-0.37.0.dev20241118070559.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/auth.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.parse
4
+ import webbrowser
5
+ from getpass import getpass
6
+ from http.server import BaseHTTPRequestHandler
7
+ from http.server import HTTPServer
8
+ from typing import Optional
9
+
10
+ from kleinkram.config import Config
11
+ from kleinkram.config import CONFIG_PATH
12
+ from kleinkram.config import CorruptedConfigFile
13
+ from kleinkram.config import Credentials
14
+ from kleinkram.config import InvalidConfigFile
15
+
16
+ CLI_CALLBACK_ENDPOINT = "/cli/callback"
17
+ OAUTH_SLUG = "/auth/google?state=cli"
18
+
19
+
20
+ def _has_browser() -> bool:
21
+ try:
22
+ webbrowser.get()
23
+ return True
24
+ except webbrowser.Error:
25
+ return False
26
+
27
+
28
+ def _headless_auth(*, url: str) -> None:
29
+ config = Config()
30
+
31
+ print(f"Please open the following URL manually to authenticate: {url}")
32
+ print("Enter the authentication token provided after logging in:")
33
+ auth_token = getpass("Authentication Token: ")
34
+ refresh_token = getpass("Refresh Token: ")
35
+
36
+ if auth_token and refresh_token:
37
+ creds = Credentials(auth_token=auth_token, refresh_token=refresh_token)
38
+ config.save_credentials(creds)
39
+ print(f"Authentication complete. Tokens saved to {CONFIG_PATH}.")
40
+ else:
41
+ raise ValueError("Please provided tokens.")
42
+
43
+
44
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
45
+ def do_GET(self):
46
+ if self.path.startswith(CLI_CALLBACK_ENDPOINT):
47
+ query = urllib.parse.urlparse(self.path).query
48
+ params = urllib.parse.parse_qs(query)
49
+
50
+ try:
51
+ creds = Credentials(
52
+ auth_token=params.get("authtoken")[0], # type: ignore
53
+ refresh_token=params.get("refreshtoken")[0], # type: ignore
54
+ )
55
+ except Exception:
56
+ raise RuntimeError("Failed to fetch authentication tokens.")
57
+
58
+ config = Config()
59
+ config.save_credentials(creds)
60
+
61
+ self.send_response(200)
62
+ self.send_header("Content-type", "text/html")
63
+ self.end_headers()
64
+ self.wfile.write(b"Authentication successful. You can close this window.")
65
+ else:
66
+ raise RuntimeError("Invalid path")
67
+
68
+ def log_message(self, *args, **kwargs):
69
+ _ = args, kwargs
70
+ pass # suppress logging
71
+
72
+
73
+ def _browser_auth(*, url: str) -> None:
74
+ webbrowser.open(url)
75
+
76
+ server = HTTPServer(("", 8000), OAuthCallbackHandler)
77
+ server.handle_request()
78
+
79
+ print(f"Authentication complete. Tokens saved to {CONFIG_PATH}.")
80
+
81
+
82
+ def login_flow(*, key: Optional[str] = None, headless: bool = False) -> None:
83
+ config = Config(overwrite=True)
84
+
85
+ # use cli key login
86
+ if key is not None:
87
+ creds = Credentials(cli_key=key)
88
+ config.save_credentials(creds)
89
+
90
+ url = f"{config.endpoint}{OAUTH_SLUG}"
91
+
92
+ if not headless and _has_browser():
93
+ _browser_auth(url=url)
94
+ else:
95
+ headless_url = f"{url}-no-redirect"
96
+ _headless_auth(url=headless_url)
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import List
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from kleinkram.api.client import AuthenticatedClient
10
+ from kleinkram.api.file_transfer import download_file
11
+ from kleinkram.api.routes import get_files_by_file_spec
12
+ from kleinkram.config import get_shared_state
13
+ from kleinkram.models import FILE_STATE_COLOR
14
+ from kleinkram.models import files_to_table
15
+ from kleinkram.models import FileState
16
+ from kleinkram.utils import b64_md5
17
+ from kleinkram.utils import get_valid_file_spec
18
+ from kleinkram.utils import to_name_or_uuid
19
+ from rich.console import Console
20
+
21
+
22
+ HELP = """\
23
+ Download files from kleinkram.
24
+ """
25
+
26
+
27
+ download_typer = typer.Typer(
28
+ name="download", no_args_is_help=True, invoke_without_command=True, help=HELP
29
+ )
30
+
31
+
32
+ @download_typer.callback()
33
+ def download(
34
+ files: Optional[List[str]] = typer.Argument(
35
+ None, help="file names, ids or patterns"
36
+ ),
37
+ project: Optional[str] = typer.Option(
38
+ None, "--project", "-p", help="project name or id"
39
+ ),
40
+ mission: Optional[str] = typer.Option(
41
+ None, "--mission", "-m", help="mission name or id"
42
+ ),
43
+ dest: str = typer.Option(prompt="destination", help="local path to save the files"),
44
+ ) -> None:
45
+ _files = [to_name_or_uuid(f) for f in files or []]
46
+ _project = to_name_or_uuid(project) if project else None
47
+ _mission = to_name_or_uuid(mission) if mission else None
48
+
49
+ # create destionation directory
50
+ dest_dir = Path(dest)
51
+
52
+ if not dest_dir.exists():
53
+ typer.confirm(f"Destination {dest_dir} does not exist. Create it?", abort=True)
54
+
55
+ dest_dir.mkdir(parents=True, exist_ok=True)
56
+
57
+ client = AuthenticatedClient()
58
+ file_spec = get_valid_file_spec(_files, mission=_mission, project=_project)
59
+ parsed_files = get_files_by_file_spec(client, file_spec)
60
+
61
+ # check if filenames are unique
62
+ if len(set(f.name for f in parsed_files)) != len(parsed_files):
63
+ raise ValueError(
64
+ "the files you are trying to download do not have unique names"
65
+ )
66
+
67
+ console = Console()
68
+ if get_shared_state().verbose:
69
+ table = files_to_table(parsed_files, title="downloading files...")
70
+ console.print(table)
71
+
72
+ for file in parsed_files:
73
+ if file.state != FileState.OK:
74
+ if get_shared_state().verbose:
75
+ console.print(
76
+ f"Skipping file {file.name} with state ",
77
+ end="",
78
+ )
79
+ console.print(f"{file.state.value}", style=FILE_STATE_COLOR[file.state])
80
+ else:
81
+ print(
82
+ f"skipping file {file.name} with state {file.state.value}",
83
+ file=sys.stderr,
84
+ )
85
+ continue
86
+
87
+ try:
88
+ download_file(
89
+ client,
90
+ file_id=file.id,
91
+ name=file.name,
92
+ dest=dest_dir,
93
+ hash=file.hash,
94
+ size=file.size,
95
+ )
96
+ except FileExistsError:
97
+ local_hash = b64_md5(dest_dir / file.name)
98
+ if local_hash == file.hash:
99
+ print(f"{file.name} already exists in dest, skipping...")
100
+ else:
101
+ print(f"{file.name} already exists in dest, but has different hash!")
102
+ except Exception as e:
103
+ print(f"Error downloading file {file.name}: {repr(e)}")
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import typer
6
+ from kleinkram.auth import Config
7
+
8
+ HELP = """\
9
+ Get or set the current endpoint.
10
+
11
+ The endpoint is used to determine the API server to connect to\
12
+ (default is the API server of https://datasets.leggedrobotics.com).
13
+ """
14
+
15
+ endpoint_typer = typer.Typer(
16
+ name="endpoint",
17
+ help=HELP,
18
+ no_args_is_help=True,
19
+ context_settings={"help_option_names": ["-h", "--help"]},
20
+ )
21
+
22
+
23
+ @endpoint_typer.command("set")
24
+ def set_endpoint(endpoint: str = typer.Argument(None, help="API endpoint to use")):
25
+ """
26
+ Use this command to switch between different API endpoints.\n
27
+ Standard endpoints are:\n
28
+ - http://localhost:3000\n
29
+ - https://api.datasets.leggedrobotics.com\n
30
+ - https://api.datasets.dev.leggedrobotics.com
31
+ """
32
+
33
+ if not endpoint:
34
+ raise ValueError("No endpoint provided.")
35
+
36
+ tokenfile = Config()
37
+ tokenfile.endpoint = endpoint
38
+ tokenfile.save()
39
+
40
+ print(f"Endpoint set to: {endpoint}")
41
+ if tokenfile.endpoint not in tokenfile.credentials:
42
+ print("\nLogin with `klein login`.")
43
+
44
+
45
+ @endpoint_typer.command("list")
46
+ def list_endpoints():
47
+ """
48
+ Get the current endpoint
49
+
50
+ Also displays all endpoints with saved tokens.
51
+ """
52
+ config = Config()
53
+ print(f"Current endpoint: {config.endpoint}\n", file=sys.stderr)
54
+
55
+ if not config.credentials:
56
+ print("No saved credentials found.", file=sys.stderr)
57
+ return
58
+
59
+ print("Found Credentials for:", file=sys.stderr)
60
+ for ep in config.credentials.keys():
61
+ print(" - ", file=sys.stderr, end="", flush=True)
62
+ print(ep, file=sys.stdout, flush=True)
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from kleinkram.api.client import AuthenticatedClient
8
+ from kleinkram.api.routes import get_files
9
+ from kleinkram.api.routes import get_missions
10
+ from kleinkram.api.routes import get_projects
11
+ from kleinkram.config import get_shared_state
12
+ from kleinkram.models import files_to_table
13
+ from kleinkram.models import missions_to_table
14
+ from kleinkram.models import projects_to_table
15
+ from rich.console import Console
16
+ from typer import BadParameter
17
+
18
+ HELP = """\
19
+ List projects, missions, or files.
20
+ """
21
+
22
+
23
+ list_typer = typer.Typer(
24
+ name="list", invoke_without_command=True, help=HELP, no_args_is_help=True
25
+ )
26
+
27
+
28
+ def _parse_metadata(raw: List[str]) -> dict:
29
+ ret = {}
30
+ for tag in raw:
31
+ if "=" not in tag:
32
+ raise BadParameter("tag must be formatted as `key=value`")
33
+ k, v = tag.split("=")
34
+ ret[k] = v
35
+ return ret
36
+
37
+
38
+ @list_typer.command()
39
+ def files(
40
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="project name"),
41
+ mission: Optional[str] = typer.Option(None, "--mission", "-m", help="mission name"),
42
+ topics: List[str] = typer.Option(None, "--topics", "-t", help="topics"),
43
+ metadata: Optional[List[str]] = typer.Argument(None, help="tag=value pairs"),
44
+ ) -> None:
45
+ client = AuthenticatedClient()
46
+
47
+ _topics = topics if topics else None
48
+ _metadata = _parse_metadata(metadata or [])
49
+
50
+ files = get_files(
51
+ client, project=project, mission=mission, tags=_metadata, topics=_topics
52
+ )
53
+
54
+ if get_shared_state().verbose:
55
+ table = files_to_table(files)
56
+ console = Console()
57
+ console.print(table)
58
+ else:
59
+ for file in files:
60
+ print(file.id)
61
+
62
+
63
+ @list_typer.command()
64
+ def projects() -> None:
65
+ client = AuthenticatedClient()
66
+ projects = get_projects(client)
67
+
68
+ if get_shared_state().verbose:
69
+ table = projects_to_table(projects)
70
+ console = Console()
71
+ console.print(table)
72
+ else:
73
+ for project in projects:
74
+ print(project.id)
75
+
76
+
77
+ @list_typer.command()
78
+ def missions(
79
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="project name"),
80
+ metadata: Optional[List[str]] = typer.Argument(None, help="tag=value pairs"),
81
+ ) -> None:
82
+ client = AuthenticatedClient()
83
+
84
+ _metadata = _parse_metadata(metadata or [])
85
+ missions = get_missions(client, project=project, tags=_metadata)
86
+
87
+ if get_shared_state().verbose:
88
+ table = missions_to_table(missions)
89
+ console = Console()
90
+ console.print(table)
91
+ else:
92
+ for mission in missions:
93
+ print(mission.id)
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from kleinkram.api.client import AuthenticatedClient
8
+ from kleinkram.api.routes import get_mission_by_spec
9
+ from kleinkram.api.routes import update_mission_metadata
10
+ from kleinkram.errors import MissionDoesNotExist
11
+ from kleinkram.utils import get_valid_mission_spec
12
+ from kleinkram.utils import load_metadata
13
+ from kleinkram.utils import to_name_or_uuid
14
+
15
+ mission_typer = typer.Typer(
16
+ no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}
17
+ )
18
+
19
+
20
+ UPDATE_HELP = """\
21
+ Update a mission.
22
+ """
23
+
24
+ NOT_IMPLEMENTED_YET = "Not implemented yet"
25
+
26
+
27
+ @mission_typer.command(help=UPDATE_HELP)
28
+ def update(
29
+ project: Optional[str] = typer.Option(
30
+ None, "--project", "-p", help="project id or name"
31
+ ),
32
+ mission: str = typer.Option(..., "--mission", "-m", help="mission id or name"),
33
+ metadata: str = typer.Option(help="path to metadata file (json or yaml)"),
34
+ ) -> None:
35
+ _project = to_name_or_uuid(project) if project else None
36
+ _mission = to_name_or_uuid(mission) if mission else None
37
+
38
+ client = AuthenticatedClient()
39
+
40
+ mission_spec = get_valid_mission_spec(_mission, _project)
41
+ mission_parsed = get_mission_by_spec(client, mission_spec)
42
+
43
+ if mission_parsed is None:
44
+ raise MissionDoesNotExist(f"Mission {mission} does not exist")
45
+
46
+ metadata_dct = load_metadata(Path(metadata))
47
+ update_mission_metadata(client, mission_parsed.id, metadata_dct)
48
+
49
+
50
+ @mission_typer.command(help=NOT_IMPLEMENTED_YET)
51
+ def create() -> None:
52
+ raise NotImplementedError("Not implemented yet")
53
+
54
+
55
+ @mission_typer.command(help=NOT_IMPLEMENTED_YET)
56
+ def delete() -> None:
57
+ raise NotImplementedError("Not implemented yet")
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ project_typer = typer.Typer(
6
+ no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}
7
+ )
8
+
9
+ NOT_IMPLEMENTED_YET = "Not implemented yet"
10
+
11
+
12
+ @project_typer.command(help=NOT_IMPLEMENTED_YET)
13
+ def update() -> None:
14
+ raise NotImplementedError("Not implemented yet")
15
+
16
+
17
+ @project_typer.command(help=NOT_IMPLEMENTED_YET)
18
+ def create() -> None:
19
+ raise NotImplementedError("Not implemented yet")
20
+
21
+
22
+ @project_typer.command(help=NOT_IMPLEMENTED_YET)
23
+ def delete() -> None:
24
+ raise NotImplementedError("Not implemented yet")
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+ from typing import Optional
6
+ from uuid import UUID
7
+
8
+ import typer
9
+ from kleinkram.api.client import AuthenticatedClient
10
+ from kleinkram.api.file_transfer import upload_files
11
+ from kleinkram.api.routes import create_mission
12
+ from kleinkram.api.routes import get_mission_by_id
13
+ from kleinkram.api.routes import get_mission_by_spec
14
+ from kleinkram.api.routes import get_project_id_by_name
15
+ from kleinkram.api.routes import get_tags_map
16
+ from kleinkram.config import get_shared_state
17
+ from kleinkram.errors import MissionDoesNotExist
18
+ from kleinkram.models import MissionByName
19
+ from kleinkram.utils import check_file_paths
20
+ from kleinkram.utils import get_filename_map
21
+ from kleinkram.utils import get_valid_mission_spec
22
+ from kleinkram.utils import load_metadata
23
+ from kleinkram.utils import to_name_or_uuid
24
+ from rich.console import Console
25
+
26
+
27
+ HELP = """\
28
+ Upload files to kleinkram.
29
+ """
30
+
31
+ upload_typer = typer.Typer(
32
+ name="upload",
33
+ no_args_is_help=True,
34
+ invoke_without_command=True,
35
+ help=HELP,
36
+ )
37
+
38
+
39
+ @upload_typer.callback()
40
+ def upload(
41
+ files: List[str] = typer.Argument(help="files to upload"),
42
+ project: Optional[str] = typer.Option(
43
+ None, "--project", "-p", help="project id or name"
44
+ ),
45
+ mission: str = typer.Option(..., "--mission", "-m", help="mission id or name"),
46
+ create: bool = typer.Option(False, help="create mission if it does not exist"),
47
+ metadata: Optional[str] = typer.Option(
48
+ None, help="path to metadata file (json or yaml)"
49
+ ),
50
+ fix_filenames: bool = typer.Option(False, help="fix filenames"),
51
+ ignore_missing_tags: bool = typer.Option(False, help="ignore mission tags"),
52
+ ) -> None:
53
+ _project = to_name_or_uuid(project) if project else None
54
+ _mission = to_name_or_uuid(mission)
55
+
56
+ client = AuthenticatedClient()
57
+
58
+ # check files and `fix` filenames
59
+ if files is None:
60
+ files = []
61
+
62
+ file_paths = [Path(file) for file in files]
63
+ check_file_paths(file_paths)
64
+
65
+ files_map = get_filename_map(
66
+ [Path(file) for file in files],
67
+ )
68
+
69
+ if not fix_filenames:
70
+ for name, path in files_map.items():
71
+ if name != path.name:
72
+ raise ValueError(
73
+ f"invalid filename format {path.name}, use `--fix-filenames`"
74
+ )
75
+
76
+ # parse the mission spec and get mission
77
+ mission_spec = get_valid_mission_spec(_mission, _project)
78
+ mission_parsed = get_mission_by_spec(client, mission_spec)
79
+
80
+ if not create and mission_parsed is None:
81
+ raise MissionDoesNotExist(f"mission: {mission} does not exist, use `--create`")
82
+
83
+ # create missing mission
84
+ if mission_parsed is None:
85
+ if not isinstance(mission_spec, MissionByName):
86
+ raise ValueError(
87
+ "cannot create mission using mission id, pecify a mission name"
88
+ )
89
+
90
+ # get the metadata
91
+ tags_dct = {}
92
+ if metadata is not None:
93
+ metadata_dct = load_metadata(Path(metadata))
94
+ tags_dct = get_tags_map(client, metadata_dct)
95
+
96
+ # get project id
97
+ if isinstance(mission_spec.project, UUID):
98
+ project_id = mission_spec.project
99
+ else:
100
+ project_id = get_project_id_by_name(client, mission_spec.project)
101
+ if project_id is None:
102
+ raise ValueError(
103
+ f"unable to create mission, project: {mission_spec.project} not found"
104
+ )
105
+
106
+ mission_id = create_mission(
107
+ client,
108
+ project_id,
109
+ mission_spec.name,
110
+ tags=tags_dct,
111
+ ignore_missing_tags=ignore_missing_tags,
112
+ )
113
+
114
+ mission_parsed = get_mission_by_id(client, mission_id)
115
+ assert mission_parsed is not None, "unreachable"
116
+
117
+ filtered_files_map = {}
118
+ remote_file_names = [file.name for file in mission_parsed.files]
119
+
120
+ console = Console()
121
+ for name, path in files_map.items():
122
+ if name in remote_file_names:
123
+ console.print(
124
+ f"file: {name} (path: {path}) already exists in mission", style="yellow"
125
+ )
126
+ else:
127
+ filtered_files_map[name] = path
128
+
129
+ if not filtered_files_map:
130
+ console.print("\nNO FILES UPLOADED", style="yellow")
131
+ return
132
+
133
+ upload_files(
134
+ filtered_files_map,
135
+ mission_parsed.id,
136
+ n_workers=2,
137
+ verbose=get_shared_state().verbose,
138
+ )
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import List
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from kleinkram.api.client import AuthenticatedClient
11
+ from kleinkram.api.routes import get_mission_by_spec
12
+ from kleinkram.config import get_shared_state
13
+ from kleinkram.errors import MissionDoesNotExist
14
+ from kleinkram.models import FileState
15
+ from kleinkram.utils import b64_md5
16
+ from kleinkram.utils import check_file_paths
17
+ from kleinkram.utils import get_filename_map
18
+ from kleinkram.utils import get_valid_mission_spec
19
+ from kleinkram.utils import to_name_or_uuid
20
+ from rich.console import Console
21
+ from rich.table import Table
22
+ from rich.text import Text
23
+ from tqdm import tqdm
24
+
25
+
26
+ class FileVerificationStatus(str, Enum):
27
+ UPLAODED = "uploaded"
28
+ UPLOADING = "uploading"
29
+ MISSING = "missing"
30
+ CORRUPTED = "hash mismatch"
31
+ UNKNOWN = "unknown"
32
+
33
+
34
+ FILE_STATUS_STYLES = {
35
+ FileVerificationStatus.UPLAODED: "green",
36
+ FileVerificationStatus.UPLOADING: "yellow",
37
+ FileVerificationStatus.MISSING: "yellow",
38
+ FileVerificationStatus.CORRUPTED: "red",
39
+ FileVerificationStatus.UNKNOWN: "gray",
40
+ }
41
+
42
+
43
+ HELP = """\
44
+ Verify if files were uploaded correctly.
45
+ """
46
+
47
+ verify_typer = typer.Typer(name="verify", invoke_without_command=True, help=HELP)
48
+
49
+
50
+ @verify_typer.callback()
51
+ def verify(
52
+ files: List[str] = typer.Argument(help="files to upload"),
53
+ project: Optional[str] = typer.Option(
54
+ None, "--project", "-p", help="project id or name"
55
+ ),
56
+ mission: str = typer.Option(..., "--mission", "-m", help="mission id or name"),
57
+ skip_hash: bool = typer.Option(False, help="skip hash check"),
58
+ ) -> None:
59
+ _project = to_name_or_uuid(project) if project else None
60
+ _mission = to_name_or_uuid(mission)
61
+
62
+ client = AuthenticatedClient()
63
+
64
+ if files is None:
65
+ files = []
66
+
67
+ mission_spec = get_valid_mission_spec(_mission, _project)
68
+ mission_parsed = get_mission_by_spec(client, mission_spec)
69
+
70
+ if mission_parsed is None:
71
+ raise MissionDoesNotExist(f"Mission {mission} does not exist")
72
+
73
+ # check file types
74
+ file_paths = [Path(file) for file in files]
75
+ check_file_paths(file_paths)
76
+
77
+ filename_map = get_filename_map(file_paths)
78
+ remote_files = {file.name: file for file in mission_parsed.files}
79
+
80
+ status_dct = {}
81
+ for name, file in tqdm(
82
+ filename_map.items(),
83
+ desc="verifying files",
84
+ unit="file",
85
+ disable=skip_hash or not get_shared_state().verbose,
86
+ ):
87
+ if name not in remote_files:
88
+ status_dct[file] = FileVerificationStatus.MISSING
89
+ continue
90
+
91
+ state = remote_files[name].state
92
+
93
+ if state == FileState.UPLOADING:
94
+ status_dct[file] = FileVerificationStatus.UPLOADING
95
+ elif state == FileState.OK and remote_files[name].hash != b64_md5(file):
96
+ status_dct[file] = FileVerificationStatus.CORRUPTED
97
+ elif state == FileState.OK:
98
+ status_dct[file] = FileVerificationStatus.UPLAODED
99
+ else:
100
+ status_dct[file] = FileVerificationStatus.UNKNOWN
101
+
102
+ if get_shared_state().verbose:
103
+ table = Table(title="file status")
104
+ table.add_column("filename", style="cyan")
105
+ table.add_column("status", style="green")
106
+
107
+ for path, status in status_dct.items():
108
+ table.add_row(str(path), Text(status, style=FILE_STATUS_STYLES[status]))
109
+
110
+ console = Console()
111
+ console.print(table)
112
+ else:
113
+ for path, status in status_dct.items():
114
+ stream = (
115
+ sys.stdout if status == FileVerificationStatus.UPLAODED else sys.stderr
116
+ )
117
+ print(path, file=stream)