kleinkram 0.38.1.dev20241120100707__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.

kleinkram/app.py CHANGED
@@ -1,24 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- import os
5
4
  import sys
6
5
  import time
7
- from collections import OrderedDict
8
6
  from enum import Enum
9
7
  from pathlib import Path
10
- from typing import Any
11
- from typing import Callable
12
8
  from typing import List
13
9
  from typing import Optional
14
- from typing import Type
15
10
 
16
11
  import typer
17
12
  from click import Context
18
13
  from kleinkram._version import __version__
19
14
  from kleinkram.api.client import AuthenticatedClient
20
- from kleinkram.api.routes import claim_admin
21
- from kleinkram.api.routes import get_api_version
15
+ from kleinkram.api.routes import _claim_admin
16
+ from kleinkram.api.routes import _get_api_version
22
17
  from kleinkram.auth import login_flow
23
18
  from kleinkram.commands.download import download_typer
24
19
  from kleinkram.commands.endpoint import endpoint_typer
@@ -29,6 +24,7 @@ from kleinkram.commands.upload import upload_typer
29
24
  from kleinkram.commands.verify import verify_typer
30
25
  from kleinkram.config import Config
31
26
  from kleinkram.config import get_shared_state
27
+ from kleinkram.errors import ErrorHandledTyper
32
28
  from kleinkram.errors import InvalidCLIVersion
33
29
  from kleinkram.utils import format_traceback
34
30
  from kleinkram.utils import get_supported_api_version
@@ -73,40 +69,6 @@ class OrderCommands(TyperGroup):
73
69
  return list(self.commands)
74
70
 
75
71
 
