Skip to content

Commit f435e5e

Browse files
committed
address interlinked issues #36, #35 and #27
1 parent a270e6e commit f435e5e

File tree

7 files changed

+195
-64
lines changed

7 files changed

+195
-64
lines changed

django_typer/__init__.py

Lines changed: 47 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@
107107
)
108108
from .utils import _command_context, traceback_config, with_typehint
109109

110-
VERSION = (1, 0, 1)
110+
VERSION = (1, 0, 2)
111111

112112
__title__ = "Django Typer"
113113
__version__ = ".".join(str(i) for i in VERSION)
@@ -298,6 +298,7 @@ class _ParsedArgs(SimpleNamespace): # pylint: disable=too-few-public-methods
298298
def __init__(self, args: t.Sequence[t.Any], **kwargs):
299299
super().__init__(**kwargs)
300300
self.args = args
301+
self.traceback = kwargs.get("traceback", TyperCommand._traceback)
301302

302303
def _get_kwargs(self):
303304
return {"args": self.args, **COMMON_DEFAULTS}
@@ -1451,23 +1452,11 @@ def option_strings(self) -> t.List[str]:
14511452
prog_name: str
14521453
subcommand: str
14531454

1454-
missing_args_message: str = "Missing parameter: {parameter}"
1455-
called_from_command_line: t.Optional[bool] = None
1456-
1457-
def __init__(
1458-
self,
1459-
django_command: "TyperCommand",
1460-
prog_name,
1461-
subcommand,
1462-
missing_args_message: str = missing_args_message,
1463-
called_from_command_line: t.Optional[bool] = None,
1464-
):
1455+
def __init__(self, django_command: "TyperCommand", prog_name, subcommand):
14651456
self._actions = []
14661457
self.django_command = django_command
14671458
self.prog_name = prog_name
14681459
self.subcommand = subcommand
1469-
self.missing_args_message = missing_args_message
1470-
self.called_from_command_line = called_from_command_line
14711460

14721461
def populate_params(node: CommandNode) -> None:
14731462
for param in node.click_command.params:
@@ -1505,29 +1494,18 @@ def parse_args(self, args=None, namespace=None) -> _ParsedArgs:
15051494
(this is ignored for TyperCommands but is required by the django
15061495
base class)
15071496
"""
1508-
try:
1497+
with self.django_command:
15091498
cmd = get_typer_command(self.django_command.typer_app)
15101499
with cmd.make_context(
15111500
info_name=f"{self.prog_name} {self.subcommand}",
15121501
django_command=self.django_command,
15131502
args=list(args or []),
15141503
) as ctx:
1515-
return _ParsedArgs(args=args or [], **{**COMMON_DEFAULTS, **ctx.params})
1516-
except click.exceptions.Exit:
1517-
sys.exit()
1518-
except (click.exceptions.UsageError, CommandError) as arg_err:
1519-
if self.called_from_command_line:
1520-
err_msg = (
1521-
_(self.missing_args_message).format(
1522-
parameter=getattr(getattr(arg_err, "param", None), "name", "")
1523-
)
1524-
if isinstance(arg_err, click.exceptions.MissingParameter)
1525-
else str(arg_err)
1504+
common = {**COMMON_DEFAULTS, **ctx.params}
1505+
self.django_command._traceback = common.get(
1506+
"traceback", self.django_command._traceback
15261507
)
1527-
self.print_help()
1528-
self.django_command.stderr.write(err_msg)
1529-
sys.exit(1)
1530-
raise CommandError(str(arg_err)) from arg_err
1508+
return _ParsedArgs(args=args or [], **common)
15311509

15321510
def add_argument(self, *args, **kwargs):
15331511
"""
@@ -1686,27 +1664,57 @@ def command2(self, option: t.Optional[str] = None):
16861664
# they can use the type from django_typer.types.Verbosity
16871665
suppressed_base_arguments = {"verbosity"}
16881666

1667+
missing_args_message = "Missing parameter: {parameter}"
1668+
16891669
typer_app: Typer
16901670
no_color: bool = False
16911671
force_color: bool = False
16921672
_num_commands: int = 0
16931673
_has_callback: bool = False
16941674
_root_groups: int = 0
16951675
_handle: t.Callable[..., t.Any]
1676+
_traceback: bool = False
16961677

