kleinkram 0.38.1.dev20241120100707__py3-none-any.whl → 0.38.1.dev20241212075157__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/api/client.py +31 -23
- kleinkram/api/file_transfer.py +323 -203
- kleinkram/api/parsing.py +86 -0
- kleinkram/api/routes.py +77 -311
- kleinkram/app.py +20 -54
- kleinkram/auth.py +0 -2
- kleinkram/commands/download.py +60 -60
- kleinkram/commands/list.py +53 -48
- kleinkram/commands/mission.py +25 -13
- kleinkram/commands/upload.py +79 -53
- kleinkram/commands/verify.py +58 -35
- kleinkram/config.py +2 -3
- kleinkram/errors.py +48 -31
- kleinkram/models.py +2 -2
- kleinkram/resources.py +158 -0
- kleinkram/utils.py +16 -47
- {kleinkram-0.38.1.dev20241120100707.dist-info → kleinkram-0.38.1.dev20241212075157.dist-info}/METADATA +5 -3
- kleinkram-0.38.1.dev20241212075157.dist-info/RECORD +37 -0
- {kleinkram-0.38.1.dev20241120100707.dist-info → kleinkram-0.38.1.dev20241212075157.dist-info}/WHEEL +1 -1
- tests/test_end_to_end.py +105 -0
- tests/test_resources.py +137 -0
- tests/test_utils.py +13 -59
- kleinkram-0.38.1.dev20241120100707.dist-info/RECORD +0 -33
- {kleinkram-0.38.1.dev20241120100707.dist-info → kleinkram-0.38.1.dev20241212075157.dist-info}/LICENSE +0 -0
- {kleinkram-0.38.1.dev20241120100707.dist-info → kleinkram-0.38.1.dev20241212075157.dist-info}/entry_points.txt +0 -0
- {kleinkram-0.38.1.dev20241120100707.dist-info → kleinkram-0.38.1.dev20241212075157.dist-info}/top_level.txt +0 -0
kleinkram/commands/upload.py
CHANGED
|
@@ -3,24 +3,26 @@ from __future__ import annotations
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import List
|
|
5
5
|
from typing import Optional
|
|
6
|
-
from uuid import UUID
|
|
7
6
|
|
|
8
7
|
import typer
|
|
9
8
|
from kleinkram.api.client import AuthenticatedClient
|
|
10
9
|
from kleinkram.api.file_transfer import upload_files
|
|
11
|
-
from kleinkram.api.routes import
|
|
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
|
|
10
|
+
from kleinkram.api.routes import _create_mission as _create_mission_api
|
|
16
11
|
from kleinkram.config import get_shared_state
|
|
17
|
-
from kleinkram.errors import
|
|
18
|
-
from kleinkram.
|
|
12
|
+
from kleinkram.errors import MissionNotFound
|
|
13
|
+
from kleinkram.errors import ProjectNotFound
|
|
14
|
+
from kleinkram.models import Mission
|
|
15
|
+
from kleinkram.resources import check_mission_spec_is_creatable
|
|
16
|
+
from kleinkram.resources import get_missions_by_spec
|
|
17
|
+
from kleinkram.resources import get_projects_by_spec
|
|
18
|
+
from kleinkram.resources import InvalidMissionSpec
|
|
19
|
+
from kleinkram.resources import mission_spec_is_unique
|
|
20
|
+
from kleinkram.resources import MissionSpec
|
|
21
|
+
from kleinkram.resources import ProjectSpec
|
|
19
22
|
from kleinkram.utils import check_file_paths
|
|
20
23
|
from kleinkram.utils import get_filename_map
|
|
21
|
-
from kleinkram.utils import get_valid_mission_spec
|
|
22
24
|
from kleinkram.utils import load_metadata
|
|
23
|
-
from kleinkram.utils import
|
|
25
|
+
from kleinkram.utils import split_args
|
|
24
26
|
from rich.console import Console
|
|
25
27
|
|
|
26
28
|
|
|
@@ -36,6 +38,45 @@ upload_typer = typer.Typer(
|
|
|
36
38
|
)
|
|
37
39
|
|
|
38
40
|
|
|
41
|
+
# TODO: move this to core
|
|
42
|
+
def _create_mission(
|
|
43
|
+
client: AuthenticatedClient,
|
|
44
|
+
mission_spec: MissionSpec,
|
|
45
|
+
metadata_path: Optional[Path],
|
|
46
|
+
ignore_missing_tags: bool,
|
|
47
|
+
) -> Mission:
|
|
48
|
+
check_mission_spec_is_creatable(mission_spec)
|
|
49
|
+
mission_name = mission_spec.patterns[0]
|
|
50
|
+
|
|
51
|
+
# get the metadata
|
|
52
|
+
metadata_dct = {}
|
|
53
|
+
if metadata_path is not None:
|
|
54
|
+
metadata_dct = load_metadata(metadata_path)
|
|
55
|
+
|
|
56
|
+
# get project
|
|
57
|
+
projects = get_projects_by_spec(client, mission_spec.project_spec)
|
|
58
|
+
|
|
59
|
+
if not projects:
|
|
60
|
+
raise ProjectNotFound(f"project {mission_spec.project_spec} does not exist")
|
|
61
|
+
elif len(projects) > 1:
|
|
62
|
+
raise AssertionError("unreachable")
|
|
63
|
+
|
|
64
|
+
parsed_project = projects[0]
|
|
65
|
+
|
|
66
|
+
_create_mission_api(
|
|
67
|
+
client,
|
|
68
|
+
parsed_project.id,
|
|
69
|
+
mission_name,
|
|
70
|
+
metadata=metadata_dct,
|
|
71
|
+
ignore_missing_tags=ignore_missing_tags,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
missions = get_missions_by_spec(client, mission_spec)
|
|
75
|
+
assert len(missions) is not None, "unreachable, the ghost is back"
|
|
76
|
+
|
|
77
|
+
return missions[0]
|
|
78
|
+
|
|
79
|
+
|
|
39
80
|
@upload_typer.callback()
|
|
40
81
|
def upload(
|
|
41
82
|
files: List[str] = typer.Argument(help="files to upload"),
|
|
@@ -50,21 +91,13 @@ def upload(
|
|
|
50
91
|
fix_filenames: bool = typer.Option(False, help="fix filenames"),
|
|
51
92
|
ignore_missing_tags: bool = typer.Option(False, help="ignore mission tags"),
|
|
52
93
|
) -> 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
94
|
# check files and `fix` filenames
|
|
59
95
|
if files is None:
|
|
60
96
|
files = []
|
|
61
97
|
|
|
62
98
|
file_paths = [Path(file) for file in files]
|
|
63
99
|
check_file_paths(file_paths)
|
|
64
|
-
|
|
65
|
-
files_map = get_filename_map(
|
|
66
|
-
[Path(file) for file in files],
|
|
67
|
-
)
|
|
100
|
+
files_map = get_filename_map(file_paths)
|
|
68
101
|
|
|
69
102
|
if not fix_filenames:
|
|
70
103
|
for name, path in files_map.items():
|
|
@@ -73,51 +106,43 @@ def upload(
|
|
|
73
106
|
f"invalid filename format {path.name}, use `--fix-filenames`"
|
|
74
107
|
)
|
|
75
108
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
109
|
+
mission_ids, mission_patterns = split_args([mission])
|
|
110
|
+
project_ids, project_patterns = split_args([project] if project else [])
|
|
111
|
+
|
|
112
|
+
project_spec = ProjectSpec(ids=project_ids, patterns=project_patterns)
|
|
113
|
+
mission_spec = MissionSpec(
|
|
114
|
+
ids=mission_ids,
|
|
115
|
+
patterns=mission_patterns,
|
|
116
|
+
project_spec=project_spec,
|
|
117
|
+
)
|
|
79
118
|
|
|
80
|
-
if not
|
|
81
|
-
raise
|
|
119
|
+
if not mission_spec_is_unique(mission_spec):
|
|
120
|
+
raise InvalidMissionSpec(f"mission spec is not unique: {mission_spec}")
|
|
82
121
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if not isinstance(mission_spec, MissionByName):
|
|
86
|
-
raise ValueError(
|
|
87
|
-
"cannot create mission using mission id, pecify a mission name"
|
|
88
|
-
)
|
|
122
|
+
client = AuthenticatedClient()
|
|
123
|
+
missions = get_missions_by_spec(client, mission_spec)
|
|
89
124
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if metadata is not None:
|
|
93
|
-
metadata_dct = load_metadata(Path(metadata))
|
|
94
|
-
tags_dct = get_tags_map(client, metadata_dct)
|
|
125
|
+
if len(missions) > 1:
|
|
126
|
+
raise AssertionError("unreachable")
|
|
95
127
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
)
|
|
128
|
+
if not create and not missions:
|
|
129
|
+
raise MissionNotFound(f"mission: {mission_spec} does not exist, use `--create`")
|
|
105
130
|
|
|
106
|
-
|
|
131
|
+
# create mission if it does not exist
|
|
132
|
+
mission_parsed = (
|
|
133
|
+
missions[0]
|
|
134
|
+
if missions
|
|
135
|
+
else _create_mission(
|
|
107
136
|
client,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
tags=tags_dct,
|
|
137
|
+
mission_spec,
|
|
138
|
+
metadata_path=Path(metadata) if metadata else None,
|
|
111
139
|
ignore_missing_tags=ignore_missing_tags,
|
|
112
140
|
)
|
|
141
|
+
)
|
|
113
142
|
|
|
114
|
-
|
|
115
|
-
assert mission_parsed is not None, "unreachable"
|
|
116
|
-
|
|
143
|
+
console = Console()
|
|
117
144
|
filtered_files_map = {}
|
|
118
145
|
remote_file_names = [file.name for file in mission_parsed.files]
|
|
119
|
-
|
|
120
|
-
console = Console()
|
|
121
146
|
for name, path in files_map.items():
|
|
122
147
|
if name in remote_file_names:
|
|
123
148
|
console.print(
|
|
@@ -131,6 +156,7 @@ def upload(
|
|
|
131
156
|
return
|
|
132
157
|
|
|
133
158
|
upload_files(
|
|
159
|
+
client,
|
|
134
160
|
filtered_files_map,
|
|
135
161
|
mission_parsed.id,
|
|
136
162
|
n_workers=2,
|
kleinkram/commands/verify.py
CHANGED
|
@@ -4,20 +4,26 @@ import logging
|
|
|
4
4
|
import sys
|
|
5
5
|
from enum import Enum
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Dict
|
|
7
8
|
from typing import List
|
|
8
9
|
from typing import Optional
|
|
9
10
|
|
|
10
11
|
import typer
|
|
11
12
|
from kleinkram.api.client import AuthenticatedClient
|
|
12
|
-
from kleinkram.api.routes import get_mission_by_spec
|
|
13
13
|
from kleinkram.config import get_shared_state
|
|
14
|
-
from kleinkram.errors import
|
|
14
|
+
from kleinkram.errors import InvalidMissionSpec
|
|
15
|
+
from kleinkram.errors import MissionNotFound
|
|
15
16
|
from kleinkram.models import FileState
|
|
17
|
+
from kleinkram.resources import FileSpec
|
|
18
|
+
from kleinkram.resources import get_files_by_spec
|
|
19
|
+
from kleinkram.resources import get_missions_by_spec
|
|
20
|
+
from kleinkram.resources import mission_spec_is_unique
|
|
21
|
+
from kleinkram.resources import MissionSpec
|
|
22
|
+
from kleinkram.resources import ProjectSpec
|
|
16
23
|
from kleinkram.utils import b64_md5
|
|
17
24
|
from kleinkram.utils import check_file_paths
|
|
18
25
|
from kleinkram.utils import get_filename_map
|
|
19
|
-
from kleinkram.utils import
|
|
20
|
-
from kleinkram.utils import to_name_or_uuid
|
|
26
|
+
from kleinkram.utils import split_args
|
|
21
27
|
from rich.console import Console
|
|
22
28
|
from rich.table import Table
|
|
23
29
|
from rich.text import Text
|
|
@@ -29,8 +35,9 @@ logger = logging.getLogger(__name__)
|
|
|
29
35
|
class FileVerificationStatus(str, Enum):
|
|
30
36
|
UPLAODED = "uploaded"
|
|
31
37
|
UPLOADING = "uploading"
|
|
38
|
+
COMPUTING_HASH = "computing hash"
|
|
32
39
|
MISSING = "missing"
|
|
33
|
-
|
|
40
|
+
MISMATCHED_HASH = "hash mismatch"
|
|
34
41
|
UNKNOWN = "unknown"
|
|
35
42
|
|
|
36
43
|
|
|
@@ -38,8 +45,9 @@ FILE_STATUS_STYLES = {
|
|
|
38
45
|
FileVerificationStatus.UPLAODED: "green",
|
|
39
46
|
FileVerificationStatus.UPLOADING: "yellow",
|
|
40
47
|
FileVerificationStatus.MISSING: "yellow",
|
|
41
|
-
FileVerificationStatus.
|
|
48
|
+
FileVerificationStatus.MISMATCHED_HASH: "red",
|
|
42
49
|
FileVerificationStatus.UNKNOWN: "gray",
|
|
50
|
+
FileVerificationStatus.COMPUTING_HASH: "purple",
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
|
|
@@ -59,60 +67,75 @@ def verify(
|
|
|
59
67
|
mission: str = typer.Option(..., "--mission", "-m", help="mission id or name"),
|
|
60
68
|
skip_hash: bool = typer.Option(False, help="skip hash check"),
|
|
61
69
|
) -> None:
|
|
62
|
-
|
|
63
|
-
_mission = to_name_or_uuid(mission)
|
|
64
|
-
|
|
65
|
-
client = AuthenticatedClient()
|
|
66
|
-
|
|
70
|
+
# get all filepaths
|
|
67
71
|
if files is None:
|
|
68
72
|
files = []
|
|
69
73
|
|
|
70
|
-
mission_spec = get_valid_mission_spec(_mission, _project)
|
|
71
|
-
mission_parsed = get_mission_by_spec(client, mission_spec)
|
|
72
|
-
|
|
73
|
-
if mission_parsed is None:
|
|
74
|
-
raise MissionDoesNotExist(f"Mission {mission} does not exist")
|
|
75
|
-
|
|
76
|
-
# check file types
|
|
77
74
|
file_paths = [Path(file) for file in files]
|
|
78
75
|
check_file_paths(file_paths)
|
|
76
|
+
files_map = get_filename_map(file_paths)
|
|
77
|
+
|
|
78
|
+
# get the mission by the provided spec
|
|
79
|
+
mission_ids, mission_patterns = split_args([mission])
|
|
80
|
+
project_ids, project_patterns = split_args([project] if project else [])
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
project_spec = ProjectSpec(ids=project_ids, patterns=project_patterns)
|
|
83
|
+
mission_spec = MissionSpec(
|
|
84
|
+
ids=mission_ids, patterns=mission_patterns, project_spec=project_spec
|
|
85
|
+
)
|
|
86
|
+
file_spec = FileSpec(mission_spec=mission_spec)
|
|
82
87
|
|
|
83
|
-
|
|
88
|
+
client = AuthenticatedClient()
|
|
89
|
+
|
|
90
|
+
# check first that the mission even exists, the mission could be empty
|
|
91
|
+
if not mission_spec_is_unique(mission_spec):
|
|
92
|
+
raise InvalidMissionSpec(f"mission spec is not unique: {mission_spec}")
|
|
93
|
+
missions = get_missions_by_spec(client, mission_spec)
|
|
94
|
+
if len(missions) > 1:
|
|
95
|
+
raise AssertionError("unreachable")
|
|
96
|
+
if not missions:
|
|
97
|
+
raise MissionNotFound(f"mission: {mission_spec} does not exist")
|
|
98
|
+
|
|
99
|
+
# get all files from that mission
|
|
100
|
+
remote_files = {file.name: file for file in get_files_by_spec(client, file_spec)}
|
|
101
|
+
|
|
102
|
+
# verify files
|
|
103
|
+
file_status: Dict[Path, FileVerificationStatus] = {}
|
|
84
104
|
for name, file in tqdm(
|
|
85
|
-
|
|
105
|
+
files_map.items(),
|
|
86
106
|
desc="verifying files",
|
|
87
107
|
unit="file",
|
|
88
|
-
disable=
|
|
108
|
+
disable=not get_shared_state().verbose,
|
|
89
109
|
):
|
|
90
110
|
if name not in remote_files:
|
|
91
|
-
|
|
111
|
+
file_status[file] = FileVerificationStatus.MISSING
|
|
92
112
|
continue
|
|
93
113
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if state == FileState.UPLOADING:
|
|
97
|
-
|
|
98
|
-
elif state == FileState.OK
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
114
|
+
remote_file = remote_files[name]
|
|
115
|
+
|
|
116
|
+
if remote_file.state == FileState.UPLOADING:
|
|
117
|
+
file_status[file] = FileVerificationStatus.UPLOADING
|
|
118
|
+
elif remote_file.state == FileState.OK:
|
|
119
|
+
if remote_file.hash is None:
|
|
120
|
+
file_status[file] = FileVerificationStatus.COMPUTING_HASH
|
|
121
|
+
elif skip_hash or remote_file.hash == b64_md5(file):
|
|
122
|
+
file_status[file] = FileVerificationStatus.UPLAODED
|
|
123
|
+
else:
|
|
124
|
+
file_status[file] = FileVerificationStatus.MISMATCHED_HASH
|
|
102
125
|
else:
|
|
103
|
-
|
|
126
|
+
file_status[file] = FileVerificationStatus.UNKNOWN
|
|
104
127
|
|
|
105
128
|
if get_shared_state().verbose:
|
|
106
129
|
table = Table(title="file status")
|
|
107
130
|
table.add_column("filename", style="cyan")
|
|
108
131
|
table.add_column("status", style="green")
|
|
109
132
|
|
|
110
|
-
for path, status in
|
|
133
|
+
for path, status in file_status.items():
|
|
111
134
|
table.add_row(str(path), Text(status, style=FILE_STATUS_STYLES[status]))
|
|
112
135
|
|
|
113
136
|
Console().print(table)
|
|
114
137
|
else:
|
|
115
|
-
for path, status in
|
|
138
|
+
for path, status in file_status.items():
|
|
116
139
|
stream = (
|
|
117
140
|
sys.stdout if status == FileVerificationStatus.UPLAODED else sys.stderr
|
|
118
141
|
)
|
kleinkram/config.py
CHANGED
|
@@ -12,7 +12,6 @@ from typing import Optional
|
|
|
12
12
|
|
|
13
13
|
from kleinkram._version import __local__
|
|
14
14
|
from kleinkram._version import __version__
|
|
15
|
-
from kleinkram.errors import CorruptedConfigFile
|
|
16
15
|
from kleinkram.errors import InvalidConfigFile
|
|
17
16
|
|
|
18
17
|
CONFIG_PATH = Path().home() / ".kleinkram.json"
|
|
@@ -71,7 +70,7 @@ class Config:
|
|
|
71
70
|
|
|
72
71
|
try:
|
|
73
72
|
self._read_config()
|
|
74
|
-
except
|
|
73
|
+
except InvalidConfigFile:
|
|
75
74
|
if not overwrite:
|
|
76
75
|
self.credentials = {}
|
|
77
76
|
self.endpoint = default_endpoint
|
|
@@ -84,7 +83,7 @@ class Config:
|
|
|
84
83
|
try:
|
|
85
84
|
content = json.load(file)
|
|
86
85
|
except Exception:
|
|
87
|
-
raise
|
|
86
|
+
raise InvalidConfigFile
|
|
88
87
|
|
|
89
88
|
endpoint = content.get(JSON_ENDPOINT_KEY, None)
|
|
90
89
|
if not isinstance(endpoint, str):
|
kleinkram/errors.py
CHANGED
|
@@ -1,65 +1,82 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
from typing import Any
|
|
4
|
+
from typing import Callable
|
|
5
|
+
from typing import OrderedDict
|
|
6
|
+
from typing import Type
|
|
4
7
|
|
|
8
|
+
import typer
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
LOGIN_MESSAGE = "Please login using `klein login`."
|
|
11
|
+
INVALID_CONFIG_MESSAGE = "Invalid config file."
|
|
7
12
|
|
|
8
13
|
|
|
9
|
-
class
|
|
14
|
+
class ParsingError(Exception): ...
|
|
10
15
|
|
|
11
16
|
|
|
12
|
-
class
|
|
17
|
+
class InvalidMissionSpec(Exception): ...
|
|
13
18
|
|
|
14
19
|
|
|
15
|
-
class
|
|
20
|
+
class InvalidProjectSpec(Exception): ...
|
|
16
21
|
|
|
17
22
|
|
|
18
|
-
class
|
|
23
|
+
class MissionExists(Exception): ...
|
|
19
24
|
|
|
20
25
|
|
|
21
|
-
class
|
|
26
|
+
class MissionNotFound(Exception): ...
|
|
22
27
|
|
|
23
28
|
|
|
24
|
-
class
|
|
25
|
-
def __init__(self, message: str, api_error: str):
|
|
26
|
-
self.message = message
|
|
27
|
-
self.api_error = api_error
|
|
29
|
+
class ProjectNotFound(Exception): ...
|
|
28
30
|
|
|
29
31
|
|
|
30
|
-
class
|
|
31
|
-
def __init__(self, endpoint: str):
|
|
32
|
-
message = (
|
|
33
|
-
f"You are not authenticated on endpoint '{endpoint}'.\n{LOGIN_MESSAGE}"
|
|
34
|
-
)
|
|
35
|
-
super().__init__(message)
|
|
32
|
+
class AccessDenied(Exception): ...
|
|
36
33
|
|
|
37
34
|
|
|
38
|
-
class
|
|
35
|
+
class NotAuthenticated(Exception):
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
super().__init__(LOGIN_MESSAGE)
|
|
39
38
|
|
|
40
39
|
|
|
41
|
-
class
|
|
40
|
+
class InvalidCLIVersion(Exception): ...
|
|
42
41
|
|
|
43
42
|
|
|
44
|
-
class
|
|
43
|
+
class FileTypeNotSupported(Exception): ...
|
|
45
44
|
|
|
46
45
|
|
|
47
|
-
class
|
|
46
|
+
class InvalidConfigFile(Exception):
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
super().__init__(INVALID_CONFIG_MESSAGE)
|
|
48
49
|
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
ExceptionHandler = Callable[[Exception], int]
|
|
51
52
|
|
|
52
53
|
|
|
53
|
-
class
|
|
54
|
+
class ErrorHandledTyper(typer.Typer):
|
|
55
|
+
"""\
|
|
56
|
+
error handlers that are last added will be used first
|
|
57
|
+
"""
|
|
54
58
|
|
|
59
|
+
_error_handlers: OrderedDict[Type[Exception], ExceptionHandler]
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
def error_handler(
|
|
62
|
+
self, exc: type[Exception]
|
|
63
|
+
) -> Callable[[ExceptionHandler], ExceptionHandler]:
|
|
64
|
+
def dec(func: ExceptionHandler) -> ExceptionHandler:
|
|
65
|
+
self._error_handlers[exc] = func
|
|
66
|
+
return func
|
|
59
67
|
|
|
68
|
+
return dec
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
71
|
+
super().__init__(*args, **kwargs)
|
|
72
|
+
self._error_handlers = OrderedDict()
|
|
73
|
+
|
|
74
|
+
def __call__(self, *args: Any, **kwargs: Any) -> int:
|
|
75
|
+
try:
|
|
76
|
+
return super().__call__(*args, **kwargs)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
for tp, handler in reversed(self._error_handlers.items()):
|
|
79
|
+
if isinstance(e, tp):
|
|
80
|
+
exit_code = handler(e)
|
|
81
|
+
raise SystemExit(exit_code)
|
|
82
|
+
raise
|
kleinkram/models.py
CHANGED
|
@@ -14,7 +14,7 @@ from rich.table import Table
|
|
|
14
14
|
from rich.text import Text
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
@dataclass(
|
|
17
|
+
@dataclass(eq=True)
|
|
18
18
|
class Project:
|
|
19
19
|
id: UUID
|
|
20
20
|
name: str
|
|
@@ -22,7 +22,7 @@ class Project:
|
|
|
22
22
|
missions: List[Mission] = field(default_factory=list)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
@dataclass(
|
|
25
|
+
@dataclass(eq=True)
|
|
26
26
|
class Mission:
|
|
27
27
|
id: UUID
|
|
28
28
|
name: str
|
kleinkram/resources.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import field
|
|
6
|
+
from itertools import chain
|
|
7
|
+
from typing import List
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from kleinkram.api.client import AuthenticatedClient
|
|
11
|
+
from kleinkram.api.routes import _get_files_by_mission
|
|
12
|
+
from kleinkram.api.routes import _get_missions_by_project
|
|
13
|
+
from kleinkram.api.routes import _get_projects
|
|
14
|
+
from kleinkram.errors import InvalidMissionSpec
|
|
15
|
+
from kleinkram.errors import InvalidProjectSpec
|
|
16
|
+
from kleinkram.models import File
|
|
17
|
+
from kleinkram.models import Mission
|
|
18
|
+
from kleinkram.models import Project
|
|
19
|
+
from kleinkram.utils import filtered_by_patterns
|
|
20
|
+
|
|
21
|
+
MAX_PARALLEL_REQUESTS = 32
|
|
22
|
+
SPECIAL_PATTERN_CHARS = ["*", "?", "[", "]"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ProjectSpec:
|
|
27
|
+
patterns: List[str] = field(default_factory=list)
|
|
28
|
+
ids: List[UUID] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class MissionSpec:
|
|
33
|
+
patterns: List[str] = field(default_factory=list)
|
|
34
|
+
ids: List[UUID] = field(default_factory=list)
|
|
35
|
+
project_spec: ProjectSpec = field(default=ProjectSpec())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class FileSpec:
|
|
40
|
+
patterns: List[str] = field(default_factory=list)
|
|
41
|
+
ids: List[UUID] = field(default_factory=list)
|
|
42
|
+
mission_spec: MissionSpec = field(default=MissionSpec())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_mission_spec_is_creatable(spec: MissionSpec) -> None:
|
|
46
|
+
if not mission_spec_is_unique(spec):
|
|
47
|
+
raise InvalidMissionSpec(f"Mission spec is not unique: {spec}")
|
|
48
|
+
# cant create a missing by id
|
|
49
|
+
if spec.ids:
|
|
50
|
+
raise InvalidMissionSpec(f"cant create mission by id: {spec}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_project_spec_is_creatable(spec: ProjectSpec) -> None:
|
|
54
|
+
if not project_spec_is_unique(spec):
|
|
55
|
+
raise InvalidProjectSpec(f"Project spec is not unique: {spec}")
|
|
56
|
+
# cant create a missing by id
|
|
57
|
+
if spec.ids:
|
|
58
|
+
raise InvalidProjectSpec(f"cant create project by id: {spec}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _pattern_is_unique(pattern: str) -> bool:
|
|
62
|
+
for char in SPECIAL_PATTERN_CHARS:
|
|
63
|
+
if char in pattern:
|
|
64
|
+
return False
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def project_spec_is_unique(spec: ProjectSpec) -> bool:
|
|
69
|
+
# a single project id is specified
|
|
70
|
+
if len(spec.ids) == 1 and not spec.patterns:
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
# a single project name is specified
|
|
74
|
+
if len(spec.patterns) == 1 and _pattern_is_unique(spec.patterns[0]):
|
|
75
|
+
return True
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def mission_spec_is_unique(spec: MissionSpec) -> bool:
|
|
80
|
+
# a single mission id is specified
|
|
81
|
+
if len(spec.ids) == 1 and not spec.patterns:
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
# a single mission name a unique project spec are specified
|
|
85
|
+
if (
|
|
86
|
+
project_spec_is_unique(spec.project_spec)
|
|
87
|
+
and len(spec.patterns) == 1
|
|
88
|
+
and _pattern_is_unique(spec.patterns[0])
|
|
89
|
+
):
|
|
90
|
+
return True
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_projects_by_spec(
|
|
95
|
+
client: AuthenticatedClient, spec: ProjectSpec
|
|
96
|
+
) -> List[Project]:
|
|
97
|
+
projects = _get_projects(client)
|
|
98
|
+
|
|
99
|
+
matched_names = filtered_by_patterns(
|
|
100
|
+
[project.name for project in projects], spec.patterns
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not spec.patterns and not spec.ids:
|
|
104
|
+
return projects
|
|
105
|
+
|
|
106
|
+
return [
|
|
107
|
+
project
|
|
108
|
+
for project in projects
|
|
109
|
+
if project.name in matched_names or project.id in spec.ids
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_missions_by_spec(
|
|
114
|
+
client: AuthenticatedClient, spec: MissionSpec
|
|
115
|
+
) -> List[Mission]:
|
|
116
|
+
projects = get_projects_by_spec(client, spec.project_spec)
|
|
117
|
+
|
|
118
|
+
with ThreadPoolExecutor(max_workers=MAX_PARALLEL_REQUESTS) as executor:
|
|
119
|
+
missions = chain.from_iterable(
|
|
120
|
+
executor.map(
|
|
121
|
+
lambda project: _get_missions_by_project(client, project), projects
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
missions = list(missions)
|
|
126
|
+
|
|
127
|
+
if not spec.patterns and not spec.ids:
|
|
128
|
+
return list(missions)
|
|
129
|
+
|
|
130
|
+
matched_names = filtered_by_patterns(
|
|
131
|
+
[mission.name for mission in missions], spec.patterns
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
filter = [
|
|
135
|
+
mission
|
|
136
|
+
for mission in missions
|
|
137
|
+
if mission.name in matched_names or mission.id in spec.patterns
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
return filter
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_files_by_spec(client: AuthenticatedClient, spec: FileSpec) -> List[File]:
|
|
144
|
+
missions = get_missions_by_spec(client, spec.mission_spec)
|
|
145
|
+
|
|
146
|
+
# collect files
|
|
147
|
+
with ThreadPoolExecutor(max_workers=MAX_PARALLEL_REQUESTS) as executor:
|
|
148
|
+
files = chain.from_iterable(
|
|
149
|
+
executor.map(
|
|
150
|
+
lambda mission: _get_files_by_mission(client, mission), missions
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if not spec.patterns and not spec.ids:
|
|
155
|
+
return list(files)
|
|
156
|
+
matched_names = filtered_by_patterns([file.name for file in files], spec.patterns)
|
|
157
|
+
|
|
158
|
+
return [file for file in files if file.name in matched_names or file.id in spec.ids]
|