kleinkram 0.38.1.dev20241119134715__py3-none-any.whl → 0.38.1.dev20241125112529__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.

@@ -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 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
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 MissionDoesNotExist
18
- from kleinkram.models import MissionByName
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 to_name_or_uuid
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
- # 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)
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 create and mission_parsed is None:
81
- raise MissionDoesNotExist(f"mission: {mission} does not exist, use `--create`")
119
+ if not mission_spec_is_unique(mission_spec):
120
+ raise InvalidMissionSpec(f"mission spec is not unique: {mission_spec}")
82
121
 
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
- )
122
+ client = AuthenticatedClient()
123
+ missions = get_missions_by_spec(client, mission_spec)
89
124
 
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)
125
+ if len(missions) > 1:
126
+ raise AssertionError("unreachable")
95
127
 
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
- )
128
+ if not create and not missions:
129
+ raise MissionNotFound(f"mission: {mission_spec} does not exist, use `--create`")
105
130
 
106
- mission_id = create_mission(
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
- project_id,
109
- mission_spec.name,
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
- mission_parsed = get_mission_by_id(client, mission_id)
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,
@@ -1,33 +1,43 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import sys
4
5
  from enum import Enum
5
6
  from pathlib import Path
7
+ from typing import Dict
6
8
  from typing import List
7
9
  from typing import Optional
8
10
 
9
11
  import typer
10
12
  from kleinkram.api.client import AuthenticatedClient
11
- from kleinkram.api.routes import get_mission_by_spec
12
13
  from kleinkram.config import get_shared_state
13
- from kleinkram.errors import MissionDoesNotExist
14
+ from kleinkram.errors import InvalidMissionSpec
15
+ from kleinkram.errors import MissionNotFound
14
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
15
23
  from kleinkram.utils import b64_md5
16
24
  from kleinkram.utils import check_file_paths
17
25
  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
26
+ from kleinkram.utils import split_args
20
27
  from rich.console import Console
21
28
  from rich.table import Table
22
29
  from rich.text import Text
23
30
  from tqdm import tqdm
24
31
 
32
+ logger = logging.getLogger(__name__)
33
+
25
34
 
26
35
  class FileVerificationStatus(str, Enum):
27
36
  UPLAODED = "uploaded"
28
37
  UPLOADING = "uploading"
38
+ COMPUTING_HASH = "computing hash"
29
39
  MISSING = "missing"
30
- CORRUPTED = "hash mismatch"
40
+ MISMATCHED_HASH = "hash mismatch"
31
41
  UNKNOWN = "unknown"
32
42
 
33
43
 
@@ -35,8 +45,9 @@ FILE_STATUS_STYLES = {
35
45
  FileVerificationStatus.UPLAODED: "green",
36
46
  FileVerificationStatus.UPLOADING: "yellow",
37
47
  FileVerificationStatus.MISSING: "yellow",
38
- FileVerificationStatus.CORRUPTED: "red",
48
+ FileVerificationStatus.MISMATCHED_HASH: "red",
39
49
  FileVerificationStatus.UNKNOWN: "gray",
50
+ FileVerificationStatus.COMPUTING_HASH: "purple",
40
51
  }
41
52
 
42
53
 
@@ -56,61 +67,75 @@ def verify(
56
67
  mission: str = typer.Option(..., "--mission", "-m", help="mission id or name"),
57
68
  skip_hash: bool = typer.Option(False, help="skip hash check"),
58
69
  ) -> None:
59
- _project = to_name_or_uuid(project) if project else None
60
- _mission = to_name_or_uuid(mission)
61
-
62
- client = AuthenticatedClient()
63
-
70
+ # get all filepaths
64
71
  if files is None:
65
72
  files = []
66
73
 
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
74
  file_paths = [Path(file) for file in files]
75
75
  check_file_paths(file_paths)
76
+ files_map = get_filename_map(file_paths)
76
77
 
77
- filename_map = get_filename_map(file_paths)
78
- remote_files = {file.name: file for file in mission_parsed.files}
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
- status_dct = {}
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)
87
+
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] = {}
81
104
  for name, file in tqdm(
82
- filename_map.items(),
105
+ files_map.items(),
83
106
  desc="verifying files",
84
107
  unit="file",
85
- disable=skip_hash or not get_shared_state().verbose,
108
+ disable=not get_shared_state().verbose,
86
109
  ):