76
- ExceptionHandler = Callable[[Exception], int]
77
-
78
-
79
- class ErrorHandledTyper(typer.Typer):
80
- """\
81
- error handlers that are last added will be used first
82
- """
83
-
84
- _error_handlers: OrderedDict[Type[Exception], ExceptionHandler]
85
-
86
- def error_handler(
87
- self, exc: type[Exception]
88
- ) -> Callable[[ExceptionHandler], ExceptionHandler]:
89
- def dec(func: ExceptionHandler) -> ExceptionHandler:
90
- self._error_handlers[exc] = func
91
- return func
92
-
93
- return dec
94
-
95
- def __init__(self, *args: Any, **kwargs: Any) -> None:
96
- super().__init__(*args, **kwargs)
97
- self._error_handlers = OrderedDict()
98
-
99
- def __call__(self, *args: Any, **kwargs: Any) -> int:
100
- try:
101
- return super().__call__(*args, **kwargs)
102
- except Exception as e:
103
- for tp, handler in reversed(self._error_handlers.items()):
104
- if isinstance(e, tp):
105
- exit_code = handler(e)
106
- raise SystemExit(exit_code)
107
- raise
108
-
109
-
110
72
  app = ErrorHandledTyper(
111
73
  cls=OrderCommands,
112
74
  help=CLI_HELP,
@@ -114,7 +76,16 @@ app = ErrorHandledTyper(
114
76
  no_args_is_help=True,
115
77
  )
116
78
 
79
+ app.add_typer(download_typer, name="download", rich_help_panel=CommandTypes.CORE)
80
+ app.add_typer(upload_typer, name="upload", rich_help_panel=CommandTypes.CORE)
81
+ app.add_typer(verify_typer, name="verify", rich_help_panel=CommandTypes.CORE)
82
+ app.add_typer(list_typer, name="list", rich_help_panel=CommandTypes.CORE)
83
+ app.add_typer(endpoint_typer, name="endpoint", rich_help_panel=CommandTypes.AUTH)
84
+ app.add_typer(mission_typer, name="mission", rich_help_panel=CommandTypes.CRUD)
85
+ app.add_typer(project_typer, name="project", rich_help_panel=CommandTypes.CRUD)
117
86
 
87
+
88
+ # attach error handler to app
118
89
  @app.error_handler(Exception)
119
90
  def base_handler(exc: Exception) -> int:
120
91
  if not get_shared_state().debug:
@@ -124,15 +95,6 @@ def base_handler(exc: Exception) -> int:
124
95
  raise exc
125
96
 
126
97
 
127
- app.add_typer(download_typer, name="download", rich_help_panel=CommandTypes.CORE)
128
- app.add_typer(upload_typer, name="upload", rich_help_panel=CommandTypes.CORE)
129
- app.add_typer(verify_typer, name="verify", rich_help_panel=CommandTypes.CORE)
130
- app.add_typer(list_typer, name="list", rich_help_panel=CommandTypes.CORE)
131
- app.add_typer(endpoint_typer, name="endpoint", rich_help_panel=CommandTypes.AUTH)
132
- app.add_typer(mission_typer, name="mission", rich_help_panel=CommandTypes.CRUD)
133
- app.add_typer(project_typer, name="project", rich_help_panel=CommandTypes.CRUD)
134
-
135
-
136
98
  @app.command(rich_help_panel=CommandTypes.AUTH)
137
99
  def login(
138
100
  key: Optional[str] = typer.Option(None, help="CLI key"),
@@ -150,7 +112,7 @@ def logout(all: bool = typer.Option(False, help="logout on all enpoints")) -> No
150
112
  @app.command(hidden=True)
151
113
  def claim():
152
114
  client = AuthenticatedClient()
153
- claim_admin(client)
115
+ _claim_admin(client)
154
116
  print("admin rights claimed successfully.")
155
117
 
156
118
 
@@ -162,7 +124,7 @@ def _version_callback(value: bool) -> None:
162
124
 
163
125
  def check_version_compatiblity() -> None:
164
126
  cli_version = get_supported_api_version()
165
- api_version = get_api_version()
127
+ api_version = _get_api_version()
166
128
  api_vers_str = ".".join(map(str, api_version))
167
129
 
168
130
  if cli_version[0] != api_version[0]:
@@ -197,6 +159,8 @@ def cli(
197
159
  LOG_DIR.mkdir(parents=True, exist_ok=True)
198
160
  level = logging.getLevelName(log_level)
199
161
  logging.basicConfig(level=level, filename=LOG_FILE, format=LOG_FORMAT)
162
+ else:
163
+ logging.disable(logging.CRITICAL)
200
164
 
201
165
  logger.info(f"CLI version: {__version__}")
202
166
 
@@ -206,6 +170,8 @@ def cli(
206
170
  logger.error(format_traceback(e))
207
171
  raise
208
172
  except Exception:
209
- err = "failed to check version compatibility"
210
- Console(file=sys.stderr).print(err, style="yellow")
173
+ err = ("failed to check version compatibility",)
174
+ Console(file=sys.stderr).print(
175
+ err, style="yellow" if shared_state.verbose else None
176
+ )
211
177
  logger.error(err)
kleinkram/auth.py CHANGED
@@ -9,9 +9,7 @@ from typing import Optional
9
9
 
10
10
  from kleinkram.config import Config
11
11
  from kleinkram.config import CONFIG_PATH
12
- from kleinkram.config import CorruptedConfigFile
13
12
  from kleinkram.config import Credentials
14
- from kleinkram.config import InvalidConfigFile
15
13
 
16
14
  CLI_CALLBACK_ENDPOINT = "/cli/callback"
17
15
  OAUTH_SLUG = "/auth/google?state=cli"
@@ -1,24 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
- import sys
3
+ import logging
4
4
  from pathlib import Path
5
5
  from typing import List
6
6
  from typing import Optional
7
7
 
8
8
  import typer
9
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
10
+ from kleinkram.api.file_transfer import download_files
12
11
  from kleinkram.config import get_shared_state
13
- from kleinkram.models import FILE_STATE_COLOR
14
12
  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
13
+ from kleinkram.resources import FileSpec
14
+ from kleinkram.resources import get_files_by_spec
15
+ from kleinkram.resources import MissionSpec
16
+ from kleinkram.resources import ProjectSpec
17
+ from kleinkram.utils import split_args
19
18
  from rich.console import Console
20
19
 
21
20
 
21
+ logger = logging.getLogger(__name__)
22
+
22
23
  HELP = """\
23
24
  Download files from kleinkram.
24
25
  """
@@ -34,70 +35,69 @@ def download(
34
35
  files: Optional[List[str]] = typer.Argument(
35
36
  None, help="file names, ids or patterns"
36
37
  ),
37
- project: Optional[str] = typer.Option(
38
- None, "--project", "-p", help="project name or id"
38
+ projects: Optional[List[str]] = typer.Option(
39
+ None, "--project", "-p", help="project names, ids or patterns"
39
40
  ),
40
- mission: Optional[str] = typer.Option(
41
- None, "--mission", "-m", help="mission name or id"
41
+ missions: Optional[List[str]] = typer.Option(
42
+ None, "--mission", "-m", help="mission names, ids or patterns"
42
43
  ),
43
44
  dest: str = typer.Option(prompt="destination", help="local path to save the files"),
45
+ nested: bool = typer.Option(
46
+ False, help="save files in nested directories, project-name/mission-name"
47
+ ),
48
+ overwrite: bool = typer.Option(
49
+ False, help="overwrite files if they already exist and don't match the filehash"
50
+ ),
44
51
  ) -> 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
52
  # create destionation directory
50
53
  dest_dir = Path(dest)
51
-
52
54
  if not dest_dir.exists():
53
55
  typer.confirm(f"Destination {dest_dir} does not exist. Create it?", abort=True)
54
-
55
56
  dest_dir.mkdir(parents=True, exist_ok=True)
56
57
 
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)
58
+ # get file spec
59
+ file_ids, file_patterns = split_args(files or [])
60
+ mission_ids, mission_patterns = split_args(missions or [])
61
+ project_ids, project_patterns = split_args(projects or [])
62
+
63
+ project_spec = ProjectSpec(patterns=project_patterns, ids=project_ids)
64
+ mission_spec = MissionSpec(
65
+ patterns=mission_patterns,
66
+ ids=mission_ids,
67
+ project_spec=project_spec,
68
+ )
69
+ file_spec = FileSpec(
70
+ patterns=file_patterns, ids=file_ids, mission_spec=mission_spec
71
+ )
60
72
 
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
- )
73
+ client = AuthenticatedClient()
74
+ parsed_files = get_files_by_spec(client, file_spec)
66
75
 
67
- console = Console()
68
76
  if get_shared_state().verbose:
69
77
  table = files_to_table(parsed_files, title="downloading files...")
70
- console.print(table)
78
+ Console().print(table)
71
79
 
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)}")
80
+ # get paths to files map
81
+ if (
82
+ len(set([(file.project_id, file.mission_id) for file in parsed_files])) > 1
83
+ and not nested
84
+ ):
85
+ raise ValueError(
86
+ "files from multiple missions were selected, consider using `--nested`"
87
+ )
88
+ elif not nested:
89
+ # flat structure
90
+ paths_to_files = {dest_dir / file.name: file for file in parsed_files}
91
+ else:
92
+ # allow for nested directories
93
+ paths_to_files = {}
94
+ for file in parsed_files:
95
+ paths_to_files[
96
+ dest_dir / file.project_name / file.mission_name / file.name
97
+ ] = file
98
+
99
+ # download files
100
+ logger.info(f"downloading {paths_to_files} files to {dest_dir}")
101
+ download_files(
102
+ client, paths_to_files, verbose=get_shared_state().verbose, overwrite=overwrite
103
+ )
@@ -5,17 +5,18 @@ from typing import Optional
5
5
 
6
6
  import typer
7
7
  from kleinkram.api.client import AuthenticatedClient
8
- from kleinkram.api.routes import get_files_by_file_spec
9
- from kleinkram.api.routes import get_missions
10
- from kleinkram.api.routes import get_projects
11
8
  from kleinkram.config import get_shared_state
12
9
  from kleinkram.models import files_to_table
13
10
  from kleinkram.models import missions_to_table
14
11
  from kleinkram.models import projects_to_table
15
- from kleinkram.utils import get_valid_file_spec
16
- from kleinkram.utils import to_name_or_uuid
12
+ from kleinkram.resources import FileSpec
13
+ from kleinkram.resources import get_files_by_spec
14
+ from kleinkram.resources import get_missions_by_spec
15
+ from kleinkram.resources import get_projects_by_spec
16
+ from kleinkram.resources import MissionSpec
17
+ from kleinkram.resources import ProjectSpec
18
+ from kleinkram.utils import split_args
17
19
  from rich.console import Console
18
- from typer import BadParameter
19
20
 
20
21
 
21
22
  HELP = """\
@@ -28,75 +29,79 @@ list_typer = typer.Typer(
28
29
  )
29
30
 
30
31
 
31
- def _parse_metadata(raw: List[str]) -> dict:
32
- ret = {}
33
- for tag in raw:
34
- if "=" not in tag:
35
- raise BadParameter("tag must be formatted as `key=value`")
36
- k, v = tag.split("=")
37
- ret[k] = v
38
- return ret
39
-
40
-
41
32
  @list_typer.command()
42
33
  def files(
43
34
  files: Optional[List[str]] = typer.Argument(
44
- None, help="file names, ids or patterns"
35
+ None,
36
+ help="file names, ids or patterns",
45
37
  ),
46
- project: Optional[str] = typer.Option(
38
+ projects: Optional[List[str]] = typer.Option(
47
39
  None, "--project", "-p", help="project name or id"
48
40
  ),
49
- mission: Optional[str] = typer.Option(
41
+ missions: Optional[List[str]] = typer.Option(
50
42
  None, "--mission", "-m", help="mission name or id"
51
43
  ),
52
44
  ) -> None:
53
- client = AuthenticatedClient()
54
-
55
- _files = [to_name_or_uuid(f) for f in files or []]
56
- _project = to_name_or_uuid(project) if project else None
57
- _mission = to_name_or_uuid(mission) if mission else None
45
+ file_ids, file_patterns = split_args(files or [])
46
+ mission_ids, mission_patterns = split_args(missions or [])
47
+ project_ids, project_patterns = split_args(projects or [])
48
+
49
+ project_spec = ProjectSpec(patterns=project_patterns, ids=project_ids)
50
+ mission_spec = MissionSpec(
51
+ project_spec=project_spec,
52
+ ids=mission_ids,
53
+ patterns=mission_patterns,
54
+ )
55
+ file_spec = FileSpec(
56
+ mission_spec=mission_spec, patterns=file_patterns, ids=file_ids
57
+ )
58
58
 
59
59
  client = AuthenticatedClient()
60
- file_spec = get_valid_file_spec(_files, mission=_mission, project=_project)
61
- parsed_files = get_files_by_file_spec(client, file_spec)
60
+ parsed_files = get_files_by_spec(client, file_spec)
62
61
 
63
62
  if get_shared_state().verbose:
64
- table = files_to_table(parsed_files)
65
- console = Console()
66
- console.print(table)
63
+ Console().print(files_to_table(parsed_files))
67
64
  else:
68
65
  for file in parsed_files:
69
66
  print(file.id)
70
67
 
71
68
 
72
69
  @list_typer.command()
73
- def projects() -> None:
70
+ def missions(
71
+ projects: Optional[List[str]] = typer.Option(
72
+ None, "--project", "-p", help="project name or id"
73
+ ),
74
+ missions: Optional[List[str]] = typer.Argument(None, help="mission names"),
75
+ ) -> None:
76
+ mission_ids, mission_patterns = split_args(missions or [])
77
+ project_ids, project_patterns = split_args(projects or [])
78
+
79
+ project_spec = ProjectSpec(ids=project_ids, patterns=project_patterns)
80
+ mission_spec = MissionSpec(
81
+ ids=mission_ids,
82
+ patterns=mission_patterns,
83
+ project_spec=project_spec,
84
+ )
85
+
74
86
  client = AuthenticatedClient()
75
- projects = get_projects(client)
87
+ parsed_missions = get_missions_by_spec(client, mission_spec)
76
88
 
77
89
  if get_shared_state().verbose:
78
- table = projects_to_table(projects)
79
- console = Console()
80
- console.print(table)
81
- else:
82
- for project in projects:
83
- print(project.id)
90
+ Console().print(missions_to_table(parsed_missions))
84
91
 
85
92
 
86
93
  @list_typer.command()
87
- def missions(
88
- project: Optional[str] = typer.Option(None, "--project", "-p", help="project name"),
89
- metadata: Optional[List[str]] = typer.Argument(None, help="tag=value pairs"),
94
+ def projects(
95
+ projects: Optional[List[str]] = typer.Argument(None, help="project names"),
90
96
  ) -> None:
91
- client = AuthenticatedClient()
97
+ project_ids, project_patterns = split_args(projects or [])
98
+ project_spec = ProjectSpec(patterns=project_patterns, ids=project_ids)
92
99
 
93
- _metadata = _parse_metadata(metadata or [])
94
- missions = get_missions(client, project=project, tags=_metadata)
100
+ client = AuthenticatedClient()
101
+ parsed_projects = get_projects_by_spec(client, project_spec)
95
102
 
96
103
  if get_shared_state().verbose:
97
- table = missions_to_table(missions)
98
- console = Console()
99
- console.print(table)
104
+ Console().print(projects_to_table(parsed_projects))
100
105
  else:
101
- for mission in missions:
102
- print(mission.id)
106
+ for project in parsed_projects:
107
+ print(project.id)
@@ -5,12 +5,14 @@ from typing import Optional
5
5
 
6
6
  import typer
7
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
8
+ from kleinkram.api.routes import _update_mission_metadata
9
+ from kleinkram.errors import MissionNotFound
10
+ from kleinkram.resources import get_missions_by_spec
11
+ from kleinkram.resources import mission_spec_is_unique
12
+ from kleinkram.resources import MissionSpec
13
+ from kleinkram.resources import ProjectSpec
12
14
  from kleinkram.utils import load_metadata
13
- from kleinkram.utils import to_name_or_uuid
15
+ from kleinkram.utils import split_args
14
16
 
15
17
  mission_typer = typer.Typer(
16
18
  no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}
@@ -32,19 +34,29 @@ def update(
32
34
  mission: str = typer.Option(..., "--mission", "-m", help="mission id or name"),
33
35
  metadata: str = typer.Option(help="path to metadata file (json or yaml)"),
34
36
  ) -> None:
35
- _project = to_name_or_uuid(project) if project else None
36
- _mission = to_name_or_uuid(mission) if mission else None
37
+ mission_ids, mission_patterns = split_args([mission])
38
+ project_ids, project_patterns = split_args([project] if project else [])
37
39
 
38
- client = AuthenticatedClient()
40
+ project_spec = ProjectSpec(ids=project_ids, patterns=project_patterns)
41
+ mission_spec = MissionSpec(
42
+ ids=mission_ids,
43
+ patterns=mission_patterns,
44
+ project_spec=project_spec,
45
+ )
46
+
47
+ if not mission_spec_is_unique(mission_spec):
48
+ raise ValueError(f"mission spec is not unique: {mission_spec}")
39
49
 
40
- mission_spec = get_valid_mission_spec(_mission, _project)
41
- mission_parsed = get_mission_by_spec(client, mission_spec)
50
+ client = AuthenticatedClient()
51
+ missions = get_missions_by_spec(client, mission_spec)
42
52
 
43
- if mission_parsed is None:
44
- raise MissionDoesNotExist(f"Mission {mission} does not exist")
53
+ if not missions:
54
+ raise MissionNotFound(f"Mission {mission} does not exist")
55
+ elif len(missions) > 1:
56
+ raise RuntimeError(f"Multiple missions found: {missions}") # unreachable
45
57
 
46
58
  metadata_dct = load_metadata(Path(metadata))
47
- update_mission_metadata(client, mission_parsed.id, metadata_dct)
59
+ _update_mission_metadata(client, missions[0].id, metadata=metadata_dct)
48
60
 
49
61
 
50
62
  @mission_typer.command(help=NOT_IMPLEMENTED_YET)