16971678
command_tree: CommandNode
16981679

16991680
@property
1700-
def name(self):
1681+
def name(self) -> str:
17011682
"""The name of the django command"""
1702-
return self.typer_app.info.name
1683+
return self.typer_app.info.name or self.__module__.rsplit(".", maxsplit=1)[-1]
17031684

17041685
def __enter__(self):
17051686
_command_context.__dict__.setdefault("stack", []).append(self)
17061687
return self
17071688

17081689
def __exit__(self, exc_type, exc_val, exc_tb):
17091690
_command_context.stack.pop()
1691+
if isinstance(exc_val, click.exceptions.Exit):
1692+
sys.exit(exc_val.exit_code)
1693+
if isinstance(exc_val, click.exceptions.UsageError):
1694+
err_msg = (
1695+
_(self.missing_args_message).format(
1696+
parameter=getattr(getattr(exc_val, "param", None), "name", "")
1697+
)
1698+
if isinstance(exc_val, click.exceptions.MissingParameter)
1699+
else str(exc_val)
1700+
)
1701+
1702+
# we might be in a subcommand - so make sure we pull that help out
1703+
# by walking up the context tree until we're at root
1704+
cmd_pth: t.List[str] = []
1705+
ctx = exc_val.ctx
1706+
while ctx and ctx.parent:
1707+
if ctx.info_name:
1708+
cmd_pth.insert(0, ctx.info_name)
1709+
ctx = ctx.parent
1710+
if (
1711+
getattr(self, "_called_from_command_line", False)
1712+
and not self._traceback
1713+
):
1714+
self.print_help(sys.argv[0], self.name, *cmd_pth)
1715+
self.stderr.write(err_msg)
1716+
sys.exit(1)
1717+
raise CommandError(str(exc_val)) from exc_val
17101718

17111719
def __init__(
17121720
self,
@@ -1816,17 +1824,7 @@ def create_parser( # pyright: ignore[reportIncompatibleMethodOverride]
18161824
:param subcommand: the name of the django command
18171825
"""
18181826
with self:
1819-
return TyperParser(
1820-
self,
1821-
prog_name,
1822-
subcommand,
1823-
missing_args_message=getattr(
1824-
self, "missing_args_message", TyperParser.missing_args_message
1825-
),
1826-
called_from_command_line=getattr(
1827-
self, "_called_from_command_line", None
1828-
),
1829-
)
1827+
return TyperParser(self, prog_name, subcommand)
18301828

18311829
def print_help(self, prog_name: str, subcommand: str, *cmd_path: str):
18321830
"""
@@ -1936,8 +1934,9 @@ def execute(self, *args, **options):
19361934
self.no_color = options["no_color"]
19371935
if options.get("force_color", None) is not None:
19381936
self.force_color = options["force_color"]
1939-
with self:
1940-
result = super().execute(*args, **options)
1941-
self.no_color = no_color
1942-
self.force_color = force_color
1943-
return result
1937+
try:
1938+
with self:
1939+
return super().execute(*args, **options)
1940+
finally:
1941+
self.no_color = no_color
1942+
self.force_color = force_color

django_typer/apps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@
2323
rich: t.Union[ModuleType, None] = None
2424

2525
try:
26+
import sys
27+
2628
import rich
2729
from rich import traceback
30+
from typer import main as typer_main
2831

2932
tb_config = traceback_config()
3033
if rich and isinstance(tb_config, dict) and not tb_config.get("no_install", False):
@@ -52,6 +55,10 @@
5255
if param in set(inspect.signature(traceback.install).parameters.keys())
5356
},
5457
)
58+
# typer installs its own exception hook and it falls back to the sys hook - depending
59+
# on when typer was imported it may have the original fallback system hook or our
60+
# installed rich one - we patch it here to make sure!
61+
typer_main._original_except_hook = sys.excepthook
5562
except ImportError:
5663
pass
5764

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import sys
2+
3+
import click
4+
from django.core.management.base import CommandError
5+
6+
from django_typer import TyperCommand, command
7+
8+
9+
class Command(TyperCommand):
10+
11+
help = "Test traceback scenarios."
12+
13+
@command()
14+
def error(self, throw_command: bool = False, throw_other: bool = False):
15+
if throw_command and throw_other:
16+
raise click.exceptions.UsageError(
17+
f"--throw-command and --throw-other are mutually exclusive."
18+
)
19+
if throw_command:
20+
raise CommandError(f"This command failed!")
21+
if throw_other:
22+
raise RuntimeError(f"This command threw an unexpected error!")
23+
24+
@command()
25+
def exit(self, code: int = 0):
26+
sys.exit(code)
Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import json
2-
from pprint import pprint
1+
import sys
32

