kleinkram 0.48.0.dev20250723090520__py3-none-any.whl → 0.58.0.dev20260110152317__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.
Files changed (44) hide show
  1. kleinkram/api/client.py +6 -18
  2. kleinkram/api/deser.py +152 -1
  3. kleinkram/api/file_transfer.py +57 -87
  4. kleinkram/api/pagination.py +11 -2
  5. kleinkram/api/query.py +10 -10
  6. kleinkram/api/routes.py +192 -59
  7. kleinkram/auth.py +108 -7
  8. kleinkram/cli/_action.py +131 -0
  9. kleinkram/cli/_download.py +6 -18
  10. kleinkram/cli/_endpoint.py +2 -4
  11. kleinkram/cli/_file.py +6 -18
  12. kleinkram/cli/_file_validator.py +125 -0
  13. kleinkram/cli/_list.py +5 -15
  14. kleinkram/cli/_mission.py +24 -28
  15. kleinkram/cli/_project.py +10 -26
  16. kleinkram/cli/_run.py +220 -0
  17. kleinkram/cli/_upload.py +58 -26
  18. kleinkram/cli/_verify.py +48 -15
  19. kleinkram/cli/app.py +56 -17
  20. kleinkram/cli/error_handling.py +1 -3
  21. kleinkram/config.py +6 -21
  22. kleinkram/core.py +19 -36
  23. kleinkram/errors.py +12 -0
  24. kleinkram/models.py +49 -0
  25. kleinkram/printing.py +225 -15
  26. kleinkram/utils.py +8 -22
  27. kleinkram/wrappers.py +13 -34
  28. {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/METADATA +6 -5
  29. kleinkram-0.58.0.dev20260110152317.dist-info/RECORD +53 -0
  30. {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/top_level.txt +0 -1
  31. {testing → tests}/backend_fixtures.py +27 -3
  32. tests/conftest.py +1 -1
  33. tests/generate_test_data.py +314 -0
  34. tests/test_config.py +2 -6
  35. tests/test_core.py +11 -31
  36. tests/test_end_to_end.py +3 -5
  37. tests/test_fixtures.py +3 -5
  38. tests/test_printing.py +1 -3
  39. tests/test_utils.py +1 -3
  40. tests/test_wrappers.py +9 -27
  41. kleinkram-0.48.0.dev20250723090520.dist-info/RECORD +0 -50
  42. testing/__init__.py +0 -0
  43. {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/WHEEL +0 -0
  44. {kleinkram-0.48.0.dev20250723090520.dist-info → kleinkram-0.58.0.dev20260110152317.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Optional
5
+ from uuid import UUID
6
+
7
+ import httpx
8
+ import typer
9
+
10
+ import kleinkram.api.routes
11
+ from kleinkram.api.client import AuthenticatedClient
12
+ from kleinkram.api.query import MissionQuery
13
+ from kleinkram.api.query import ProjectQuery
14
+ from kleinkram.config import get_shared_state
15
+ from kleinkram.printing import print_action_templates_table
16
+ from kleinkram.printing import print_run_info
17
+ from kleinkram.utils import is_valid_uuid4
18
+ from kleinkram.utils import split_args
19
+
20
+ HELP = """\
21
+ Launch kleinkram actions from predefined templates.
22
+
23
+ You can list available action templates, launch new actions on specific missions, and optionally
24
+ follow their logs in real-time.
25
+ """
26
+
27
+ action_typer = typer.Typer(
28
+ no_args_is_help=True,
29
+ context_settings={"help_option_names": ["-h", "--help"]},
30
+ help=HELP,
31
+ )
32
+
33
+ LIST_HELP = "Lists action templates (definitions). To list individual runs, use `klein run list`."
34
+ GET_HELP = "Get details for a specific action template."
35
+ RUN_HELP = "Launch a new action from a template."
36
+
37
+
38
+ @action_typer.command(help=LIST_HELP, name="list")
39
+ def list_actions() -> None:
40
+ client = AuthenticatedClient()
41
+ templates = list(kleinkram.api.routes.get_action_templates(client))
42
+
43
+ if not templates:
44
+ typer.echo("No action templates found.")
45
+ return
46
+
47
+ print_action_templates_table(templates, pprint=get_shared_state().verbose)
48
+
49
+
50
+ @action_typer.command(help=RUN_HELP)
51
+ def run(
52
+ template_name: str = typer.Argument(..., help="Name or ID of the template to run."),
53
+ mission: str = typer.Option(..., "--mission", "-m", help="Mission ID or name to run the action on."),
54
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID or name (to scope mission)."),
55
+ follow: bool = typer.Option(False, "--follow", "-f", help="Follow the logs of the action run."),
56
+ ) -> None:
57
+ """
58
+ Submits an action to run on a specific mission and optionally follows its logs.
59
+ """
60
+ client = AuthenticatedClient()
61
+ pprint = get_shared_state().verbose
62
+
63
+ try:
64
+ project_ids, project_patterns = split_args([project] if project else [])
65
+ project_query = ProjectQuery(ids=project_ids, patterns=project_patterns)
66
+
67
+ mission_ids, mission_patterns = split_args([mission])
68
+ mission_query = MissionQuery(
69
+ ids=mission_ids,
70
+ patterns=mission_patterns,
71
+ project_query=project_query,
72
+ )
73
+ mission_obj = kleinkram.api.routes.get_mission(client, mission_query)
74
+ mission_uuid = mission_obj.id
75
+ except kleinkram.errors.MissionNotFound:
76
+ typer.secho(f"Error: Mission '{mission}' not found.", fg=typer.colors.RED)
77
+ raise typer.Exit(code=1)
78
+ except kleinkram.errors.InvalidMissionQuery:
79
+ typer.secho(
80
+ "Error: Mission query is ambiguous. Try specifying a project with -p.",
81
+ fg=typer.colors.RED,
82
+ )
83
+ raise typer.Exit(code=1)
84
+ except Exception as e:
85
+ typer.secho(f"Error resolving mission: {e}", fg=typer.colors.RED)
86
+ raise typer.Exit(code=1)
87
+
88
+ # 2. Resolve Template to UUID
89
+ try:
90
+ if is_valid_uuid4(template_name):
91
+ template_uuid = UUID(template_name)
92
+ else:
93
+ templates = kleinkram.api.routes.get_action_templates(client)
94
+ found_template = next((t for t in templates if t.name == template_name), None)
95
+
96
+ if not found_template:
97
+ typer.secho(
98
+ f"Error: Action template '{template_name}' not found.",
99
+ fg=typer.colors.RED,
100
+ )
101
+ raise typer.Exit(code=1)
102
+ template_uuid = found_template.uuid
103
+ except Exception as e:
104
+ typer.secho(f"Error resolving template: {e}", fg=typer.colors.RED)
105
+ raise typer.Exit(code=1)
106
+
107
+ try:
108
+ action_uuid_str = kleinkram.api.routes.submit_action(client, mission_uuid, template_uuid)
109
+ typer.secho(f"Action submitted. Run ID: {action_uuid_str}", fg=typer.colors.GREEN)
110
+
111
+ except httpx.HTTPStatusError as e:
112
+ typer.secho(f"Error submitting action: {e.response.text}", fg=typer.colors.RED)
113
+ raise typer.Exit(code=1)
114
+ except (KeyError, Exception) as e:
115
+ typer.secho(f"An unexpected error occurred: {e}", fg=typer.colors.RED)
116
+ raise typer.Exit(code=1)
117
+
118
+ if follow:
119
+ exit_code = kleinkram.printing.follow_run_logs(client, action_uuid_str)
120
+ if exit_code != 0:
121
+ raise typer.Exit(code=exit_code)
122
+
123
+ elif pprint:
124
+ # Not following, but in verbose mode. Show run info.
125
+ try:
126
+ time.sleep(0.5) # Give API a moment
127
+ run_details = kleinkram.api.routes.get_run(client, action_uuid_str)
128
+ kleinkram.printing.print_run_info(run_details, pprint=True)
129
+ except Exception:
130
+ # Non-critical, we already printed the ID.
131
+ pass
@@ -22,26 +22,16 @@ Download files from kleinkram.
22
22
  """
23
23
 
24
24
 
25
- download_typer = typer.Typer(
26
- name="download", no_args_is_help=True, invoke_without_command=True, help=HELP
27
- )
25
+ download_typer = typer.Typer(name="download", no_args_is_help=True, invoke_without_command=True, help=HELP)
28
26
 
29
27
 
30
28
  @download_typer.callback()
31
29
  def download(
32
- files: Optional[List[str]] = typer.Argument(
33
- None, help="file names, ids or patterns"
34
- ),
35
- projects: Optional[List[str]] = typer.Option(
36
- None, "--project", "-p", help="project names, ids or patterns"
37
- ),
38
- missions: Optional[List[str]] = typer.Option(
39
- None, "--mission", "-m", help="mission names, ids or patterns"
40
- ),
30
+ files: Optional[List[str]] = typer.Argument(None, help="file names, ids or patterns"),
31
+ projects: Optional[List[str]] = typer.Option(None, "--project", "-p", help="project names, ids or patterns"),
32
+ missions: Optional[List[str]] = typer.Option(None, "--mission", "-m", help="mission names, ids or patterns"),
41
33
  dest: str = typer.Option(prompt="destination", help="local path to save the files"),
42
- nested: bool = typer.Option(
43
- False, help="save files in nested directories, project-name/mission-name"
44
- ),
34
+ nested: bool = typer.Option(False, help="save files in nested directories, project-name/mission-name"),
45
35
  overwrite: bool = typer.Option(
46
36
  False,
47
37
  help="overwrite files if they already exist and don't match the file size or file hash",
@@ -64,9 +54,7 @@ def download(
64
54
  ids=mission_ids,
65
55
  project_query=project_query,
66
56
  )
67
- file_query = FileQuery(
68
- patterns=file_patterns, ids=file_ids, mission_query=mission_query
69
- )
57
+ file_query = FileQuery(patterns=file_patterns, ids=file_ids, mission_query=mission_query)
70
58
 
71
59
  kleinkram.core.download(
72
60
  client=AuthenticatedClient(),
@@ -18,7 +18,7 @@ from kleinkram.config import get_config
18
18
  from kleinkram.config import select_endpoint
19
19
 
20
20
  HELP = """\
21
- Get or set the current endpoint.
21
+ Switch between different Kleinkram hosting.
22
22
 
23
23
  The endpoint is used to determine the API server to connect to\
24
24
  (default is the API server of https://datasets.leggedrobotics.com).
@@ -50,9 +50,7 @@ def endpoint(
50
50
  console.print(f"Endpoint {name} not found.\n", style="red")
51
51
  console.print(endpoint_table(config))
52
52
  elif not (name and api and s3):
53
- raise typer.BadParameter(
54
- "to add a new endpoint you must specify the api and s3 endpoints"
55
- )
53
+ raise typer.BadParameter("to add a new endpoint you must specify the api and s3 endpoints")
56
54
  else:
57
55
  new_endpoint = Endpoint(name, api, s3)
58
56
  add_endpoint(config, new_endpoint)
kleinkram/cli/_file.py CHANGED
@@ -19,19 +19,13 @@ INFO_HELP = "get information about a file"
19
19
  DELETE_HELP = "delete a file"
20
20
 
21
21
 
22
- file_typer = typer.Typer(
23
- no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}
24
- )
22
+ file_typer = typer.Typer(no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]})
25
23
 
26
24
 
27
25
  @file_typer.command(help=INFO_HELP)
28
26
  def info(
29
- project: Optional[str] = typer.Option(
30
- None, "--project", "-p", help="project id or name"
31
- ),
32
- mission: Optional[str] = typer.Option(
33
- None, "--mission", "-m", help="mission id or name"
34
- ),
27
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="project id or name"),
28
+ mission: Optional[str] = typer.Option(None, "--mission", "-m", help="mission id or name"),
35
29
  file: str = typer.Option(..., "--file", "-f", help="file id or name"),
36
30
  ) -> None:
37
31
  project_ids, project_patterns = split_args([project] if project else [])
@@ -58,16 +52,10 @@ def info(
58
52
 
59
53
  @file_typer.command(help=DELETE_HELP)
60
54
  def delete(
61
- project: Optional[str] = typer.Option(
62
- None, "--project", "-p", help="project id or name"
63
- ),
64
- mission: Optional[str] = typer.Option(
65
- None, "--mission", "-m", help="mission id or name"
66
- ),
55
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="project id or name"),
56
+ mission: Optional[str] = typer.Option(None, "--mission", "-m", help="mission id or name"),
67
57
  file: str = typer.Option(..., "--file", "-f", help="file id or name"),
68
- confirm: bool = typer.Option(
69
- False, "--confirm", "-y", "--yes", help="confirm deletion"
70
- ),
58
+ confirm: bool = typer.Option(False, "--confirm", "-y", "--yes", help="confirm deletion"),
71
59
  ) -> None:
72
60
  if not confirm:
73
61
  typer.confirm(f"delete {project} {mission}", abort=True)
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from dataclasses import field
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ import typer
9
+
10
+ from kleinkram.config import get_shared_state
11
+ from kleinkram.errors import DatatypeNotSupported
12
+ from kleinkram.errors import FileNameNotSupported
13
+ from kleinkram.utils import EXPERIMENTAL_FILE_TYPES
14
+ from kleinkram.utils import SUPPORT_FILE_TYPES
15
+ from kleinkram.utils import check_filename_is_sanatized
16
+
17
+
18
+ @dataclass
19
+ class FileValidator:
20
+ """Encapsulates all file validation logic based on CLI flags."""
21
+
22
+ skip: bool
23
+ experimental_datatypes: bool
24
+
25
+ # Stores (file, reason) for skipped files
26
+ skipped_files: List[tuple[Path, str]] = field(default_factory=list)
27
+
28
+ def filter_files(self, file_paths: List[Path]) -> List[Path]:
29
+ """
30
+ Validates a list of file paths.
31
+
32
+ - Populates `self.skipped_files` with invalid files.
33
+ - Raises an exception on the first invalid file if `self.skip` is False.
34
+ - Returns a list of valid files to verify.
35
+ """
36
+ files_to_verify = []
37
+ for file in file_paths:
38
+ try:
39
+ self._validate_path(file)
40
+ files_to_verify.append(file)
41
+ except (
42
+ FileNotFoundError,
43
+ IsADirectoryError,
44
+ DatatypeNotSupported,
45
+ FileNameNotSupported,
46
+ ) as e:
47
+ if self.skip:
48
+ self.skipped_files.append((file, str(e)))
49
+ else:
50
+ # Re-raise the exception to be caught by Typer
51
+ self._raise_with_skip_hint(e)
52
+ return files_to_verify
53
+
54
+ def _validate_path(self, file: Path) -> None:
55
+ """
56
+ Runs a single file through all validation checks.
57
+ Raises an error if any check fails.
58
+ """
59
+ # 0. Check for existence
60
+ if not file.exists():
61
+ raise FileNotFoundError(f"File not found: {file}")
62
+
63
+ # 1. Check for directories
64
+ if file.is_dir():
65
+ raise IsADirectoryError(f"{file} is a directory")
66
+
67
+ file_suffix = file.suffix.lower()
68
+ is_experimental = file_suffix in EXPERIMENTAL_FILE_TYPES
69
+ is_standard = file_suffix in SUPPORT_FILE_TYPES
70
+
71
+ # 2. Check if the datatype is known at all
72
+ if not is_standard and not is_experimental:
73
+ raise DatatypeNotSupported(f"Unsupported file type '{file_suffix}' on file {file}")
74
+
75
+ # 3. Check if an experimental datatype is allowed
76
+ if is_experimental and not self.experimental_datatypes:
77
+ raise DatatypeNotSupported(f"Experimental datatype '{file_suffix}' not enabled for file {file}")
78
+
79
+ # 4. Check filename
80
+ is_bad_name = not check_filename_is_sanatized(file.stem)
81
+ if is_bad_name:
82
+ raise FileNameNotSupported(f"Badly formed filename for file {file}")
83
+
84
+ def _raise_with_skip_hint(self, e: Exception) -> None:
85
+ """Re-raises a validation error with a hint to use --skip."""
86
+ base_message = str(e)
87
+ hint = "Use --skip to ignore."
88
+
89
+ if isinstance(e, IsADirectoryError):
90
+ hint = "Use --skip to ignore directories."
91
+ elif isinstance(e, DatatypeNotSupported):
92
+ if "Experimental" in base_message:
93
+ hint = "Use --experimental-datatypes to allow or --skip to ignore."
94
+ elif isinstance(e, FileNameNotSupported):
95
+ hint = "Use --skip to ignore." # No --fix-filenames hint here
96
+
97
+ # Raise a new exception to the same type to preserve the error type
98
+ raise type(e)(f"{base_message}. {hint}")
99
+
100
+
101
+ def _report_skipped_files(skipped_files: List[tuple[Path, str]]) -> None:
102
+ """Prints a formatted report of all skipped files."""
103
+ if skipped_files:
104
+ debug = get_shared_state().debug
105
+
106
+ if debug:
107
+ # Full report
108
+ typer.echo(
109
+ typer.style(
110
+ f"--- Skipped {len(skipped_files)} path(s) ---",
111
+ fg=typer.colors.YELLOW,
112
+ )
113
+ )
114
+ for file, reason in skipped_files:
115
+ typer.echo(f"Skipped: {file} (Reason: {reason})")
116
+ typer.echo("---------------------------\n")
117
+ else:
118
+ # Summary report
119
+ typer.echo(
120
+ typer.style(
121
+ f"Skipped {len(skipped_files)} path(s) due to errors. Use `klein --debug [...]` for details.",
122
+ fg=typer.colors.YELLOW,
123
+ )
124
+ )
125
+ typer.echo("")
kleinkram/cli/_list.py CHANGED
@@ -23,9 +23,7 @@ List projects, missions, or files.
23
23
  """
24
24
 
25
25
 
26
- list_typer = typer.Typer(
27
- name="list", invoke_without_command=True, help=HELP, no_args_is_help=True
28
- )
26
+ list_typer = typer.Typer(name="list", invoke_without_command=True, help=HELP, no_args_is_help=True)
29
27
 
30
28
 
31
29
  @list_typer.command()
@@ -34,12 +32,8 @@ def files(
34
32
  None,
35
33
  help="file names, ids or patterns",
36
34
  ),
37
- projects: Optional[List[str]] = typer.Option(
38
- None, "--project", "-p", help="project name or id"
39
- ),
40
- missions: Optional[List[str]] = typer.Option(
41
- None, "--mission", "-m", help="mission name or id"
42
- ),
35
+ projects: Optional[List[str]] = typer.Option(None, "--project", "-p", help="project name or id"),
36
+ missions: Optional[List[str]] = typer.Option(None, "--mission", "-m", help="mission name or id"),
43
37
  ) -> None:
44
38
  file_ids, file_patterns = split_args(files or [])
45
39
  mission_ids, mission_patterns = split_args(missions or [])
@@ -51,9 +45,7 @@ def files(
51
45
  ids=mission_ids,
52
46
  patterns=mission_patterns,
53
47
  )
54
- file_query = FileQuery(
55
- mission_query=mission_query, patterns=file_patterns, ids=file_ids
56
- )
48
+ file_query = FileQuery(mission_query=mission_query, patterns=file_patterns, ids=file_ids)
57
49
 
58
50
  client = AuthenticatedClient()
59
51
  parsed_files = list(get_files(client, file_query=file_query))
@@ -62,9 +54,7 @@ def files(
62
54
 
63
55
  @list_typer.command()
64
56
  def missions(
65
- projects: Optional[List[str]] = typer.Option(
66
- None, "--project", "-p", help="project name or id"
67
- ),
57
+ projects: Optional[List[str]] = typer.Option(None, "--project", "-p", help="project name or id"),
68
58
  missions: Optional[List[str]] = typer.Argument(None, help="mission names"),
69
59
  ) -> None:
70
60
  mission_ids, mission_patterns = split_args(missions or [])
kleinkram/cli/_mission.py CHANGED
@@ -13,6 +13,7 @@ from kleinkram.api.query import ProjectQuery
13
13
  from kleinkram.api.routes import get_mission
14
14
  from kleinkram.api.routes import get_project
15
15
  from kleinkram.config import get_shared_state
16
+ from kleinkram.errors import InvalidMissionQuery
16
17
  from kleinkram.printing import print_mission_info
17
18
  from kleinkram.utils import load_metadata
18
19
  from kleinkram.utils import split_args
@@ -25,18 +26,14 @@ NOT_IMPLEMENTED_YET = """\
25
26
  Not implemented yet, open an issue if you want specific functionality
26
27
  """
27
28
 
28
- mission_typer = typer.Typer(
29
- no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}
30
- )
29
+ mission_typer = typer.Typer(no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]})
31
30
 
32
31
 
33
32
  @mission_typer.command(help=CREATE_HELP)
34
33
  def create(
35
34
  project: str = typer.Option(..., "--project", "-p", help="project id or name"),
36
35
  mission_name: str = typer.Option(..., "--mission", "-m", help="mission name"),
37
- metadata: Optional[str] = typer.Option(
38
- None, help="path to metadata file (json or yaml)"
39
- ),
36
+ metadata: Optional[str] = typer.Option(None, help="path to metadata file (json or yaml)"),
40
37
  ignore_missing_tags: bool = typer.Option(False, help="ignore mission tags"),
41
38
  ) -> None:
42
39
  project_ids, project_patterns = split_args([project] if project else [])
@@ -45,13 +42,16 @@ def create(
45
42
  metadata_dct = load_metadata(Path(metadata)) if metadata else {} # noqa
46
43
 
47
44
  client = AuthenticatedClient()
48
- project_id = get_project(client, project_query).id
45
+ project = get_project(client, project_query, exact_match=True)
46
+ project_id = project.id
47
+ project_required_tags = project.required_tags
49
48
  mission_id = kleinkram.api.routes._create_mission(
50
49
  client,
51
50
  project_id,
52
51
  mission_name,
53
52
  metadata=metadata_dct,
54
53
  ignore_missing_tags=ignore_missing_tags,
54
+ required_tags=project_required_tags,
55
55
  )
56
56
 
57
57
  mission_parsed = get_mission(client, MissionQuery(ids=[mission_id]))
@@ -60,9 +60,7 @@ def create(
60
60
 
61
61
  @mission_typer.command(help=INFO_HELP)
62
62
  def info(
63
- project: Optional[str] = typer.Option(
64
- None, "--project", "-p", help="project id or name"
65
- ),
63
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="project id or name"),
66
64
  mission: str = typer.Option(..., "--mission", "-m", help="mission id or name"),
67
65
  ) -> None:
68
66
  mission_ids, mission_patterns = split_args([mission])
@@ -82,9 +80,7 @@ def info(
82
80
 
83
81
  @mission_typer.command(help=UPDATE_HELP)
84
82
  def update(
85
- project: Optional[str] = typer.Option(
86
- None, "--project", "-p", help="project id or name"
87
- ),
83
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="project id or name"),
88
84
  mission: str = typer.Option(..., "--mission", "-m", help="mission id or name"),
89
85
  metadata: str = typer.Option(help="path to metadata file (json or yaml)"),
90
86
  ) -> None:
@@ -102,9 +98,7 @@ def update(
102
98
 
103
99
  client = AuthenticatedClient()
104
100
  mission_id = get_mission(client, mission_query).id
105
- kleinkram.core.update_mission(
106
- client=client, mission_id=mission_id, metadata=metadata_dct
107
- )
101
+ kleinkram.core.update_mission(client=client, mission_id=mission_id, metadata=metadata_dct)
108
102
 
109
103
  mission_parsed = get_mission(client, mission_query)
110
104
  print_mission_info(mission_parsed, pprint=get_shared_state().verbose)
@@ -112,17 +106,10 @@ def update(
112
106
 
113
107
  @mission_typer.command(help=DELETE_HELP)
114
108
  def delete(
115
- project: Optional[str] = typer.Option(
116
- None, "--project", "-p", help="project id or name"
117
- ),
109
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="project id or name"),
118
110
  mission: str = typer.Option(..., "--mission", "-m", help="mission id or name"),
119
- confirm: bool = typer.Option(
120
- False, "--confirm", "-y", "--yes", help="confirm deletion"
121
- ),
111
+ confirm: bool = typer.Option(False, "--confirm", "-y", "--yes", help="confirm deletion"),
122
112
  ) -> None:
123
- if not confirm:
124
- typer.confirm(f"delete {project} {mission}", abort=True)
125
-
126
113
  project_ids, project_patterns = split_args([project] if project else [])
127
114
  project_query = ProjectQuery(ids=project_ids, patterns=project_patterns)
128
115
 
@@ -132,17 +119,26 @@ def delete(
132
119
  patterns=mission_patterns,
133
120
  project_query=project_query,
134
121
  )
122
+ if mission_patterns and not (project_patterns or project_ids):
123
+ raise InvalidMissionQuery(
124
+ "Mission query does not uniquely determine mission. "
125
+ "Project name or id must be specified when deleting by mission name"
126
+ )
135
127
 
136
128
  client = AuthenticatedClient()
137
129
  mission_parsed = get_mission(client, mission_query)
130
+ if not confirm:
131
+ if project:
132
+ typer.confirm(f"delete {project} {mission}", abort=True)
133
+ else:
134
+ typer.confirm(f"delete {mission_parsed.name} {mission}", abort=True)
135
+
138
136
  kleinkram.core.delete_mission(client=client, mission_id=mission_parsed.id)
139
137
 
140
138
 
141
139
  @mission_typer.command(help=NOT_IMPLEMENTED_YET)
142
140
  def prune(
143
- project: Optional[str] = typer.Option(
144
- None, "--project", "-p", help="project id or name"
145
- ),
141
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="project id or name"),
146
142
  mission: str = typer.Option(..., "--mission", "-m", help="mission id or name"),
147
143
  ) -> None:
148
144
  """\
kleinkram/cli/_project.py CHANGED
@@ -13,9 +13,7 @@ from kleinkram.config import get_shared_state
13
13
  from kleinkram.printing import print_project_info
14
14
  from kleinkram.utils import split_args
15
15
 
16
- project_typer = typer.Typer(
17
- no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}
18
- )
16
+ project_typer = typer.Typer(no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]})
19
17
 
