kleinkram 0.36.3.dev20241113165309__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.
- kleinkram/__init__.py +6 -0
- kleinkram/__main__.py +6 -0
- kleinkram/_version.py +6 -0
- kleinkram/api/__init__.py +0 -0
- kleinkram/api/client.py +65 -0
- kleinkram/api/file_transfer.py +337 -0
- kleinkram/api/routes.py +460 -0
- kleinkram/app.py +180 -0
- kleinkram/auth.py +96 -0
- kleinkram/commands/__init__.py +1 -0
- kleinkram/commands/download.py +103 -0
- kleinkram/commands/endpoint.py +62 -0
- kleinkram/commands/list.py +93 -0
- kleinkram/commands/mission.py +57 -0
- kleinkram/commands/project.py +24 -0
- kleinkram/commands/upload.py +138 -0
- kleinkram/commands/verify.py +117 -0
- kleinkram/config.py +171 -0
- kleinkram/consts.py +8 -1
- kleinkram/core.py +14 -0
- kleinkram/enums.py +10 -0
- kleinkram/errors.py +59 -0
- kleinkram/main.py +6 -484
- kleinkram/models.py +186 -0
- kleinkram/utils.py +179 -0
- {kleinkram-0.36.3.dev20241113165309.dist-info/licenses → kleinkram-0.37.0.dist-info}/LICENSE +1 -1
- kleinkram-0.37.0.dist-info/METADATA +113 -0
- kleinkram-0.37.0.dist-info/RECORD +33 -0
- {kleinkram-0.36.3.dev20241113165309.dist-info → kleinkram-0.37.0.dist-info}/WHEEL +2 -1
- kleinkram-0.37.0.dist-info/entry_points.txt +2 -0
- kleinkram-0.37.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_utils.py +153 -0
- kleinkram/api_client.py +0 -63
- kleinkram/auth/auth.py +0 -160
- kleinkram/endpoint/endpoint.py +0 -58
- kleinkram/error_handling.py +0 -177
- kleinkram/file/file.py +0 -144
- kleinkram/helper.py +0 -272
- kleinkram/mission/mission.py +0 -310
- kleinkram/project/project.py +0 -138
- kleinkram/queue/queue.py +0 -8
- kleinkram/tag/tag.py +0 -71
- kleinkram/topic/topic.py +0 -55
- kleinkram/user/user.py +0 -75
- kleinkram-0.36.3.dev20241113165309.dist-info/METADATA +0 -24
- kleinkram-0.36.3.dev20241113165309.dist-info/RECORD +0 -20
- kleinkram-0.36.3.dev20241113165309.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)
|