Repository: aiidalab/aiidalab-launch (language: python)

Issue:
Hide stack traces unless debug mode is enabled.
I always find it jarring when as a user a python CLI tool spews a full stack trace into my terminal. More importantly, I always have to squint to find tha actual error.

I wonder whether it wouldn't be a more user friendly to disable stack traces unless a debug mode is enabled? Something.like below perhaps?
https://stackoverflow.com/a/27674608

I am asking both as a user of this specific package, but also in general as a newbie Python dev about what's the best practice here.


Discussion / hints:
I agree, there should ideally never be a traceback visible to the user which is why all expected exceptions should be caught and handled in https://github.com/aiidalab/aiidalab-launch/blob/main/aiidalab_launch/__main__.py . 

However, it is of course possible that something "falls through the cracks", and setting the tracebacklimit to 0 could be a viable solution to address that. But I assume that the error message would potentially be even more cryptic, the traceback dump at least indicates that something went "horribly wrong". Using a custom `sys.excepthook` exception handler might be a better option, where we can at least show a message that explains that the program crashed with an unexpected error.

Maybe it would be worthwhile to find an example of such an implementation in a "popular" CLI application that we could compare against?

Did you encounter a traceback with the package or is this just a general question?
Here's an example of what happens when  you provide an non-existent profile name.

```sh
$ aiidalab-launch profiles edit invalid
Traceback (most recent call last):
  File "/home/bl22441/.local/bin/aiidalab-launch", line 8, in <module>
    sys.exit(cli())
  File "/home/bl22441/.local/pipx/venvs/aiidalab_launch/lib/python3.8/site-packages/click/core.py", line 1128, in __call__
    return self.main(*args, **kwargs)
  File "/home/bl22441/.local/pipx/venvs/aiidalab_launch/lib/python3.8/site-packages/click/core.py", line 1053, in main
    rv = self.invoke(ctx)
  File "/home/bl22441/.local/pipx/venvs/aiidalab_launch/lib/python3.8/site-packages/click/core.py", line 1659, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/home/bl22441/.local/pipx/venvs/aiidalab_launch/lib/python3.8/site-packages/click/core.py", line 1659, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/home/bl22441/.local/pipx/venvs/aiidalab_launch/lib/python3.8/site-packages/click/core.py", line 1395, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/home/bl22441/.local/pipx/venvs/aiidalab_launch/lib/python3.8/site-packages/click/core.py", line 754, in invoke
    return __callback(*args, **kwargs)
  File "/home/bl22441/.local/pipx/venvs/aiidalab_launch/lib/python3.8/site-packages/click/decorators.py", line 84, in new_func
    return ctx.invoke(f, obj, *args, **kwargs)
  File "/home/bl22441/.local/pipx/venvs/aiidalab_launch/lib/python3.8/site-packages/click/core.py", line 754, in invoke
    return __callback(*args, **kwargs)
  File "/home/bl22441/.local/pipx/venvs/aiidalab_launch/lib/python3.8/site-packages/aiidalab_launch/__main__.py", line 245, in edit_profile
    current_profile = app_state.config.get_profile(profile)
  File "/home/bl22441/.local/pipx/venvs/aiidalab_launch/lib/python3.8/site-packages/aiidalab_launch/config.py", line 65, in get_profile
    raise ValueError(f"Did not find profile with name '{name}'.")
ValueError: Did not find profile with name 'invalid'.
```

It would be great if only the last line was printed, or if we need more context to limit the traceback depth to 1 for example.

Also all the additional validations that we added for extra mounts throw ValueError which I think is not handled in `__main__.py`
@danielhollas Would you be open to trying to implement this?
@csadorf sure, I can give it a shot, feel free to assign this to me. Although it's a low priority for now so if somebody else wants to go ahead feel free.

Fix patch:
diff --git a/aiidalab_launch/__main__.py b/aiidalab_launch/__main__.py
index a71c5f7..552f013 100644
--- a/aiidalab_launch/__main__.py
+++ b/aiidalab_launch/__main__.py
@@ -8,6 +8,7 @@
 import getpass
 import logging
 import socket
+import sys
 from pathlib import Path
 from textwrap import wrap
 
@@ -67,6 +68,13 @@
 pass_app_state = click.make_pass_decorator(ApplicationState, ensure=True)
 
 
+def exception_handler(exception_type, exception, traceback):  # noqa: U100
+    click.echo(f"Unexpected {exception_type.__name__}: {exception}", err=True)
+    click.echo(
+        "Use verbose mode `aiidalab-launch --verbose` to see full stack trace", err=True
+    )
+
+
 def with_profile(cmd):
     def callback(ctx, param, value):  # noqa: U100
         app_state = ctx.ensure_object(ApplicationState)
@@ -100,6 +108,10 @@ def cli(app_state, verbose):
             err=True,
         )
 
+    # Hide stack trace by default.
+    if verbose == 0:
+        sys.excepthook = exception_handler
+
     LOGGER.info(f"Configuration file path: {app_state.config_path}")
 
     latest_version = get_latest_version(timeout=0.1)


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 44fe80d..36c155d 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -51,6 +51,23 @@ def test_version_verbose_logging():
     assert "Verbose logging is enabled." in result.output.strip()
 
 
+def test_invalid_profile_name_throws():
+    """
+    Arrange/Act: Run `profiles show invalid` subcommand.
+    Assert:  The command throws an exception due to invalid profile name.
+    """
+    runner: CliRunner = CliRunner()
+    with pytest.raises(ValueError):
+        result: Result = runner.invoke(
+            cli.cli, ["profiles", "show", "invalid"], catch_exceptions=False
+        )
+    result: Result = runner.invoke(
+        cli.cli,
+        ["profiles", "show", "invalid"],
+    )
+    assert isinstance(result.exception, ValueError)
+
+
 def test_list_profiles():
     runner: CliRunner = CliRunner()
     result: Result = runner.invoke(cli.cli, ["profiles", "list"])