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.

kleinkram/app.py CHANGED
@@ -1,19 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections import OrderedDict
3
+ import logging
4
+ import sys
5
+ import time
4
6
  from enum import Enum
5
- from typing import Any
6
- from typing import Callable
7
+ from pathlib import Path
7
8
  from typing import List
8
9
  from typing import Optional
9
- from typing import Type
10
10
 
11
11
  import typer
12
12
  from click import Context
13
13
  from kleinkram._version import __version__
14
14
  from kleinkram.api.client import AuthenticatedClient
15
- from kleinkram.api.routes import claim_admin
16
- 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
17
17
  from kleinkram.auth import login_flow
18
18
  from kleinkram.commands.download import download_typer
19
19
  from kleinkram.commands.endpoint import endpoint_typer
@@ -24,11 +24,20 @@ from kleinkram.commands.upload import upload_typer
24
24
  from kleinkram.commands.verify import verify_typer
25
25
  from kleinkram.config import Config
26
26
  from kleinkram.config import get_shared_state
27
+ from kleinkram.errors import ErrorHandledTyper
27
28
  from kleinkram.errors import InvalidCLIVersion
29
+ from kleinkram.utils import format_traceback
28
30
  from kleinkram.utils import get_supported_api_version
29
31
  from rich.console import Console
30
32
  from typer.core import TyperGroup
31
33
 
34
+ LOG_DIR = Path() / "logs"
35
+ LOG_FILE = LOG_DIR / f"{time.time_ns()}.log"
36
+ LOG_FORMAT = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
37
+
38
+ # setup default logging
39
+ logger = logging.getLogger(__name__)
40
+
32
41
 
33
42
  CLI_HELP = """\
34
43
  Kleinkram CLI
@@ -40,6 +49,14 @@ for more information.
40
49
  """
41
50
 
42
51
 
52
+ class LogLevel(str, Enum):
53
+ DEBUG = "DEBUG"
54
+ INFO = "INFO"
55
+ WARNING = "WARNING"
56
+ ERROR = "ERROR"
57
+ CRITICAL = "CRITICAL"
58
+
59
+
43
60
  class CommandTypes(str, Enum):
44
61
  AUTH = "Authentication Commands"
45
62
  CORE = "Core Commands"
@@ -52,40 +69,6 @@ class OrderCommands(TyperGroup):
52
69
  return list(self.commands)
53
70
 
54
71
 
