Skip to content

Commit 94dae7d

Browse files
authored
Expose verbose and rich tracebacks in CLI (#152)
* Fixing loading entrypoints Signed-off-by: Marc Romeyn <[email protected]> * Moving global callback to command and allowing overwrites in main Signed-off-by: Marc Romeyn <[email protected]> * Run linting Signed-off-by: Marc Romeyn <[email protected]> --------- Signed-off-by: Marc Romeyn <[email protected]> Signed-off-by: Marc Romeyn <[email protected]>
1 parent 0cc3d1d commit 94dae7d

File tree

3 files changed

+184
-24
lines changed

3 files changed

+184
-24
lines changed

examples/entrypoint/task.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,4 @@ def train_model(
7070

7171

7272
if __name__ == "__main__":
73-
run.cli.main(train_model)
73+
run.cli.main(train_model, cmd_defaults={"skip_confirmation": True})

src/nemo_run/cli/api.py

Lines changed: 137 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
from typer import Option, Typer, rich_utils
5252
from typer.core import TyperCommand, TyperGroup
5353
from typer.models import OptionInfo
54-
from typing_extensions import ParamSpec
54+
from typing_extensions import NotRequired, ParamSpec, TypedDict
5555

5656
from nemo_run.cli import devspace as devspace_cli
5757
from nemo_run.cli import experiment as experiment_cli
@@ -77,6 +77,7 @@
7777
NEMORUN_SKIP_CONFIRMATION: Optional[bool] = None
7878

7979
INCLUDE_WORKSPACE_FILE = os.environ.get("INCLUDE_WORKSPACE_FILE", "true").lower() == "true"
80+
NEMORUN_PRETTY_EXCEPTIONS = os.environ.get("NEMORUN_PRETTY_EXCEPTIONS", "false").lower() == "true"
8081

8182
logger = logging.getLogger(__name__)
8283
MAIN_ENTRYPOINT = None
@@ -211,11 +212,28 @@ def wrapper(f: F) -> F:
211212
return wrapper(fn)
212213

213214

215+
class CommandDefaults(TypedDict, total=False):
216+
direct: NotRequired[bool]
217+
dryrun: NotRequired[bool]
218+
load: NotRequired[str]
219+
yaml: NotRequired[str]
220+
repl: NotRequired[bool]
221+
detach: NotRequired[bool]
222+
skip_confirmation: NotRequired[bool]
223+
tail_logs: NotRequired[bool]
224+
rich_exceptions: NotRequired[bool]
225+
rich_traceback: NotRequired[bool]
226+
rich_locals: NotRequired[bool]
227+
rich_theme: NotRequired[str]
228+
verbose: NotRequired[bool]
229+
230+
214231
def main(
215232
fn: F,
216233
default_factory: Optional[Callable] = None,
217234
default_executor: Optional[Config[Executor]] = None,
218235
default_plugins: Optional[List[Config[Plugin]] | Config[Plugin]] = None,
236+
cmd_defaults: Optional[CommandDefaults] = None,
219237
**kwargs,
220238
):
221239
"""
@@ -280,7 +298,7 @@ def my_cli_function():
280298
MAIN_ENTRYPOINT = fn.cli_entrypoint
281299
return
282300

283-
fn.cli_entrypoint.main()
301+
fn.cli_entrypoint.main(cmd_defaults)
284302

285303
fn.cli_entrypoint.default_factory = _original_default_factory
286304
fn.cli_entrypoint.default_executor = _original_default_executor
@@ -507,7 +525,7 @@ def list_factories(type_or_namespace: Type | str) -> list[Callable]:
507525

508526

509527
def create_cli(
510-
add_verbose_callback: bool = True,
528+
add_verbose_callback: bool = False,
511529
nested_entrypoints_creation: bool = True,
512530
) -> Typer:
513531
app: Typer = Typer(pretty_exceptions_enable=False)
@@ -558,7 +576,7 @@ def create_cli(
558576
)
559577

560578
if add_verbose_callback:
561-
app.callback()(global_options)
579+
add_global_options(app)
562580

563581
return app
564582

@@ -577,9 +595,53 @@ def wrapped(self) -> Callable: ...
577595
def __factory__(self) -> bool: ...
578596

579597

580-
def global_options(verbose: bool = Option(False, "-v", "--verbose")):
598+
def add_global_options(app: Typer):
599+
@app.callback()
600+
def global_options(
601+
verbose: bool = Option(False, "-v", "--verbose"),
602+
rich_exceptions: bool = typer.Option(
603+
False, "--rich-exceptions/--no-rich-exceptions", help="Enable rich exception formatting"
604+
),
605+
rich_traceback: bool = typer.Option(
606+
False,
607+
"--rich-traceback-short/--rich-traceback-full",
608+
help="Control traceback verbosity",
609+
),
610+
rich_locals: bool = typer.Option(
611+
True,
612+
"--rich-show-locals/--rich-hide-locals",
613+
help="Toggle local variables in exceptions",
614+
),
615+
rich_theme: Optional[str] = typer.Option(
616+
None, "--rich-theme", help="Color theme (dark/light/monochrome)"
617+
),
618+
):
619+
_configure_global_options(
620+
app, rich_exceptions, rich_traceback, rich_locals, rich_theme, verbose
621+
)
622+
623+
return global_options
624+
625+
626+
def _configure_global_options(
627+
app: Typer,
628+
rich_exceptions=False,
629+
rich_traceback=True,
630+
rich_locals=True,
631+
rich_theme=None,
632+
verbose=False,
633+
):
581634
configure_logging(verbose)
582635

636+
app.pretty_exceptions_enable = rich_exceptions
637+
app.pretty_exceptions_short = False if rich_exceptions else rich_traceback
638+
app.pretty_exceptions_show_locals = True if rich_exceptions else rich_locals
639+
640+
if rich_theme:
641+
from rich.traceback import Traceback
642+
643+
Traceback.theme = rich_theme
644+
583645

584646
def configure_logging(verbose: bool):
585647
handler = RichHandler(
@@ -682,7 +744,10 @@ def _get_return_type(fn: Callable) -> Type:
682744
def _load_entrypoints():
683745
entrypoints = metadata.entry_points().select(group="nemo_run.cli")
684746
for ep in entrypoints:
685-
ep.load()
747+
try:
748+
ep.load()
749+
except Exception as e:
750+
print(f"Couldn't load entrypoint {ep.name}: {e}")
686751

687752

688753
def _search_workspace_file() -> str | None:
@@ -775,6 +840,7 @@ def cli_command(
775840
type: Literal["task", "experiment"] = "task",
776841
command_kwargs: Dict[str, Any] = {},
777842
is_main: bool = False,
843+
cmd_defaults: Optional[Dict[str, Any]] = None,
778844
):
779845
"""
780846
Create a CLI command for the given function.
@@ -820,19 +886,50 @@ def command(
820886
tail_logs: bool = typer.Option(
821887
False, "--tail-logs/--no-tail-logs", help="Tail logs after execution"
822888
),
889+
verbose: bool = Option(False, "-v", "--verbose", help="Enable verbose logging"),
890+
rich_exceptions: bool = typer.Option(
891+
False,
892+
"--rich-exceptions/--no-rich-exceptions",
893+
help="Enable rich exception formatting",
894+
),
895+
rich_traceback: bool = typer.Option(
896+
False,
897+
"--rich-traceback-short/--rich-traceback-full",
898+
help="Control traceback verbosity",
899+
),
900+
rich_locals: bool = typer.Option(
901+
True,
902+
"--rich-show-locals/--rich-hide-locals",
903+
help="Toggle local variables in exceptions",
904+
),
905+
rich_theme: Optional[str] = typer.Option(
906+
None, "--rich-theme", help="Color theme (dark/light/monochrome)"
907+
),
823908
ctx: typer.Context = typer.Context,
824909
):
910+
_cmd_defaults = cmd_defaults or {}
825911
self = cls(
826912
name=run_name or name,
827-
direct=direct,
828-
dryrun=dryrun,
913+
direct=direct or _cmd_defaults.get("direct", False),
914+
dryrun=dryrun or _cmd_defaults.get("dryrun", False),
829915
factory=factory or default_factory,
830-
load=load,
831-
yaml=yaml,
832-
repl=repl,
833-
detach=detach,
834-
skip_confirmation=skip_confirmation,
835-
tail_logs=tail_logs,
916+
load=load or _cmd_defaults.get("load", None),
917+
yaml=yaml or _cmd_defaults.get("yaml", None),
918+
repl=repl or _cmd_defaults.get("repl", False),
919+
detach=detach or _cmd_defaults.get("detach", False),
920+
skip_confirmation=skip_confirmation
921+
or _cmd_defaults.get("skip_confirmation", False),
922+
tail_logs=tail_logs or _cmd_defaults.get("tail_logs", False),
923+
)
924+
925+
print("Configuring global options")
926+
_configure_global_options(
927+
parent,
928+
rich_exceptions or _cmd_defaults.get("rich_exceptions", False),
929+
rich_traceback or _cmd_defaults.get("rich_traceback", True),
930+
rich_locals or _cmd_defaults.get("rich_locals", True),
931+
rich_theme or _cmd_defaults.get("rich_theme", None),
932+
verbose or _cmd_defaults.get("verbose", False),
836933
)
837934

838935
if default_executor:
@@ -851,8 +948,15 @@ def command(
851948
_load_workspace()
852949
self.cli_execute(fn, ctx.args, type)
853950
except RunContextError as e:
854-
typer.echo(f"Error: {str(e)}", err=True, color=True)
855-
raise typer.Exit(code=1)
951+
if not verbose:
952+
typer.echo(f"Error: {str(e)}", err=True, color=True)
953+
raise typer.Exit(code=1)
954+
raise # Re-raise the exception for verbose mode
955+
except Exception as e:
956+
if not verbose:
957+
typer.echo(f"Unexpected error: {str(e)}", err=True, color=True)
958+
raise typer.Exit(code=1)
959+
raise # Re-raise the exception for verbose mode
856960

857961
return command
858962

@@ -1285,9 +1389,14 @@ def parse_partial(self, args: List[str], **default_args) -> Partial[T]:
12851389
def cli(self, parent: typer.Typer):
12861390
self._add_command(parent)
12871391

1288-
def _add_command(self, typer_instance: typer.Typer, is_main: bool = False):
1392+
def _add_command(
1393+
self,
1394+
typer_instance: typer.Typer,
1395+
is_main: bool = False,
1396+
cmd_defaults: Optional[Dict[str, Any]] = None,
1397+
):
12891398
if self.enable_executor:
1290-
self._add_executor_command(typer_instance, is_main=is_main)
1399+
self._add_executor_command(typer_instance, is_main=is_main, cmd_defaults=cmd_defaults)
12911400
else:
12921401
self._add_simple_command(typer_instance, is_main=is_main)
12931402

@@ -1307,7 +1416,12 @@ def cmd_cli(ctx: typer.Context):
13071416
console.print(f"[bold red]Error: {str(e)}[/bold red]")
13081417
sys.exit(1)
13091418

1310-
def _add_executor_command(self, parent: typer.Typer, is_main: bool = False):
1419+
def _add_executor_command(
1420+
self,
1421+
parent: typer.Typer,
1422+
is_main: bool = False,
1423+
cmd_defaults: Optional[Dict[str, Any]] = None,
1424+
):
13111425
help = self.help_str
13121426
colored_help = None
13131427
if help:
@@ -1329,6 +1443,7 @@ class CLITaskCommand(EntrypointCommand):
13291443
cls=CLITaskCommand,
13301444
),
13311445
is_main=is_main,
1446+
cmd_defaults=cmd_defaults,
13321447
)
13331448

13341449
def _add_options_to_command(self, command: Callable):
@@ -1345,9 +1460,9 @@ def _execute_simple(self, args: List[str], console: Console):
13451460
fn.func.__io__ = config
13461461
fn()
13471462

1348-
def main(self):
1349-
app = typer.Typer(help=self.help_str, pretty_exceptions_enable=False)
1350-
self._add_command(app, is_main=True)
1463+
def main(self, cmd_defaults: Optional[Dict[str, Any]] = None):
1464+
app = typer.Typer(help=self.help_str, pretty_exceptions_enable=NEMORUN_PRETTY_EXCEPTIONS)
1465+
self._add_command(app, is_main=True, cmd_defaults=cmd_defaults)
13511466
app(standalone_mode=False)
13521467

13531468
def help(self, console=Console(), with_docs: bool = True):

test/cli/test_api.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,22 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
import os
1617
from configparser import ConfigParser
1718
from dataclasses import dataclass
1819
from typing import Annotated, List, Optional, Tuple, Union
1920
from unittest.mock import Mock, patch
2021

2122
import fiddle as fdl
2223
import pytest
24+
import typer
2325
from importlib_metadata import EntryPoint, EntryPoints
2426
from typer.testing import CliRunner
2527

2628
import nemo_run as run
2729
from nemo_run import cli, config
2830
from nemo_run.cli import api as cli_api
29-
from nemo_run.cli.api import Entrypoint, RunContext, create_cli
31+
from nemo_run.cli.api import Entrypoint, RunContext, add_global_options, create_cli
3032
from test.dummy_factory import DummyModel, dummy_entrypoint
3133

3234
_RUN_FACTORIES_ENTRYPOINT: str = """
@@ -730,3 +732,46 @@ def test_build_from_default_factory(self):
730732
assert isinstance(result["optimizer"], Optimizer)
731733
assert result["epochs"] == 40
732734
assert result["batch_size"] == 1024
735+
736+
737+
@pytest.fixture
738+
def runner():
739+
return CliRunner()
740+
741+
742+
class TestGlobalOptions:
743+
@pytest.fixture(autouse=True)
744+
def _setup(self):
745+
"""Setup for all test cases"""
746+
# Store original environment for cleanup
747+
self.original_env = os.environ.copy()
748+
yield
749+
# Restore environment after each test
750+
os.environ.clear()
751+
os.environ.update(self.original_env)
752+
753+
@pytest.fixture
754+
def app(self):
755+
app = typer.Typer()
756+
757+
# Add test command that throws an error
758+
@app.command()
759+
def error_command():
760+
"""Command that throws a test exception"""
761+
raise ValueError("Test error for exception handling")
762+
763+
# Add global options to test app
764+
add_global_options(app)
765+
return app
766+
767+
def test_verbose_logging(self, runner, app):
768+
"""Test verbose logging functionality"""
769+
with patch("nemo_run.cli.api.configure_logging") as mock_configure:
770+
# Test enabled
771+
runner.invoke(app, ["-v", "error-command"])
772+
mock_configure.assert_called_once_with(True)
773+
774+
# Test disabled
775+
mock_configure.reset_mock()
776+
runner.invoke(app, ["error-command"])
777+
mock_configure.assert_called_once_with(False)

0 commit comments

Comments
 (0)