87
110
  if name not in remote_files:
88
- status_dct[file] = FileVerificationStatus.MISSING
111
+ file_status[file] = FileVerificationStatus.MISSING
89
112
  continue
90
113
 
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
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
99
125
  else:
100
- status_dct[file] = FileVerificationStatus.UNKNOWN
126
+ file_status[file] = FileVerificationStatus.UNKNOWN
101
127
 
102
128
  if get_shared_state().verbose:
103
129
  table = Table(title="file status")
104
130
  table.add_column("filename", style="cyan")
105
131
  table.add_column("status", style="green")
106
132
 
107
- for path, status in status_dct.items():
133
+ for path, status in file_status.items():
108
134
  table.add_row(str(path), Text(status, style=FILE_STATUS_STYLES[status]))
109
135
 
110
- console = Console()
111
- console.print(table)
136
+ Console().print(table)
112
137
  else:
113
- for path, status in status_dct.items():
138
+ for path, status in file_status.items():
114
139
  stream = (
115
140
  sys.stdout if status == FileVerificationStatus.UPLAODED else sys.stderr
116
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 (InvalidConfigFile, CorruptedConfigFile):
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 CorruptedConfigFile
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,59 +1,82 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import Any
4
+ from typing import Callable
5
+ from typing import OrderedDict
6
+ from typing import Type
7
+
8
+ import typer
9
+
3
10
  LOGIN_MESSAGE = "Please login using `klein login`."
11
+ INVALID_CONFIG_MESSAGE = "Invalid config file."
12
+
13
+
14
+ class ParsingError(Exception): ...
4
15
 
5
16
 
6
17
  class InvalidMissionSpec(Exception): ...
7
18
 
8
19
 
9
- class InvalidFileSpec(Exception): ...
20
+ class InvalidProjectSpec(Exception): ...
10
21
 
11
22
 
12
23
  class MissionExists(Exception): ...
13
24
 
14
25
 
15
- class MissionDoesNotExist(Exception): ...
26
+ class MissionNotFound(Exception): ...
16
27
 
17
28
 
18
- class NoPermission(Exception): ...
29
+ class ProjectNotFound(Exception): ...
19
30
 
20
31
 
21
- class AccessDeniedException(Exception):
22
- def __init__(self, message: str, api_error: str):
23
- self.message = message
24
- self.api_error = api_error
32
+ class AccessDenied(Exception): ...
25
33
 
26
34
 
27
- class NotAuthenticatedException(Exception):
28
- def __init__(self, endpoint: str):
29
- message = (
30
- f"You are not authenticated on endpoint '{endpoint}'.\n{LOGIN_MESSAGE}"
31
- )
32
- super().__init__(message)
35
+ class NotAuthenticated(Exception):
36
+ def __init__(self) -> None:
37
+ super().__init__(LOGIN_MESSAGE)
33
38
 
34
39
 
35
- class CorruptedFile(Exception): ...
40
+ class InvalidCLIVersion(Exception): ...
36
41
 
37
42
 
38
- class NameIsValidUUID(Exception): ...
43
+ class FileTypeNotSupported(Exception): ...
39
44
 
40
45
 
41
- class UploadFailed(Exception): ...
46
+ class InvalidConfigFile(Exception):
47
+ def __init__(self) -> None:
48
+ super().__init__(INVALID_CONFIG_MESSAGE)
42
49
 
43
50
 
44
- class InvalidCLIVersion(Exception): ...
51
+ ExceptionHandler = Callable[[Exception], int]
45
52
 
46
53
 
47
- class FileTypeNotSupported(Exception): ...
54
+ class ErrorHandledTyper(typer.Typer):
55
+ """\
56
+ error handlers that are last added will be used first
57
+ """
48
58
 
59
+ _error_handlers: OrderedDict[Type[Exception], ExceptionHandler]
49
60
 
50
- class InvalidConfigFile(Exception):
51
- def __init__(self) -> None:
52
- super().__init__("Invalid config file.")
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
53
67
 
68
+ return dec
54
69
 
55
- class CorruptedConfigFile(Exception):
56
- def __init__(self) -> None:
57
- super().__init__(
58
- "Config file is corrupted.\nPlease run `klein login` to re-authenticate."
59
- )
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(frozen=True, eq=True)
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(frozen=True, eq=True)
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]