55
- ExceptionHandler = Callable[[Exception], int]
56
-
57
-
58
- class ErrorHandledTyper(typer.Typer):
59
- """\
60
- error handlers that are last added will be used first
61
- """
62
-
63
- _error_handlers: OrderedDict[Type[Exception], ExceptionHandler]
64
-
65
- def error_handler(
66
- self, exc: type[Exception]
67
- ) -> Callable[[ExceptionHandler], ExceptionHandler]:
68
- def dec(func: ExceptionHandler) -> ExceptionHandler:
69
- self._error_handlers[exc] = func
70
- return func
71
-
72
- return dec
73
-
74
- def __init__(self, *args: Any, **kwargs: Any) -> None:
75
- super().__init__(*args, **kwargs)
76
- self._error_handlers = OrderedDict()
77
-
78
- def __call__(self, *args: Any, **kwargs: Any) -> int:
79
- try:
80
- return super().__call__(*args, **kwargs)
81
- except Exception as e:
82
- for tp, handler in reversed(self._error_handlers.items()):
83
- if isinstance(e, tp):
84
- exit_code = handler(e)
85
- raise SystemExit(exit_code)
86
- raise
87
-
88
-
89
72
  app = ErrorHandledTyper(
90
73
  cls=OrderCommands,
91
74
  help=CLI_HELP,
@@ -93,17 +76,6 @@ app = ErrorHandledTyper(
93
76
  no_args_is_help=True,
94
77
  )
95
78
 
96
-
97
- @app.error_handler(Exception)
98
- def base_handler(exc: Exception) -> int:
99
- if not get_shared_state().debug:
100
- console = Console()
101
- console.print(f"{type(exc).__name__}: {exc}", style="red")
102
- return 1
103
-
104
- raise exc
105
-
106
-
107
79
  app.add_typer(download_typer, name="download", rich_help_panel=CommandTypes.CORE)
108
80
  app.add_typer(upload_typer, name="upload", rich_help_panel=CommandTypes.CORE)
109
81
  app.add_typer(verify_typer, name="verify", rich_help_panel=CommandTypes.CORE)
@@ -113,6 +85,16 @@ app.add_typer(mission_typer, name="mission", rich_help_panel=CommandTypes.CRUD)
113
85
  app.add_typer(project_typer, name="project", rich_help_panel=CommandTypes.CRUD)
114
86
 
115
87
 
88
+ # attach error handler to app
89
+ @app.error_handler(Exception)
90
+ def base_handler(exc: Exception) -> int:
91
+ if not get_shared_state().debug:
92
+ Console(file=sys.stderr).print(f"{type(exc).__name__}: {exc}", style="red")
93
+ logger.error(format_traceback(exc))
94
+ return 1
95
+ raise exc
96
+
97
+
116
98
  @app.command(rich_help_panel=CommandTypes.AUTH)
117
99
  def login(
118
100
  key: Optional[str] = typer.Option(None, help="CLI key"),
@@ -130,11 +112,11 @@ def logout(all: bool = typer.Option(False, help="logout on all enpoints")) -> No
130
112
  @app.command(hidden=True)
131
113
  def claim():
132
114
  client = AuthenticatedClient()
133
- claim_admin(client)
115
+ _claim_admin(client)
134
116
  print("admin rights claimed successfully.")
135
117
 
136
118
 
137
- def _version_cb(value: bool) -> None:
119
+ def _version_callback(value: bool) -> None:
138
120
  if value:
139
121
  typer.echo(__version__)
140
122
  raise typer.Exit()
@@ -142,7 +124,7 @@ def _version_cb(value: bool) -> None:
142
124
 
143
125
  def check_version_compatiblity() -> None:
144
126
  cli_version = get_supported_api_version()
145
- api_version = get_api_version()
127
+ api_version = _get_api_version()
146
128
  api_vers_str = ".".join(map(str, api_version))
147
129
 
148
130
  if cli_version[0] != api_version[0]:
@@ -151,11 +133,9 @@ def check_version_compatiblity() -> None:
151
133
  )
152
134
 
153
135
  if cli_version[1] != api_version[1]:
154
- console = Console()
155
- console.print(
156
- f"CLI version {__version__} might not be compatible with API version {api_vers_str}",
157
- style="red",
158
- )
136
+ msg = f"CLI version {__version__} might not be compatible with API version {api_vers_str}"
137
+ Console(file=sys.stderr).print(msg, style="red")
138
+ logger.warning(msg)
159
139
 
160
140
 
161
141
  @app.callback()
@@ -163,18 +143,35 @@ def cli(
163
143
  verbose: bool = typer.Option(True, help="Enable verbose mode."),
164
144
  debug: bool = typer.Option(False, help="Enable debug mode."),
165
145
  version: Optional[bool] = typer.Option(
166
- None, "--version", "-v", callback=_version_cb
146
+ None, "--version", "-v", callback=_version_callback
167
147
  ),
148
+ log_level: Optional[LogLevel] = typer.Option(None, help="Set log level."),
168
149
  ):
169
150
  _ = version # suppress unused variable warning
170
151
  shared_state = get_shared_state()
171
152
  shared_state.verbose = verbose
172
153
  shared_state.debug = debug
173
154
 
155
+ if shared_state.debug:
156
+ log_level = LogLevel.DEBUG
157
+
158
+ if log_level is not None:
159
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
160
+ level = logging.getLevelName(log_level)
161
+ logging.basicConfig(level=level, filename=LOG_FILE, format=LOG_FORMAT)
162
+ else:
163
+ logging.disable(logging.CRITICAL)
164
+
165
+ logger.info(f"CLI version: {__version__}")
166
+
174
167
  try:
175
168
  check_version_compatiblity()
176
- except InvalidCLIVersion:
169
+ except InvalidCLIVersion as e:
170
+ logger.error(format_traceback(e))
177
171
  raise
178
172
  except Exception:
179
- console = Console()
180
- console.print("failed to check version compatibility", 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
+ )
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)