Skip to content

Commit 637b118

Browse files
committed
Add new "auto_default_argument", "handle_tracebacks" and "traceback_handler" functions.
1 parent 9be044e commit 637b118

File tree

26 files changed

+281
-4
lines changed

26 files changed

+281
-4
lines changed

consolekit/options.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"MultiValueOption",
8181
"flag_option",
8282
"auto_default_option",
83+
"auto_default_argument",
8384
]
8485

8586
_A = TypeVar("_A", bound=click.Argument)
@@ -163,6 +164,8 @@ def force_option(help_text: str) -> Callable[..., click.Command]:
163164
"""
164165
Decorator to add the ``-f / --force`` option to a click command.
165166
167+
The value is exposed via the parameter ``force``: :class:`bool`.
168+
166169
.. versionadded:: 0.5.0
167170
168171
:param help_text: The help text for the option.
@@ -229,12 +232,44 @@ def decorator(f: _C) -> _C:
229232
option = OptionClass(param_decls, **option_attrs)
230233
_param_memo(f, option)
231234

232-
signature: inspect.Signature = inspect.signature(f.callback)
235+
_get_default_from_callback_and_set(f, option)
236+
237+
return f
238+
239+
return decorator
233240

234-
param_default = signature.parameters[option.name].default
241+
242+
def _get_default_from_callback_and_set(command: click.Command, param: click.Parameter):
243+
if command.callback is not None:
244+
# The callback *can* be None, for a no-op
245+
signature: inspect.Signature = inspect.signature(command.callback)
246+
247+
param_default = signature.parameters[param.name].default
235248

236249
if param_default is not inspect.Signature.empty:
237-
option.default = param_default
250+
param.default = param_default
251+
252+
253+
def auto_default_argument(*param_decls, **attrs) -> Callable[..., click.Argument]:
254+
"""
255+
Attaches an argument to the command, with a default value determined from the decorated function's signature.
256+
257+
All positional arguments are passed as parameter declarations to :class:`click.Argument`;
258+
all keyword arguments are forwarded unchanged (except ``cls``).
259+
This is equivalent to creating an :class:`click.Argument` instance manually
260+
and attaching it to the :attr:`click.Argument.params` list.
261+
262+
.. versionadded:: 0.8.0
263+
264+
:param cls: the option class to instantiate. This defaults to :class:`click.Argument`.
265+
"""
266+
267+
def decorator(f: _C) -> _C:
268+
ArgumentClass = attrs.pop("cls", Argument)
269+
argument = ArgumentClass(param_decls, **attrs)
270+
_param_memo(f, argument)
271+
272+
_get_default_from_callback_and_set(f, argument)
238273

239274
return f
240275

consolekit/utils.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
5454
"braille_spinner",
5555
"hide_cursor",
5656
"show_cursor",
57+
"handle_tracebacks",
58+
"traceback_handler",
59+
"TerminalRenderer",
5760
]
5861

5962

@@ -244,6 +247,70 @@ def show_cursor() -> None:
244247
click.echo(Cursor.SHOW)
245248

246249

250+
nullcontext: Callable[..., ContextManager]
251+
252+
if sys.version_info < (3, 7): # pragma: no cover (py37+)
253+
254+
@contextlib.contextmanager
255+
def nullcontext():
256+
yield
257+
else: # pragma: no cover (<py37)
258+
nullcontext = contextlib.nullcontext
259+
260+
261+
@contextlib.contextmanager
262+
def traceback_handler():
263+
"""
264+
Context manager to abort execution with a short error message on the following exception types:
265+
266+
* :exc:`FileNotFoundError`
267+
* :exc:`FileExistsError`
268+
269+
Other custom exception classes inheriting from :exc:`Exception` are also handled,
270+
but with a generic message.
271+
272+
The following exception classes are ignored:
273+
274+
* :exc:`EOFError`
275+
* :exc:`KeyboardInterrupt`
276+
* :exc:`click.exceptions.ClickException`
277+
278+
.. versionadded:: 0.8.0
279+
280+
.. seealso:: :func:`~.handle_tracebacks`.
281+
""" # noqa: D400
282+
283+
try:
284+
yield
285+
except (EOFError, KeyboardInterrupt, click.ClickException, click.Abort):
286+
raise
287+
except FileNotFoundError as e:
288+
raise abort(f"File Not Found: {e}")
289+
except FileExistsError as e:
290+
raise abort(f"File Exists: {e}")
291+
except Exception as e:
292+
raise abort(f"An error occurred: {e}")
293+
294+
295+
def handle_tracebacks(show_traceback: bool = False) -> ContextManager:
296+
"""
297+
Context manager to conditionally handle tracebacks, usually based on the value of a command line flag.
298+
299+
.. versionadded:: 0.8.0
300+
301+
:param show_traceback: If :py:obj:`True`, the full Python traceback will be shown on errors.
302+
If :py:obj:`False`, only the summary of the traceback will be shown.
303+
In either case the program execution will stop on error.
304+
305+
.. seealso:: :func:`~.traceback_handler`.
306+
"""
307+
308+
if show_traceback:
309+
return nullcontext()
310+
else:
311+
return traceback_handler()
312+
313+
247314
@lru_cache(1)
248315
def _pycharm_hosted():
249316
return os.environ.get("PYCHARM_HOSTED", 0)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Usage: main [OPTIONS]
2+
3+
Options:
4+
--width INTEGER The max width to display. [default: 80]
5+
-h, --help Show this message and exit.

tests/test_utils.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
# stdlib
2+
import math
3+
import re
24
import sys
5+
from typing import Callable, ContextManager
36

47
# 3rd party
8+
import click
9+
import pytest
10+
from click.testing import CliRunner, Result
511
from domdf_python_tools.paths import PathPlus
612
from domdf_python_tools.testing import check_file_regression
713
from pytest_regressions.file_regression import FileRegressionFixture
814

915
# this package
10-
from consolekit.utils import coloured_diff, overtype
16+
from consolekit import click_command
17+
from consolekit.utils import coloured_diff, handle_tracebacks, is_command, overtype, traceback_handler
1118

1219

1320
def test_overtype(capsys):
@@ -60,3 +67,122 @@ def test_coloured_diff(file_regression: FileRegressionFixture):
6067
)
6168

6269
check_file_regression(diff, file_regression)
70+
71+
72+
exceptions = pytest.mark.parametrize(
73+
"exception",
74+
[
75+
pytest.param(FileNotFoundError("foo.txt"), id="FileNotFoundError"),
76+
pytest.param(FileExistsError("foo.txt"), id="FileExistsError"),
77+
pytest.param(Exception("Something's awry!"), id="Exception"),
78+
pytest.param(ValueError("'age' must be >= 0"), id="ValueError"),
79+
pytest.param(TypeError("Expected type int, got type str"), id="TypeError"),
80+
pytest.param(NameError("name 'hello' is not defined"), id="NameError"),
81+
pytest.param(SyntaxError("invalid syntax"), id="SyntaxError"),
82+
]
83+
)
84+
contextmanagers = pytest.mark.parametrize(
85+
"contextmanager",
86+
[
87+
pytest.param(handle_tracebacks, id="handle_tracebacks"),
88+
pytest.param(traceback_handler, id="traceback_handler"),
89+
]
90+
)
91+
92+
93+
@exceptions
94+
@contextmanagers
95+
def test_handle_tracebacks(exception, contextmanager: Callable[..., ContextManager], file_regression):
96+
97+
@click.command()
98+
def demo():
99+
100+
with contextmanager():
101+
raise exception
102+
103+
runner = CliRunner()
104+
105+
result: Result = runner.invoke(demo, catch_exceptions=False)
106+
107+
check_file_regression(result.stdout.rstrip(), file_regression)
108+
109+
assert result.exit_code == 1
110+
111+
112+
@exceptions
113+
def test_handle_tracebacks_show_traceback(exception, file_regression):
114+
115+
@click.command()
116+
def demo():
117+
118+
with handle_tracebacks(show_traceback=True):
119+
raise exception
120+
121+
runner = CliRunner()
122+
123+
with pytest.raises(type(exception), match=re.escape(str(exception))):
124+
runner.invoke(demo, catch_exceptions=False)
125+
126+
127+
@pytest.mark.parametrize("exception", [EOFError(), KeyboardInterrupt(), click.Abort()])
128+
@contextmanagers
129+
def test_handle_tracebacks_ignored_exceptions(
130+
exception, contextmanager: Callable[..., ContextManager], file_regression
131+
):
132+
133+
@click.command()
134+
def demo():
135+
136+
with contextmanager():
137+
raise exception
138+
139+
runner = CliRunner()
140+
141+
result: Result = runner.invoke(demo, catch_exceptions=False)
142+
143+
assert result.stdout.strip() == "Aborted!"
144+
assert result.exit_code == 1
145+
146+
147+
@pytest.mark.parametrize(
148+
"exception, code",
149+
[
150+
pytest.param(click.UsageError("Message"), 2, id="click.UsageError"),
151+
pytest.param(click.BadParameter("Message"), 2, id="click.BadParameter"),
152+
pytest.param(click.FileError("Message"), 1, id="click.FileError"),
153+
pytest.param(click.ClickException("Message"), 1, id="click.ClickException"),
154+
]
155+
)
156+
@contextmanagers
157+
def test_handle_tracebacks_ignored_click(
158+
exception,
159+
contextmanager: Callable[..., ContextManager],
160+
file_regression,
161+
code: int,
162+
):
163+
164+
@click.command()
165+
def demo():
166+
167+
with contextmanager():
168+
raise exception
169+
170+
runner = CliRunner()
171+
172+
result: Result = runner.invoke(demo, catch_exceptions=False)
173+
174+
check_file_regression(result.stdout.rstrip(), file_regression)
175+
176+
assert result.exit_code == code
177+
178+
179+
def test_is_command():
180+
181+
@click_command()
182+
def main():
183+
...
184+
185+
assert is_command(main)
186+
assert not is_command(int)
187+
assert not is_command(lambda: True)
188+
assert not is_command(math.ceil)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
An error occurred: Something's awry!
2+
Aborted!
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
File Exists: foo.txt
2+
Aborted!
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
File Not Found: foo.txt
2+
Aborted!
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
An error occurred: name 'hello' is not defined
2+
Aborted!
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
An error occurred: invalid syntax
2+
Aborted!
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
An error occurred: Expected type int, got type str
2+
Aborted!

0 commit comments

Comments
 (0)