43
from django.core.management.base import BaseCommand, CommandParser
54

65

76
class Command(BaseCommand):
87
def add_arguments(self, parser: CommandParser) -> None:
9-
parser.add_argument("--name", type=str)
8+
parser.add_argument("--exit-code", dest="exit_code", type=int, default=0)
109

1110
def handle(self, **options):
12-
pprint(json.dumps(options))
11+
raise sys.exit(options.get("exit_code", 0))

django_typer/tests/tests.py

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,17 @@ def run_command(command, *args, parse_json=True) -> Tuple[str, str]:
7777

7878
# Check the return code to ensure the script ran successfully
7979
if result.returncode != 0:
80-
return result.stdout, result.stderr
80+
return result.stdout, result.stderr, result.returncode
8181

8282
# Parse the output
8383
if result.stdout:
8484
if parse_json:
8585
try:
86-
return json.loads(result.stdout), result.stderr
86+
return json.loads(result.stdout), result.stderr, result.returncode
8787
except json.JSONDecodeError:
88-
return result.stdout, result.stderr
89-
return result.stdout, result.stderr
90-
return result.stdout, result.stderr
88+
return result.stdout, result.stderr, result.returncode
89+
return result.stdout, result.stderr, result.returncode
90+
return result.stdout, result.stderr, result.returncode
9191
finally:
9292
os.chdir(cwd)
9393

@@ -1271,7 +1271,8 @@ def test_command_line(self, settings=None):
12711271
("hey! " * 5).strip(),
12721272
)
12731273
else:
1274-
self.assertIn("UsageError", result[1])
1274+
self.assertIn("Usage: ./manage.py groups echo [OPTIONS] MESSAGE", result[0])
1275+
self.assertIn("Got unexpected extra argument (5)", result[1])
12751276
with self.assertRaises(TypeError):
12761277
call_command("groups", "echo", "hey!", echoes=5)
12771278
with self.assertRaises(TypeError):
@@ -1415,13 +1416,16 @@ def test_command_line(self, settings=None):
14151416
"groups", *settings, "string", "annamontes", "case", "upper", "4", "9"
14161417
)
14171418
if override:
1418-
result = result[1].strip()
1419-
self.assertIn("UsageError", result)
1419+
self.assertIn(
1420+
"Usage: ./manage.py groups string STRING case upper [OPTIONS]",
1421+
result[0],
1422+
)
1423+
self.assertIn("Got unexpected extra arguments (4 9)", result[1].strip())
14201424
grp_cmd.string("annamontes")
14211425
with self.assertRaises(TypeError):
14221426
self.assertEqual(grp_cmd.upper(4, 9), "annaMONTEs")
14231427

