diff --git a/cli/kleinkram/api/client.py b/cli/kleinkram/api/client.py index 4fd1d7852..7d826044d 100644 --- a/cli/kleinkram/api/client.py +++ b/cli/kleinkram/api/client.py @@ -22,6 +22,7 @@ from kleinkram.config import get_config from kleinkram.config import save_config from kleinkram.errors import NotAuthenticated +from kleinkram.errors import UpdateCLIVersion logger = logging.getLogger(__name__) @@ -122,7 +123,7 @@ def _send_request_with_kleinkram_headers(self, *args: Any, **kwargs: Any) -> htt # check version compatibility if response.status_code == 426: - raise kleinkram.errors.UpdateCLIVersion + raise UpdateCLIVersion() return response def request( diff --git a/cli/kleinkram/api/routes.py b/cli/kleinkram/api/routes.py index 36c9baa43..381bfffe3 100644 --- a/cli/kleinkram/api/routes.py +++ b/cli/kleinkram/api/routes.py @@ -487,7 +487,7 @@ def _update_mission(client: AuthenticatedClient, mission_id: UUID, *, tags: Dict resp = client.post(UPDATE_MISSION, json=payload) if resp.status_code == 404: - raise MissionNotFound + raise MissionNotFound(f"Mission not found: {mission_id}") if resp.status_code == 403: raise AccessDenied(f"cannot update mission: {mission_id}") diff --git a/cli/kleinkram/cli/_executions.py b/cli/kleinkram/cli/_executions.py index 5297be3e0..219f80f3a 100644 --- a/cli/kleinkram/cli/_executions.py +++ b/cli/kleinkram/cli/_executions.py @@ -1,16 +1,11 @@ from __future__ import annotations -import os -import re import sys -import tarfile import time from typing import List from typing import Optional from uuid import UUID -import httpx -import requests import typer import kleinkram.api.routes @@ -65,43 +60,26 @@ def launch( client = AuthenticatedClient() pprint = get_shared_state().verbose - try: - project_ids, project_patterns = split_args([project] if project else []) - project_query = ProjectQuery(ids=project_ids, patterns=project_patterns) - - mission_ids, mission_patterns = split_args([mission]) - mission_query = MissionQuery( - ids=mission_ids, - patterns=mission_patterns, - project_query=project_query, - ) + project_ids, project_patterns = split_args([project] if project else []) + project_query = ProjectQuery(ids=project_ids, patterns=project_patterns) + + mission_ids, mission_patterns = split_args([mission]) + mission_query = MissionQuery( + ids=mission_ids, + patterns=mission_patterns, + project_query=project_query, + ) - typer.echo("Submitting action...") + typer.echo("Submitting action...") + try: execution_uuid = kleinkram.core.launch_execution( client=client, mission_query=mission_query, template=template_name, ) - typer.secho(f"Action submitted. Execution ID: {execution_uuid}", fg=typer.colors.GREEN) - - except kleinkram.errors.MissionNotFound: - typer.secho(f"Error: Mission '{mission}' not found.", fg=typer.colors.RED) - raise typer.Exit(code=1) - except kleinkram.errors.InvalidMissionQuery: - typer.secho( - "Error: Mission query is ambiguous. Try specifying a project with -p.", - fg=typer.colors.RED, - ) - raise typer.Exit(code=1) - except kleinkram.errors.TemplateNotFound as e: - typer.secho(f"Error: {e}", fg=typer.colors.RED) - raise typer.Exit(code=1) - except httpx.HTTPStatusError as e: - typer.secho(f"Error submitting action: {e.response.text}", fg=typer.colors.RED) - raise typer.Exit(code=1) - except Exception as e: - typer.secho(f"An unexpected error occurred: {e}", fg=typer.colors.RED) - raise typer.Exit(code=1) + except kleinkram.errors.InvalidMissionQuery as e: + raise kleinkram.errors.InvalidMissionQuery("Mission query is ambiguous. Try specifying a project with -p.") from e + typer.secho(f"Action submitted. Execution ID: {execution_uuid}", fg=typer.colors.GREEN) if follow: exit_code = kleinkram.printing.follow_execution_logs(client, execution_uuid) @@ -148,15 +126,13 @@ def list_executions( project_id = None if project_uuid is not None: if not is_valid_uuid4(project_uuid): - typer.secho(f"Error: '{project_uuid}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{project_uuid}' is not a valid UUID.") project_id = UUID(project_uuid) mission_id = None if mission_uuid is not None: if not is_valid_uuid4(mission_uuid): - typer.secho(f"Error: '{mission_uuid}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{mission_uuid}' is not a valid UUID.") mission_id = UUID(mission_uuid) query = None @@ -180,20 +156,12 @@ def delete( """ if not is_valid_uuid4(execution): - typer.secho(f"Error: '{execution}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{execution}' is not a valid UUID.") execution_id = parse_uuid_like(execution) client = AuthenticatedClient() - try: - kleinkram.core.delete_execution(client=client, execution_id=execution_id) - typer.secho(f"Execution {execution_id} deleted successfully.", fg=typer.colors.GREEN) - except kleinkram.errors.ExecutionNotFound: - typer.secho(f"Error: Execution '{execution_id}' not found.", fg=typer.colors.RED) - raise typer.Exit(code=1) - except Exception as e: - typer.secho(f"An unexpected error occurred: {e}", fg=typer.colors.RED) - raise typer.Exit(code=1) + kleinkram.core.delete_execution(client=client, execution_id=execution_id) + typer.secho(f"Execution {execution_id} deleted successfully.", fg=typer.colors.GREEN) @executions_typer.command(name="info", help=INFO_HELP) @@ -204,8 +172,7 @@ def get_info( Get detailed information for a single execution. """ if not is_valid_uuid4(execution): - typer.secho(f"Error: '{execution}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{execution}' is not a valid UUID.") execution_id = parse_uuid_like(execution) client = AuthenticatedClient() @@ -222,8 +189,7 @@ def logs( Fetch and display logs for a specific execution. """ if not is_valid_uuid4(execution): - typer.secho(f"Error: '{execution}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{execution}' is not a valid UUID.") execution_id = parse_uuid_like(execution) client = AuthenticatedClient() @@ -274,21 +240,16 @@ def download_artifacts( Download the artifacts (.tar.gz) for a finished execution. """ if not is_valid_uuid4(execution): - typer.secho(f"Error: '{execution}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{execution}' is not a valid UUID.") execution_id = parse_uuid_like(execution) client = AuthenticatedClient() - try: - kleinkram.core.download_artifact( - client=client, - execution_id=execution_id, - output_dir=output_dir, - filename=filename, - extract=extract, - verbose=get_shared_state().verbose, - ) - except Exception as e: - typer.secho(f"Failed to download artifacts: {e}", fg=typer.colors.RED) - raise typer.Exit(1) + kleinkram.core.download_artifact( + client=client, + execution_id=execution_id, + output_dir=output_dir, + filename=filename, + extract=extract, + verbose=get_shared_state().verbose, + ) diff --git a/cli/kleinkram/cli/_file.py b/cli/kleinkram/cli/_file.py index 55c8763f5..6db52896e 100644 --- a/cli/kleinkram/cli/_file.py +++ b/cli/kleinkram/cli/_file.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import List from typing import Optional import typer @@ -11,8 +12,10 @@ from kleinkram.api.query import MissionQuery from kleinkram.api.query import ProjectQuery from kleinkram.api.routes import get_file +from kleinkram.api.routes import get_files from kleinkram.config import get_shared_state from kleinkram.printing import print_file_info +from kleinkram.printing import print_files from kleinkram.utils import split_args INFO_HELP = "get information about a file" @@ -80,3 +83,29 @@ def delete( client = AuthenticatedClient() file_parsed = get_file(client, file_query) kleinkram.core.delete_files(client=client, file_ids=[file_parsed.id]) + + +@file_typer.command(name="list", help="list files") +def list_files( + files: Optional[List[str]] = typer.Argument( + None, + help="file names, ids or patterns", + ), + projects: Optional[List[str]] = typer.Option(None, "--project", "-p", help="project name or id"), + missions: Optional[List[str]] = typer.Option(None, "--mission", "-m", help="mission name or id"), +) -> None: + file_ids, file_patterns = split_args(files or []) + mission_ids, mission_patterns = split_args(missions or []) + project_ids, project_patterns = split_args(projects or []) + + project_query = ProjectQuery(patterns=project_patterns, ids=project_ids) + mission_query = MissionQuery( + project_query=project_query, + ids=mission_ids, + patterns=mission_patterns, + ) + file_query = FileQuery(mission_query=mission_query, patterns=file_patterns, ids=file_ids) + + client = AuthenticatedClient() + parsed_files = list(get_files(client, file_query=file_query)) + print_files(parsed_files, pprint=get_shared_state().verbose) diff --git a/cli/kleinkram/cli/_list.py b/cli/kleinkram/cli/_list.py index 3c375bb3e..1c2e94032 100644 --- a/cli/kleinkram/cli/_list.py +++ b/cli/kleinkram/cli/_list.py @@ -5,24 +5,10 @@ import typer -from kleinkram.api.client import AuthenticatedClient -from kleinkram.api.query import FileQuery -from kleinkram.api.query import MissionQuery -from kleinkram.api.query import ProjectQuery -from kleinkram.api.routes import get_files -from kleinkram.api.routes import get_missions -from kleinkram.api.routes import get_projects -from kleinkram.config import get_shared_state -from kleinkram.printing import print_files -from kleinkram.printing import print_missions -from kleinkram.printing import print_projects -from kleinkram.utils import split_args - HELP = """\ List projects, missions, or files. """ - list_typer = typer.Typer(name="list", invoke_without_command=True, help=HELP, no_args_is_help=True) @@ -35,21 +21,9 @@ def files( projects: Optional[List[str]] = typer.Option(None, "--project", "-p", help="project name or id"), missions: Optional[List[str]] = typer.Option(None, "--mission", "-m", help="mission name or id"), ) -> None: - file_ids, file_patterns = split_args(files or []) - mission_ids, mission_patterns = split_args(missions or []) - project_ids, project_patterns = split_args(projects or []) + from kleinkram.cli._file import list_files - project_query = ProjectQuery(patterns=project_patterns, ids=project_ids) - mission_query = MissionQuery( - project_query=project_query, - ids=mission_ids, - patterns=mission_patterns, - ) - file_query = FileQuery(mission_query=mission_query, patterns=file_patterns, ids=file_ids) - - client = AuthenticatedClient() - parsed_files = list(get_files(client, file_query=file_query)) - print_files(parsed_files, pprint=get_shared_state().verbose) + list_files(files=files, projects=projects, missions=missions) @list_typer.command() @@ -57,28 +31,15 @@ def missions( projects: Optional[List[str]] = typer.Option(None, "--project", "-p", help="project name or id"), missions: Optional[List[str]] = typer.Argument(None, help="mission names"), ) -> None: - mission_ids, mission_patterns = split_args(missions or []) - project_ids, project_patterns = split_args(projects or []) - - project_query = ProjectQuery(ids=project_ids, patterns=project_patterns) - mission_query = MissionQuery( - ids=mission_ids, - patterns=mission_patterns, - project_query=project_query, - ) + from kleinkram.cli._mission import list_missions - client = AuthenticatedClient() - parsed_missions = list(get_missions(client, mission_query=mission_query)) - print_missions(parsed_missions, pprint=get_shared_state().verbose) + list_missions(projects=projects, missions=missions) @list_typer.command() def projects( projects: Optional[List[str]] = typer.Argument(None, help="project names"), ) -> None: - project_ids, project_patterns = split_args(projects or []) - project_query = ProjectQuery(patterns=project_patterns, ids=project_ids) + from kleinkram.cli._project import list_projects - client = AuthenticatedClient() - parsed_projects = list(get_projects(client, project_query=project_query)) - print_projects(parsed_projects, pprint=get_shared_state().verbose) + list_projects(projects=projects) diff --git a/cli/kleinkram/cli/_mission.py b/cli/kleinkram/cli/_mission.py index 7b6bd5651..5cddfd10b 100644 --- a/cli/kleinkram/cli/_mission.py +++ b/cli/kleinkram/cli/_mission.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import List from typing import Optional import typer @@ -11,10 +12,12 @@ from kleinkram.api.query import MissionQuery from kleinkram.api.query import ProjectQuery from kleinkram.api.routes import get_mission +from kleinkram.api.routes import get_missions from kleinkram.api.routes import get_project from kleinkram.config import get_shared_state from kleinkram.errors import InvalidMissionQuery from kleinkram.printing import print_mission_info +from kleinkram.printing import print_missions from kleinkram.utils import load_metadata from kleinkram.utils import split_args @@ -147,3 +150,23 @@ def prune( """ raise NotImplementedError("Not implemented yet") + + +@mission_typer.command(name="list", help="list missions") +def list_missions( + projects: Optional[List[str]] = typer.Option(None, "--project", "-p", help="project name or id"), + missions: Optional[List[str]] = typer.Argument(None, help="mission names"), +) -> None: + mission_ids, mission_patterns = split_args(missions or []) + project_ids, project_patterns = split_args(projects or []) + + project_query = ProjectQuery(ids=project_ids, patterns=project_patterns) + mission_query = MissionQuery( + ids=mission_ids, + patterns=mission_patterns, + project_query=project_query, + ) + + client = AuthenticatedClient() + parsed_missions = list(get_missions(client, mission_query=mission_query)) + print_missions(parsed_missions, pprint=get_shared_state().verbose) diff --git a/cli/kleinkram/cli/_project.py b/cli/kleinkram/cli/_project.py index e0bde8aff..1bb65703f 100644 --- a/cli/kleinkram/cli/_project.py +++ b/cli/kleinkram/cli/_project.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import List from typing import Optional import typer @@ -9,8 +10,10 @@ from kleinkram.api.client import AuthenticatedClient from kleinkram.api.query import ProjectQuery from kleinkram.api.routes import get_project +from kleinkram.api.routes import get_projects from kleinkram.config import get_shared_state from kleinkram.printing import print_project_info +from kleinkram.printing import print_projects from kleinkram.utils import split_args project_typer = typer.Typer(no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}) @@ -81,3 +84,15 @@ def delete(project: str = typer.Option(..., "--project", "-p", help="project id @project_typer.command(help=NOT_IMPLEMENTED_YET) def prune() -> None: raise NotImplementedError(NOT_IMPLEMENTED_YET) + + +@project_typer.command(name="list", help="list projects") +def list_projects( + projects: Optional[List[str]] = typer.Argument(None, help="project names"), +) -> None: + project_ids, project_patterns = split_args(projects or []) + project_query = ProjectQuery(patterns=project_patterns, ids=project_ids) + + client = AuthenticatedClient() + parsed_projects = list(get_projects(client, project_query=project_query)) + print_projects(parsed_projects, pprint=get_shared_state().verbose) diff --git a/cli/kleinkram/cli/_templates.py b/cli/kleinkram/cli/_templates.py index c6eaffb69..1a39ef78b 100644 --- a/cli/kleinkram/cli/_templates.py +++ b/cli/kleinkram/cli/_templates.py @@ -3,7 +3,6 @@ from typing import Optional from uuid import UUID -import httpx import typer import kleinkram.api.routes @@ -56,8 +55,7 @@ def list_templates_cli( def revisions(template: str = typer.Argument(..., metavar="TEMPLATE_ID", help="Template ID (UUID)")) -> None: client = AuthenticatedClient() if not is_valid_uuid4(template): - typer.secho(f"Error: '{template}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{template}' is not a valid UUID.") template_id = parse_uuid_like(template) revisions = list(kleinkram.api.routes.get_template_revisions(client=client, template_id=template_id)) @@ -82,8 +80,7 @@ def create_version( ) -> None: client = AuthenticatedClient() if not is_valid_uuid4(template): - typer.secho(f"Error: '{template}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{template}' is not a valid UUID.") template_id = parse_uuid_like(template) template_id = kleinkram.core.create_template_version( @@ -109,36 +106,22 @@ def create_version( @templates_typer.command(help=DELETE_HELP, name="delete") def delete(template: str = typer.Argument(..., metavar="TEMPLATE_ID", help="Template ID (UUID)")) -> None: if not is_valid_uuid4(template): - typer.secho(f"Error: '{template}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{template}' is not a valid UUID.") template_id = UUID(template) client = AuthenticatedClient() - try: - archived = kleinkram.core.delete_template(client=client, template_id=template_id) - if archived: - typer.secho( - f"Template {template_id} archived (executions exist).", - fg=typer.colors.GREEN, - ) - else: - typer.secho( - f"Template {template_id} deleted successfully.", - fg=typer.colors.GREEN, - ) - except kleinkram.errors.TemplateNotFound: - typer.secho(f"Error: Template '{template_id}' not found.", fg=typer.colors.RED) - raise typer.Exit(code=1) - except kleinkram.errors.TemplateDeletionError: - typer.secho("Error: Only the latest version of a template may be deleted", fg=typer.colors.RED) - raise typer.Exit(code=1) - except httpx.HTTPStatusError as e: - typer.secho(f"Error deleting template: {e.response.text}", fg=typer.colors.RED) - raise typer.Exit(code=1) - except Exception as e: - typer.secho(f"An unexpected error occurred: {e}", fg=typer.colors.RED) - raise typer.Exit(code=1) + archived = kleinkram.core.delete_template(client=client, template_id=template_id) + if archived: + typer.secho( + f"Template {template_id} archived (executions exist).", + fg=typer.colors.GREEN, + ) + else: + typer.secho( + f"Template {template_id} deleted successfully.", + fg=typer.colors.GREEN, + ) @templates_typer.command(help=CREATE_HELP, name="create") diff --git a/cli/kleinkram/cli/_triggers.py b/cli/kleinkram/cli/_triggers.py index 92279b809..083513c0a 100644 --- a/cli/kleinkram/cli/_triggers.py +++ b/cli/kleinkram/cli/_triggers.py @@ -56,8 +56,7 @@ def list_triggers_cli( if mission_uuid is not None: if not is_valid_uuid4(mission_uuid): - typer.secho(f"Error: '{mission_uuid}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{mission_uuid}' is not a valid UUID.") else: query.mission_uuid = parse_uuid_like(mission_uuid) @@ -75,8 +74,7 @@ def trigger_info_cli(trigger_uuid: str = typer.Argument(..., metavar="TRIGGER_UU client = AuthenticatedClient() if not is_valid_uuid4(trigger_uuid): - typer.secho(f"Error: '{trigger_uuid}' is not a valid UUID.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"'{trigger_uuid}' is not a valid UUID.") trigger = kleinkram.api.routes.get_trigger(client=client, trigger_uuid=parse_uuid_like(trigger_uuid)) @@ -113,20 +111,17 @@ def create_trigger_cli( match type_: case TriggerType.FILE: if not file_patterns: - typer.secho("Error: At least one --file-pattern is required for FILE triggers.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter("At least one --file-pattern is required for FILE triggers.") event = tuple(file_events) if file_events is not None else () config = FileConfig(patterns=tuple(file_patterns), event=event) case TriggerType.TIME: if cron_expression is None: - typer.secho("Error: --cron option is required for TIME triggers.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter("--cron option is required for TIME triggers.") config = TimeConfig(cron=cron_expression) case TriggerType.WEBHOOK: config = WebhookConfig() case _: - typer.secho(f"Error: Unsupported trigger type '{type_}'.", fg=typer.colors.RED) - raise typer.Exit(code=1) + raise typer.BadParameter(f"Unsupported trigger type '{type_}'.") trigger_uuid = kleinkram.core.create_trigger( client=client, diff --git a/cli/kleinkram/cli/app.py b/cli/kleinkram/cli/app.py index a047d5a38..5a0a78289 100644 --- a/cli/kleinkram/cli/app.py +++ b/cli/kleinkram/cli/app.py @@ -31,7 +31,7 @@ from kleinkram.cli._upload import upload_typer from kleinkram.cli._verify import verify_typer from kleinkram.cli.error_handling import ErrorHandledTyper -from kleinkram.cli.error_handling import display_error +from kleinkram.cli.error_handling import register_error_handlers from kleinkram.config import MAX_TABLE_SIZE from kleinkram.config import Config from kleinkram.config import check_config_compatibility @@ -104,12 +104,14 @@ def list_commands(self, ctx: Context) -> List[str]: no_args_is_help=True, ) +register_error_handlers(app) + app.add_typer(endpoint_typer, name="endpoint", rich_help_panel=CommandTypes.AUTH) app.add_typer(download_typer, name="download", rich_help_panel=CommandTypes.CORE) app.add_typer(upload_typer, name="upload", rich_help_panel=CommandTypes.CORE) app.add_typer(verify_typer, name="verify", rich_help_panel=CommandTypes.CORE) -app.add_typer(list_typer, name="list", rich_help_panel=CommandTypes.CORE) +app.add_typer(list_typer, name="list", hidden=True) app.add_typer(file_typer, name="file", rich_help_panel=CommandTypes.CRUD) app.add_typer(mission_typer, name="mission", rich_help_panel=CommandTypes.CRUD) @@ -119,19 +121,6 @@ def list_commands(self, ctx: Context) -> List[str]: app.add_typer(triggers_typer, name="triggers", rich_help_panel=CommandTypes.ACTION) -# attach error handler to app -@app.error_handler(Exception) -def base_handler(exc: Exception) -> int: - shared_state = get_shared_state() - - display_error(exc=exc, verbose=shared_state.verbose) - logger.error(format_traceback(exc)) - - if logging.getLogger().getEffectiveLevel() != logging.DEBUG: - return 1 - raise exc - - @app.command(rich_help_panel=CommandTypes.AUTH) def login( oAuthProvider: str = typer.Option( diff --git a/cli/kleinkram/cli/error_handling.py b/cli/kleinkram/cli/error_handling.py index 6a48604f4..77eb2749c 100644 --- a/cli/kleinkram/cli/error_handling.py +++ b/cli/kleinkram/cli/error_handling.py @@ -1,19 +1,27 @@ from __future__ import annotations +import logging import sys from collections import OrderedDict from typing import Any from typing import Callable +from typing import Optional from typing import Type +import httpx import typer from click import ClickException from rich.console import Console from rich.panel import Panel +from kleinkram.config import get_config +from kleinkram.config import get_shared_state +from kleinkram.utils import format_traceback from kleinkram.utils import upper_camel_case_to_words -ExceptionHandler = Callable[[Exception], int] +logger = logging.getLogger(__name__) + +ExceptionHandler = Callable[[Any], int] class ErrorHandledTyper(typer.Typer): @@ -47,19 +55,153 @@ def __call__(self, *args: Any, **kwargs: Any) -> int: raise -def display_error(*, exc: Exception, verbose: bool) -> None: +def display_error( + *, + exc: Exception, + verbose: bool, + title: Optional[str] = None, + message: Optional[str] = None, +) -> None: split_exc_name = upper_camel_case_to_words(type(exc).__name__) + exc_name = title or " ".join(split_exc_name) + body = message or str(exc) if verbose: panel = Panel( - str(exc), # get the error message - title=" ".join(split_exc_name), + body, + title=exc_name, style="red", border_style="bold", ) Console(file=sys.stderr).print(panel) else: - text = f"{type(exc).__name__}" - if str(exc): - text += f": {exc}" - print(text, file=sys.stderr) + if title is not None or message is not None: + print(body, file=sys.stderr) + else: + text = f"{type(exc).__name__}" + if str(exc): + text += f": {exc}" + print(text, file=sys.stderr) + + +def handle_request_error(exc: httpx.RequestError) -> int: + shared_state = get_shared_state() + config = get_config() + + selected_endpoint = config.selected_endpoint + endpoint_url = config.endpoint.api + + if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)): + title = "Connection Failed" + msg = ( + f"Unable to connect to the Kleinkram backend server at:\n" + f" [bold cyan]{endpoint_url}[/bold cyan] (Endpoint name: '{selected_endpoint}')\n\n" + f"Please verify that:\n" + f" 1. The server is online and you have a stable network connection.\n" + f" 2. You are connected to the correct VPN/network if required.\n" + f" 3. Your endpoint configuration is correct (run [bold]klein endpoint show[/bold] to inspect)." + ) + quiet_msg = f"Error: Connection failed to {endpoint_url}" + + elif isinstance(exc, (httpx.ReadTimeout, httpx.WriteTimeout, httpx.TimeoutException)): + title = "Request Timeout" + msg = ( + f"The request to the Kleinkram backend server at:\n" + f" [bold cyan]{endpoint_url}[/bold cyan] timed out.\n\n" + f"This can happen if:\n" + f" 1. The server is heavily loaded or performing a slow operation.\n" + f" 2. The database query or files being processed are very large." + ) + quiet_msg = f"Error: Request to {endpoint_url} timed out" + + else: + title = "Network Error" + msg = ( + f"A network error occurred while communicating with the backend at:\n" + f" [bold cyan]{endpoint_url}[/bold cyan]\n\n" + f"Details: {exc}" + ) + quiet_msg = f"Error: Network error on {endpoint_url} ({exc})" + + display_error( + exc=exc, + verbose=shared_state.verbose, + title=title, + message=msg if shared_state.verbose else quiet_msg, + ) + + logger.error(f"Network error on {endpoint_url}: {exc}") + logger.error(format_traceback(exc)) + + if shared_state.debug: + raise exc + return 1 + + +def handle_generic_exception(exc: Exception) -> int: + shared_state = get_shared_state() + + display_error(exc=exc, verbose=shared_state.verbose) + logger.error(format_traceback(exc)) + + if not shared_state.debug: + return 1 + raise exc + + +def handle_http_status_error(exc: httpx.HTTPStatusError) -> int: + shared_state = get_shared_state() + config = get_config() + endpoint_url = config.endpoint.api + + if exc.response.status_code in (502, 504): + title = "Server Timeout" + msg = ( + f"The request to the Kleinkram backend server at:\n" + f" [bold cyan]{endpoint_url}[/bold cyan] timed out or returned a gateway error.\n\n" + f"This can happen if:\n" + f" 1. The server is heavily loaded or performing a slow operation.\n" + f" 2. The database query or files being processed are very large.\n" + f" 3. You are experiencing a slow or unstable internet connection." + ) + quiet_msg = f"Error: Server at {endpoint_url} timed out (HTTP {exc.response.status_code})" + elif exc.response.status_code == 500: + title = "Internal Server Error" + msg = ( + f"The Kleinkram backend server at:\n" + f" [bold cyan]{endpoint_url}[/bold cyan] encountered an internal error.\n\n" + f"Please try again later or contact the administrator." + ) + quiet_msg = f"Error: Internal server error on {endpoint_url} (HTTP 500)" + else: + title = f"HTTP Error {exc.response.status_code}" + msg = ( + f"The Kleinkram backend server at:\n" + f" [bold cyan]{endpoint_url}[/bold cyan] returned an error.\n\n" + f"Details: {exc}" + ) + quiet_msg = f"Error: HTTP {exc.response.status_code} on {endpoint_url}" + + display_error( + exc=exc, + verbose=shared_state.verbose, + title=title, + message=msg if shared_state.verbose else quiet_msg, + ) + + logger.error(f"HTTP error {exc.response.status_code} on {endpoint_url}: {exc}") + logger.error(format_traceback(exc)) + + if shared_state.debug: + raise exc + return 1 + + +def register_error_handlers(app: ErrorHandledTyper) -> None: + """Register all CLI error handlers. Note: since ErrorHandledTyper dispatches + handlers in reverse order of registration, the most generic Exception handler + must be registered first, and more specific handlers (like httpx.RequestError) later. + """ + app.error_handler(Exception)(handle_generic_exception) + app.error_handler(httpx.RequestError)(handle_request_error) + app.error_handler(httpx.HTTPStatusError)(handle_http_status_error) diff --git a/cli/kleinkram/printing.py b/cli/kleinkram/printing.py index 5110200d6..fb9a4b25f 100644 --- a/cli/kleinkram/printing.py +++ b/cli/kleinkram/printing.py @@ -172,20 +172,17 @@ def missions_to_table(missions: Sequence[Mission]) -> Table: table.add_column("size") # order by project, name - missions_tp: List[Tuple[str, str, Mission]] = [] - for mission in missions: - missions_tp.append((mission.project_name, mission.name, mission)) - missions_tp.sort() + missions_sorted = sorted(missions, key=lambda m: (m.project_name, m.name)) - if not missions_tp: + if not missions_sorted: return table last_project: Optional[str] = None max_table_size = get_shared_state().max_table_size - for project, _, mission in missions_tp[:max_table_size]: + for mission in missions_sorted[:max_table_size]: # add delimiter row if project changes - if last_project is not None and last_project != project: + if last_project is not None and last_project != mission.project_name: table.add_section() - last_project = project + last_project = mission.project_name table.add_row( mission.project_name, @@ -195,8 +192,8 @@ def missions_to_table(missions: Sequence[Mission]) -> Table: format_bytes(mission.size), ) - if len(missions_tp) > max_table_size: - _add_placeholder_row(table, skipped=len(missions_tp) - max_table_size) + if len(missions_sorted) > max_table_size: + _add_placeholder_row(table, skipped=len(missions_sorted) - max_table_size) return table @@ -211,20 +208,17 @@ def files_to_table(files: Sequence[File], *, title: str = "files", delimiters: b table.add_column("categories") # order by project, mission, name - files_tp: List[Tuple[str, str, str, File]] = [] - for file in files: - files_tp.append((file.project_name, file.mission_name, file.name, file)) - files_tp.sort() + files_sorted = sorted(files, key=lambda f: (f.project_name, f.mission_name, f.name)) - if not files_tp: + if not files_sorted: return table last_mission: Optional[str] = None max_table_size = get_shared_state().max_table_size - for _, mission, _, file in files_tp[:max_table_size]: - if last_mission is not None and last_mission != mission and delimiters: + for file in files_sorted[:max_table_size]: + if last_mission is not None and last_mission != file.mission_name and delimiters: table.add_section() - last_mission = mission + last_mission = file.mission_name table.add_row( file.project_name, @@ -236,8 +230,8 @@ def files_to_table(files: Sequence[File], *, title: str = "files", delimiters: b ", ".join(file.categories), ) - if len(files_tp) > max_table_size: - _add_placeholder_row(table, skipped=len(files_tp) - max_table_size) + if len(files_sorted) > max_table_size: + _add_placeholder_row(table, skipped=len(files_sorted) - max_table_size) return table diff --git a/cli/tests/test_end_to_end.py b/cli/tests/test_end_to_end.py index 926ba9274..fa5cab43f 100644 --- a/cli/tests/test_end_to_end.py +++ b/cli/tests/test_end_to_end.py @@ -65,17 +65,26 @@ def test_upload_verify_update_download_mission(project, tmp_path, api): @pytest.mark.slow def test_list_files(project, mission, api): assert api + # Legacy commands assert run_cmd(f"{CLI} list files -p {project.name}") == 0 assert run_cmd(f"{CLI} list files -p {project.name} -m {mission.name}") == 0 assert run_cmd(f"{CLI} list files") == 0 assert run_cmd(f"{CLI} list files -p {project.name}") == 0 assert run_cmd(f'{CLI} list files -p "*" -m "*" "*"') == 0 + # New commands + assert run_cmd(f"{CLI} file list -p {project.name}") == 0 + assert run_cmd(f"{CLI} file list -p {project.name} -m {mission.name}") == 0 + assert run_cmd(f"{CLI} file list") == 0 + assert run_cmd(f"{CLI} file list -p {project.name}") == 0 + assert run_cmd(f'{CLI} file list -p "*" -m "*" "*"') == 0 + @pytest.mark.slow def test_list_missions(api, project, mission): assert api + # Legacy commands assert run_cmd(f"{CLI} list missions -p {project.name} {mission.name}") == 0 assert run_cmd(f"{CLI} list missions -p {project.name} {secrets.token_hex(8)}") == 0 assert run_cmd(f"{CLI} list missions -p {project.name} {mission.id}") == 0 @@ -89,16 +98,38 @@ def test_list_missions(api, project, mission): assert run_cmd(f"{CLI} list missions") == 0 assert run_cmd(f'{CLI} list missions -p "*" "*"') == 0 + # New commands + assert run_cmd(f"{CLI} mission list -p {project.name} {mission.name}") == 0 + assert run_cmd(f"{CLI} mission list -p {project.name} {secrets.token_hex(8)}") == 0 + assert run_cmd(f"{CLI} mission list -p {project.name} {mission.id}") == 0 + assert run_cmd(f"{CLI} mission list {secrets.token_hex(8)}") == 0 + assert run_cmd(f"{CLI} mission list {mission.id}") == 0 + assert run_cmd(f"{CLI} mission list {mission.name}") == 0 + + assert run_cmd(f"{CLI} mission list -p {project.name}") == 0 + assert run_cmd(f"{CLI} mission list -p {project.id}") == 0 + assert run_cmd(f"{CLI} mission list -p {secrets.token_hex(8)}") == 0 + assert run_cmd(f"{CLI} mission list") == 0 + assert run_cmd(f'{CLI} mission list -p "*" "*"') == 0 + @pytest.mark.slow def test_list_projects(api, project): assert api + # Legacy commands assert run_cmd(f"{CLI} list projects") == 0 assert run_cmd(f"{CLI} list projects {project.name}") == 0 assert run_cmd(f"{CLI} list projects {secrets.token_hex(8)}") == 0 assert run_cmd(f"{CLI} list projects {project.id}") == 0 assert run_cmd(f'{CLI} list projects "*"') == 0 + # New commands + assert run_cmd(f"{CLI} project list") == 0 + assert run_cmd(f"{CLI} project list {project.name}") == 0 + assert run_cmd(f"{CLI} project list {secrets.token_hex(8)}") == 0 + assert run_cmd(f"{CLI} project list {project.id}") == 0 + assert run_cmd(f'{CLI} project list "*"') == 0 + @pytest.mark.slow def test_trigger_cli_operations(empty_mission, action_template, api): diff --git a/cli/tests/test_error_handling.py b/cli/tests/test_error_handling.py index 6e2099e3b..30ecbc5d4 100644 --- a/cli/tests/test_error_handling.py +++ b/cli/tests/test_error_handling.py @@ -1,8 +1,15 @@ from __future__ import annotations +from unittest.mock import MagicMock +from unittest.mock import patch + +import httpx import pytest from kleinkram.cli.error_handling import display_error +from kleinkram.cli.error_handling import handle_generic_exception +from kleinkram.cli.error_handling import handle_http_status_error +from kleinkram.cli.error_handling import handle_request_error class MyException(Exception): @@ -42,3 +49,165 @@ def test_display_error_verbose(capsys): "│ hello │\n" "╰──────────────────────────────────────────────────────────────────────────────╯\n" ) + + +def _run_handle_request_error(capsys, exc, verbose, debug, expected_title, expected_texts, should_raise): + mock_config = MagicMock() + mock_config.selected_endpoint = "my-dev" + mock_config.endpoint.api = "http://my-api-url.com" + + mock_state = MagicMock() + mock_state.verbose = verbose + mock_state.debug = debug + + with patch("kleinkram.cli.error_handling.get_config", return_value=mock_config), patch( + "kleinkram.cli.error_handling.get_shared_state", return_value=mock_state + ): + + if should_raise: + with pytest.raises(type(exc)): + handle_request_error(exc) + else: + exit_code = handle_request_error(exc) + assert exit_code == 1 + out, err = capsys.readouterr() + assert out == "" + if expected_title: + assert expected_title in err + if expected_texts: + if isinstance(expected_texts, str): + assert expected_texts in err + else: + for text in expected_texts: + assert text in err + + +def test_handle_generic_exception_verbose(capsys): + mock_state = MagicMock() + mock_state.verbose = True + mock_state.debug = False + + exc = Exception("something went wrong") + + with patch("kleinkram.cli.error_handling.get_shared_state", return_value=mock_state): + exit_code = handle_generic_exception(exc) + + assert exit_code == 1 + out, err = capsys.readouterr() + assert "something went wrong" in err + + +def test_handle_generic_exception_debug(): + mock_state = MagicMock() + mock_state.verbose = True + mock_state.debug = True + + exc = Exception("something went wrong") + + with patch("kleinkram.cli.error_handling.get_shared_state", return_value=mock_state): + with pytest.raises(Exception, match="something went wrong"): + handle_generic_exception(exc) + + +@pytest.mark.parametrize( + "exc, verbose, debug, expected_title, expected_texts, should_raise", + [ + ( + httpx.ConnectError("refused"), + True, + False, + "Connection Failed", + ["Unable to connect", "http://my-api-url.com", "my-dev"], + False, + ), + (httpx.ConnectError("refused"), False, False, None, "Error: Connection failed to http://my-api-url.com", False), + (httpx.ConnectError("refused"), True, True, None, None, True), + (httpx.ReadTimeout("timeout"), True, False, "Request Timeout", ["timed out", "http://my-api-url.com"], False), + (httpx.ReadTimeout("timeout"), False, False, None, "Error: Request to http://my-api-url.com timed out", False), + ( + httpx.RequestError("Invalid", request=MagicMock()), + True, + False, + "Network Error", + ["Details: Invalid", "http://my-api-url.com"], + False, + ), + ( + httpx.RequestError("Invalid", request=MagicMock()), + False, + False, + None, + "Error: Network error on http://my-api-url.com (Invalid)", + False, + ), + ( + httpx.ReadError("connection reset", request=MagicMock()), + True, + False, + "Network Error", + ["Details: connection reset", "http://my-api-url.com"], + False, + ), + ( + httpx.WriteError("write failed", request=MagicMock()), + True, + False, + "Network Error", + ["Details: write failed", "http://my-api-url.com"], + False, + ), + ], +) +def test_handle_request_error_parameterized(capsys, exc, verbose, debug, expected_title, expected_texts, should_raise): + _run_handle_request_error(capsys, exc, verbose, debug, expected_title, expected_texts, should_raise) + + +def _run_handle_http_status_error(capsys, exc, verbose, debug, expected_title, expected_texts, should_raise): + mock_config = MagicMock() + mock_config.selected_endpoint = "my-dev" + mock_config.endpoint.api = "http://my-api-url.com" + + mock_state = MagicMock() + mock_state.verbose = verbose + mock_state.debug = debug + + with patch("kleinkram.cli.error_handling.get_config", return_value=mock_config), patch( + "kleinkram.cli.error_handling.get_shared_state", return_value=mock_state + ): + if should_raise: + with pytest.raises(type(exc)): + handle_http_status_error(exc) + else: + exit_code = handle_http_status_error(exc) + assert exit_code == 1 + out, err = capsys.readouterr() + assert out == "" + if expected_title: + assert expected_title in err + if expected_texts: + if isinstance(expected_texts, str): + assert expected_texts in err + else: + for text in expected_texts: + assert text in err + + +@pytest.mark.parametrize( + "status_code, verbose, debug, expected_title, expected_texts, should_raise", + [ + (504, True, False, "Server Timeout", ["timed out or returned a gateway error", "http://my-api-url.com"], False), + (504, False, False, None, "Error: Server at http://my-api-url.com timed out (HTTP 504)", False), + (500, True, False, "Internal Server Error", ["encountered an internal error", "http://my-api-url.com"], False), + (500, False, False, None, "Error: Internal server error on http://my-api-url.com (HTTP 500)", False), + (400, True, False, "HTTP Error 400", ["returned an error", "http://my-api-url.com"], False), + (400, False, False, None, "Error: HTTP 400 on http://my-api-url.com", False), + (504, True, True, None, None, True), + ], +) +def test_handle_http_status_error_parameterized( + capsys, status_code, verbose, debug, expected_title, expected_texts, should_raise +): + request = httpx.Request("GET", "http://my-api-url.com") + response = httpx.Response(status_code, request=request) + exc = httpx.HTTPStatusError("error", request=request, response=response) + _run_handle_http_status_error(capsys, exc, verbose, debug, expected_title, expected_texts, should_raise) diff --git a/packages/validation/src/skip-validation.ts b/packages/validation/src/skip-validation.ts index 9bbdf2876..70ca95025 100644 --- a/packages/validation/src/skip-validation.ts +++ b/packages/validation/src/skip-validation.ts @@ -16,11 +16,13 @@ export function IsSkip(validationOptions?: ValidationOptions) { // Check if the value is an integer and within range return ( - Number.isInteger(value) && value >= 0 && value <= 9999 + Number.isInteger(value) && + value >= 0 && + value <= Number.MAX_SAFE_INTEGER ); }, defaultMessage() { - return 'Skip must be an optional integer between 0 and 9999'; // Custom error message + return 'Skip must be an optional non-negative integer'; // Custom error message }, }, });