8000 unify CLI error messages, add global error message handler (#8564) · codeperl/localstack@37e9ab3 · GitHub
[go: up one dir, main page]

Skip to content

Commit 37e9ab3

Browse files
authored
unify CLI error messages, add global error message handler (localstack#8564)
1 parent 0088cd7 commit 37e9ab3

File tree

4 files changed

+108
-35
lines changed

4 files changed

+108
-35
lines changed

localstack/cli/localstack.py

Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44
import sys
5+
import traceback
56
from typing import Dict, List, Optional
67

78
from localstack import config
@@ -20,6 +21,45 @@
2021
from .plugin import LocalstackCli, load_cli_plugins
2122

2223

24+
class ExceptionCmdHandler(click.Group):
25+
"""
26+
A Click group implementation which globally handles exceptions by:
27+
- Ignoring click exceptions (already handled)
28+
- Handling common exceptions (like DockerNotAvailable)
29+
- Wrapping all unexpected exceptions in a ClickException (for a unified error message)
30+
"""
31+
32+
def invoke(self, ctx: click.Context):
33+
try:
34+
return super(ExceptionCmdHandler, self).invoke(ctx)
35+
except click.exceptions.Exit:
36+
# raise Exit exceptions unmodified (e.g., raised on --help)
37+
raise
38+
except click.ClickException:
39+
# don't handle ClickExceptions, just reraise
40+
if ctx and ctx.params.get("debug"):
41+
click.echo(traceback.format_exc())
42+
raise
43+
except Exception as e:
44+
if ctx and ctx.params.get("debug"):
45+
click.echo(traceback.format_exc())
46+
from localstack.utils.container_utils.container_client import (
47+
ContainerException,
48+
DockerNotAvailable,
49+
)
50+
51+
if isinstance(e, DockerNotAvailable):
52+
raise click.ClickException(
53+
"Docker could not be found on the system.\n"
54+
"Please make sure that you have a working docker environment on your machine."
55+
)
56+
elif isinstance(e, ContainerException):
57+
raise click.ClickException(e.message)
58+
else:
59+
# If we have a generic exception, we wrap it in a ClickException
60+
raise click.ClickException(str(e)) from e
61+
62+
2363
def create_with_plugins() -> LocalstackCli:
2464
"""
2565
Creates a LocalstackCli instance with all cli plugins loaded.
@@ -54,6 +94,7 @@ def _setup_cli_debug() -> None:
5494
@click.group(
5595
name="localstack",
5696
help="The LocalStack Command Line Interface (CLI)",
97+
cls=ExceptionCmdHandler,
5798
context_settings={
5899
# add "-h" as a synonym for "--help"
59100
# https://click.palletsprojects.com/en/8.1.x/documentation/#help-parameter-customization
@@ -149,19 +190,13 @@ def cmd_config_validate(file: str) -> None:
149190
- The docker-compose file is syntactically incorrect.
150191
- If the file contains common issues when configuring LocalStack.
151192
"""
152-
from rich.panel import Panel
153193

154194
from localstack.utils import bootstrap
155195

156-
try:
157-
if bootstrap.validate_localstack_config(file):
158-
console.print("[green]:heavy_check_mark:[/green] config valid")
159-
sys.exit(0)
160-
else:
161-
console.print("[red]:heavy_multiplication_x:[/red] validation error")
162-
sys.exit(1)
163-
except Exception as e:
164-
console.print(Panel(str(e), title="[red]Error[/red]", expand=False))
196+
if bootstrap.validate_localstack_config(file):
197+
console.print("[green]:heavy_check_mark:[/green] config valid")
198+
sys.exit(0)
199+
else:
165200
console.print("[red]:heavy_multiplication_x:[/red] validation error")
166201
sys.exit(1)
167202

@@ -307,11 +342,9 @@ def cmd_status_services(format_: str) -> None:
307342
if format_ == "json":
308343
console.print(json.dumps(services))
309344
except requests.ConnectionError:
310-
error = f"could not connect to LocalStack health endpoint at {url}"
311-
print_error(format_, error)
312345
if config.DEBUG:
313346
console.print_exception()
314-
sys.exit(1)
347+
raise click.ClickException(f"could not connect to LocalStack health endpoint at {url}")
315348

316349

317350
def _print_service_table(services: Dict[str, str]) -> None:
@@ -431,8 +464,9 @@ def cmd_stop() -> None:
431464
DOCKER_CLIENT.stop_container(container_name)
432465
console.print("container stopped: %s" % container_name)
433466
except NoSuchContainer:
434-
console.print("no such container: %s" % container_name)
435-
sys.exit(1)
467+
raise click.ClickException(
468+
f'Expected a running LocalStack container named "{container_name}", but found none'
469+
)
436470

437471

438472
@localstack.command(
@@ -539,7 +573,7 @@ def cmd_ssh() -> None:
539573

540574
if not DOCKER_CLIENT.is_container_running(config.MAIN_CONTAINER_NAME):
541575
raise click.ClickException(
542-
'Expected a running container named "%s", but found none' % config.MAIN_CONTAINER_NAME
576+
f'Expected a running LocalStack container named "{config.MAIN_CONTAINER_NAME}", but found none'
543577
)
544578
try:
545579
process = run("docker exec -it %s bash" % config.MAIN_CONTAINER_NAME, tty=True)
@@ -585,12 +619,10 @@ def cmd_update_localstack_cli() -> None:
585619
"""
586620
if is_frozen_bundle():
587621
# "update" can only be performed if running from source / in a non-frozen interpreter
588-
console.print(
589-
":heavy_multiplication_x: The LocalStack CLI can only update itself if installed via PIP. "
590-
"Please follow the instructions on https://docs.localstack.cloud/ to update your CLI.",
591-
style="bold red",
622+
raise click.ClickException(
623+
"The LocalStack CLI can only update itself if installed via PIP. "
624+
"Please follow the instructions on https://docs.localstack.cloud/ to update your CLI."
592625
)
593-
sys.exit(1)
594626

595627
import subprocess
596628
from subprocess import CalledProcessError
@@ -770,18 +802,6 @@ def print_profile() -> None:
770802
)
771803

772804

773-
def print_error(format_: str, error: str) -> None:
774-
if format_ == "table":
775-
symbol = "[bold][red]:heavy_multiplication_x: ERROR[/red][/bold]"
776-
console.print(f"{symbol}: {error}")
777-
if format_ == "plain":
778-
console.print(f"error={error}")
779-
if format_ == "dict":
780-
console.print({"error": error})
781-
if format_ == "json":
782-
console.print(json.dumps({"error": error}))
783-
784-
785805
def print_banner() -> None:
786806
print(BANNER)
787807

localstack/utils/bootstrap.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,10 @@ def shutdown_handler(*args):
618618
try:
619619
server.start()
620620
server.join()
621+
error = server.get_error()
622+
if error:
623+
# if the server failed, raise the error
624+
raise error
621625
except KeyboardInterrupt:
622626
print("ok, bye!")
623627
shutdown_handler()

tests/bootstrap/test_cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ def test_start_wait_stop(self, runner, container_client):
6969
with pytest.raises(requests.ConnectionError):
7070
requests.get(get_edge_url() + "/_localstack/health")
7171

72+
def test_start_already_running(self, runner, container_client):
73+
runner.invoke(cli, ["start", "-d"])
74+
runner.invoke(cli, ["wait", "-t", "180"])
75+
result = runner.invoke(cli, ["start"])
76+
assert container_exists(container_client, config.MAIN_CONTAINER_NAME)
77+
assert result.exit_code == 1
78+
assert "Error" in result.output
79+
assert "is already running" in result.output
80+
7281
def test_wait_timeout_raises_exception(self, runner, container_client):
7382
# assume a wait without start fails
7483
result = runner.invoke(cli, ["wait", "-t", "0.5"])

tests/unit/cli/test_cli.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from localstack.cli.localstack import localstack as cli
1616
from localstack.utils import testutil
1717
from localstack.utils.common import is_command_ 10669 available
18+
from localstack.utils.container_utils.container_client import ContainerException, DockerNotAvailable
1819

1920
cli: click.Group
2021

@@ -24,6 +25,37 @@ def runner():
2425
return CliRunner()
2526

2627

28+
@pytest.mark.parametrize(
29+
"exception,expected_message",
30+
[
31+
(KeyboardInterrupt(), "Aborted!"),
32+
(DockerNotAvailable(), "Docker could not be found on the system"),
33+
(ContainerException("example message"), "example message"),
34+
(click.ClickException("example message"), "example message"),
35+
(click.exceptions.Exit(code=1), ""),
36+
],
37+
)
38+
def test_error_handling(runner: CliRunner, monkeypatch, exception, expected_message):
39+
"""Test different globally handled exceptions, their status code, and error message."""
40+
41+
def mock_call():
42+
raise exception
43+
44+
from localstack.utils import bootstrap
45+
46+
monkeypatch.setattr(bootstrap, "start_infra_locally", mock_call)
47+
result = runner.invoke(cli, ["start", "--host"])
48+
assert result.exit_code == 1
49+
assert expected_message in result.output
50+
51+
52+
def test_error_handling_help(runner):
53+
"""Make sure the help command is not interpreted as an error (Exit exception is raised)."""
54+
result = runner.invoke(cli, ["-h"])
55+
assert result.exit_code == 0
56+
assert "Usage: localstack" in result.output
57+
58+
2759
def test_create_with_plugins(runner):
2860
localstack_cli = create_with_plugins()
2961
result = runner.invoke(localstack_cli.group, ["--version"])
@@ -40,7 +72,15 @@ def test_version(runner):
4072
def test_status_services_error(runner):
4173
result = runner.invoke(cli, ["status", "services"])
4274
assert result.exit_code == 1
43-
assert "ERROR" in result.output
75+
assert "Error" in result.output
76+
77+
78+
@pytest.mark.parametrize("command", ["ssh", "stop"])
79+
def test_container_not_runnin_error(runner, command):
80+
result = runner.invoke(cli, [command])
81+
assert result.exit_code == 1
82+
assert "Error" in result.output
83+
assert "Expected a running LocalStack container" in result.output
4484

4585

4686
def test_start_docker_is_default(runner, monkeypatch):
@@ -147,7 +187,7 @@ def test_validate_config_syntax_error(runner, monkeypatch, tmp_path):
147187
result = runner.invoke(cli, ["config", "validate", "--file", str(file)])
148188

149189
assert result.exit_code == 1
150-
assert "error" in result.output
190+
assert "Error" in result.output
151191

152192

153193
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)
0