1424-
with self.assertRaises(UsageError):
1428+
with self.assertRaises(CommandError):
14251429
self.assertEqual(
14261430
call_command(
14271431
"groups", "string", "annamontes", "case", "upper", "4", "9"
@@ -1451,8 +1455,12 @@ def test_command_line(self, settings=None):
14511455
grp_cmd.string(" emmatc ")
14521456
self.assertEqual(grp_cmd.strip(), "emmatc")
14531457
else:
1454-
self.assertIn("UsageError", result[1])
1455-
with self.assertRaises(UsageError):
1458+
self.assertIn(
1459+
"Usage: ./manage.py groups string [OPTIONS] STRING COMMAND [ARGS]",
1460+
result[0],
1461+
)
1462+
self.assertIn("No such command 'strip'.", result[1])
1463+
with self.assertRaises(CommandError):
14561464
self.assertEqual(
14571465
call_command("groups", "string", " emmatc ", "strip"), "emmatc"
14581466
)
@@ -2615,3 +2623,85 @@ def test_custom_fallback(self):
26152623
"shell ",
26162624
)[0]
26172625
self.assertTrue("shell " in result)
2626+
2627+
2628+
class TracebackTests(TestCase):
2629+
"""
2630+
Tests that show CommandErrors and UsageErrors do not result in tracebacks unless --traceback is set.
2631+
2632+
Also make sure that sys.exit is not called when not run from the terminal
2633+
(i.e. in get_command invocation or call_command).
2634+
"""
2635+
2636+
def test_usage_error_no_tb(self):
2637+
stdout, stderr, retcode = run_command("tb", "wrong")
2638+
self.assertTrue("Usage: ./manage.py tb [OPTIONS] COMMAND [ARGS]" in stdout)
2639+
self.assertTrue("No such command" in stderr)
2640+
self.assertTrue(retcode > 0)
2641+
2642+
stdout, stderr, retcode = run_command("tb", "error", "wrong")
2643+
self.assertTrue("Usage: ./manage.py tb error [OPTIONS]" in stdout)
2644+
self.assertTrue("Got unexpected extra argument" in stderr)
2645+
self.assertTrue(retcode > 0)
2646+
2647+
with self.assertRaises(CommandError):
2648+
call_command("tb", "wrong")
2649+
2650+
with self.assertRaises(CommandError):
2651+
call_command("tb", "error", "wrong")
2652+
2653+
def test_usage_error_with_tb_if_requested(self):
2654+
2655+
stdout, stderr, retcode = run_command("tb", "--traceback", "wrong")
2656+
self.assertFalse(stdout.strip())
2657+
self.assertTrue("Traceback" in stderr)
2658+
if rich_installed:
2659+
self.assertTrue("───── locals ─────" in stderr)
2660+
else:
2661+
self.assertFalse("───── locals ─────" in stderr)
2662+
self.assertTrue("No such command 'wrong'" in stderr)
2663+
self.assertTrue(retcode > 0)
2664+
2665+
stdout, stderr, retcode = run_command("tb", "--traceback", "error", "wrong")
2666+
self.assertFalse(stdout.strip())
2667+
self.assertTrue("Traceback" in stderr)
2668+
if rich_installed:
2669+
self.assertTrue("───── locals ─────" in stderr)
2670+
else:
2671+
self.assertFalse("───── locals ─────" in stderr)
2672+
self.assertFalse(stdout.strip())
2673+
self.assertTrue("Got unexpected extra argument" in stderr)
2674+
self.assertTrue(retcode > 0)
2675+
2676+
with self.assertRaises(CommandError):
2677+
call_command("tb", "--traceback", "wrong")
2678+
2679+
with self.assertRaises(CommandError):
2680+
call_command("tb", "--traceback", "error", "wrong")
2681+
2682+
def test_click_exception_retcodes_honored(self):
2683+
2684+
self.assertEqual(run_command("vanilla")[2], 0)
2685+
self.assertEqual(run_command("vanilla", "--exit-code=2")[2], 2)
2686+
2687+
self.assertEqual(run_command("tb", "exit")[2], 0)
2688+
self.assertEqual(run_command("tb", "exit", "--code=2")[2], 2)
2689+
2690+
def test_exit_on_call(self):
2691+
with self.assertRaises(SystemExit):
2692+
call_command("vanilla", "--help")
2693+
2694+
with self.assertRaises(SystemExit):
2695+
call_command("vanilla", "--exit-code", "0")
2696+
2697+
with self.assertRaises(SystemExit):
2698+
call_command("vanilla", "--exit-code", "1")
2699+
2700+
with self.assertRaises(SystemExit):
2701+
call_command("tb", "--help")
2702+
2703+
with self.assertRaises(SystemExit):
2704+
call_command("tb", "exit")
2705+
2706+
with self.assertRaises(SystemExit):
2707+
call_command("tb", "exit", "--code=1")

0 commit comments

Comments
 (0)