20
18
 
21
19
  NOT_IMPLEMENTED_YET = """\
@@ -31,9 +29,7 @@ DELETE_HELP = "delete a project"
31
29
  @project_typer.command(help=CREATE_HELP)
32
30
  def create(
33
31
  project: str = typer.Option(..., "--project", "-p", help="project name"),
34
- description: str = typer.Option(
35
- ..., "--description", "-d", help="project description"
36
- ),
32
+ description: str = typer.Option(..., "--description", "-d", help="project description"),
37
33
  ) -> None:
38
34
  client = AuthenticatedClient()
39
35
  project_id = kleinkram.api.routes._create_project(client, project, description)
@@ -43,9 +39,7 @@ def create(
43
39
 
44
40
 
45
41
  @project_typer.command(help=INFO_HELP)
46
- def info(
47
- project: str = typer.Option(..., "--project", "-p", help="project id or name")
48
- ) -> None:
42
+ def info(project: str = typer.Option(..., "--project", "-p", help="project id or name")) -> None:
49
43
  project_ids, project_patterns = split_args([project])
50
44
  project_query = ProjectQuery(ids=project_ids, patterns=project_patterns)
51
45
 
@@ -57,40 +51,30 @@ def info(
57
51
  @project_typer.command(help=UPDATE_HELP)
58
52
  def update(
59
53
  project: str = typer.Option(..., "--project", "-p", help="project id or name"),
60
- description: Optional[str] = typer.Option(
61
- None, "--description", "-d", help="project description"
62
- ),
63
- new_name: Optional[str] = typer.Option(
64
- None, "--new-name", "-n", "--name", help="new project name"
65
- ),
54
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="project description"),
55
+ new_name: Optional[str] = typer.Option(None, "--new-name", "-n", "--name", help="new project name"),
66
56
  ) -> None:
67
57
  if description is None and new_name is None:
68
- raise typer.BadParameter(
69
- "nothing to update, provide --description or --new-name"
70
- )
58
+ raise typer.BadParameter("nothing to update, provide --description or --new-name")
71
59
 
72
60
  project_ids, project_patterns = split_args([project])
73
61
  project_query = ProjectQuery(ids=project_ids, patterns=project_patterns)
74
62
 
75
63
  client = AuthenticatedClient()
76
- project_id = get_project(client=client, query=project_query).id
77
- kleinkram.core.update_project(
78
- client=client, project_id=project_id, description=description, new_name=new_name
79
- )
64
+ project_id = get_project(client=client, query=project_query, exact_match=True).id
65
+ kleinkram.core.update_project(client=client, project_id=project_id, description=description, new_name=new_name)
80
66
 
81
67
  project_parsed = get_project(client, ProjectQuery(ids=[project_id]))
82
68
  print_project_info(project_parsed, pprint=get_shared_state().verbose)
83
69
 
84
70
 
85
71
  @project_typer.command(help=DELETE_HELP)
86
- def delete(
87
- project: str = typer.Option(..., "--project", "-p", help="project id or name")
88
- ) -> None:
72
+ def delete(project: str = typer.Option(..., "--project", "-p", help="project id or name")) -> None:
89
73
  project_ids, project_patterns = split_args([project])
90
74
  project_query = ProjectQuery(ids=project_ids, patterns=project_patterns)
91
75
 
92
76
  client = AuthenticatedClient()
93
- project_id = get_project(client=client, query=project_query).id
77
+ project_id = get_project(client=client, query=project_query, exact_match=True).id
94
78
  kleinkram.core.delete_project(client=client, project_id=project_id)
95
79
 
96
80