Skip to content

Commit 36deba8

Browse files
authored
Forward exception information to resources registered in a context (#3058)
2 parents 16fe802 + f2cae7a commit 36deba8

File tree

3 files changed

+151
-3
lines changed

3 files changed

+151
-3
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Unreleased
2222
- Show correct auto complete value for nargs option in combination with flag option :issue:`2813`
2323
- Fix handling of quoted and escaped parameters in Fish autocompletion. :issue:`2995` :pr:`3013`
2424
- Lazily import ``shutil``. :pr:`3023`
25+
- Properly forward exception information to resources registered with
26+
``click.core.Context.with_resource()``. :issue:`2447` :pr:`3058`
2527

2628
Version 8.2.2
2729
-------------

src/click/core.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -483,12 +483,15 @@ def __exit__(
483483
exc_type: type[BaseException] | None,
484484
exc_value: BaseException | None,
485485
tb: TracebackType | None,
486-
) -> None:
486+
) -> bool | None:
487487
self._depth -= 1
488+
exit_result: bool | None = None
488489
if self._depth == 0:
489-
self.close()
490+
exit_result = self._close_with_exception_info(exc_type, exc_value, tb)
490491
pop_context()
491492

493+
return exit_result
494+
492495
@contextmanager
493496
def scope(self, cleanup: bool = True) -> cabc.Iterator[Context]:
494497
"""This helper method can be used with the context object to promote
@@ -615,10 +618,26 @@ def close(self) -> None:
615618
:meth:`call_on_close`, and exit all context managers entered
616619
with :meth:`with_resource`.
617620
"""
618-
self._exit_stack.close()
621+
self._close_with_exception_info(None, None, None)
622+
623+
def _close_with_exception_info(
624+
self,
625+
exc_type: type[BaseException] | None,
626+
exc_value: BaseException | None,
627+
tb: TracebackType | None,
628+
) -> bool | None:
629+
"""Unwind the exit stack by calling its :meth:`__exit__` providing the exception
630+
information to allow for exception handling by the various resources registered
631+
using :meth;`with_resource`
632+
633+
:return: Whatever ``exit_stack.__exit__()`` returns.
634+
"""
635+
exit_result = self._exit_stack.__exit__(exc_type, exc_value, tb)
619636
# In case the context is reused, create a new exit stack.
620637
self._exit_stack = ExitStack()
621638

639+
return exit_result
640+
622641
@property
623642
def command_path(self) -> str:
624643
"""The computed command path. This is used for the ``usage``

tests/test_context.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
2+
from contextlib import AbstractContextManager
23
from contextlib import contextmanager
4+
from types import TracebackType
35

46
import pytest
57

@@ -423,6 +425,131 @@ def manager():
423425
assert rv == [0]
424426

425427

428+
def test_with_resource_exception() -> None:
429+
class TestContext(AbstractContextManager[list[int]]):
430+
_handle_exception: bool
431+
_base_val: int
432+
val: list[int]
433+
434+
def __init__(self, base_val: int = 1, *, handle_exception: bool = True) -> None:
435+
self._handle_exception = handle_exception
436+
self._base_val = base_val
437+
438+
def __enter__(self) -> list[int]:
439+
self.val = [self._base_val]
440+
return self.val
441+
442+
def __exit__(
443+
self,
444+
exc_type: type[BaseException] | None,
445+
exc_value: BaseException | None,
446+
traceback: TracebackType | None,
447+
) -> bool | None:
448+
if not exc_type:
449+
self.val[0] = self._base_val - 1
450+
return None
451+
452+
self.val[0] = self._base_val + 1
453+
return self._handle_exception
454+
455+
class TestException(Exception):
456+
pass
457+
458+
ctx = click.Context(click.Command("test"))
459+
460+
base_val = 1
461+
462+
with ctx.scope():
463+
rv = ctx.with_resource(TestContext(base_val=base_val))
464+
assert rv[0] == base_val
465+
466+
assert rv == [base_val - 1]
467+
468+
with ctx.scope():
469+
rv = ctx.with_resource(TestContext(base_val=base_val))
470+
raise TestException()
471+
472+
assert rv == [base_val + 1]
473+
474+
with pytest.raises(TestException):
475+
with ctx.scope():
476+
rv = ctx.with_resource(
477+
TestContext(base_val=base_val, handle_exception=False)
478+
)
479+
raise TestException()
480+
481+
482+
def test_with_resource_nested_exception() -> None:
483+
class TestContext(AbstractContextManager[list[int]]):
484+
_handle_exception: bool
485+
_base_val: int
486+
val: list[int]
487+
488+
def __init__(self, base_val: int = 1, *, handle_exception: bool = True) -> None:
489+
self._handle_exception = handle_exception
490+
self._base_val = base_val
491+
492+
def __enter__(self) -> list[int]:
493+
self.val = [self._base_val]
494+
return self.val
495+
496+
def __exit__(
497+
self,
498+
exc_type: type[BaseException] | None,
499+
exc_value: BaseException | None,
500+
traceback: TracebackType | None,
501+
) -> bool | None:
502+
if not exc_type:
503+
self.val[0] = self._base_val - 1
504+
return None
505+
506+
self.val[0] = self._base_val + 1
507+
return self._handle_exception
508+
509+
class TestException(Exception):
510+
pass
511+
512+
ctx = click.Context(click.Command("test"))
513+
base_val = 1
514+
base_val_nested = 11
515+
516+
with ctx.scope():
517+
rv = ctx.with_resource(TestContext(base_val=base_val))
518+
rv_nested = ctx.with_resource(TestContext(base_val=base_val_nested))
519+
assert rv[0] == base_val
520+
assert rv_nested[0] == base_val_nested
521+
522+
assert rv == [base_val - 1]
523+
assert rv_nested == [base_val_nested - 1]
524+
525+
with ctx.scope():
526+
rv = ctx.with_resource(TestContext(base_val=base_val))
527+
rv_nested = ctx.with_resource(TestContext(base_val=base_val_nested))
528+
raise TestException()
529+
530+
# If one of the context "eats" the exceptions they will not be forwarded to other
531+
# parts. This is due to how ExitStack unwinding works
532+
assert rv_nested == [base_val_nested + 1]
533+
assert rv == [base_val - 1]
534+
535+
with ctx.scope():
536+
rv = ctx.with_resource(TestContext(base_val=base_val))
537+
rv_nested = ctx.with_resource(
538+
TestContext(base_val=base_val_nested, handle_exception=False)
539+
)
540+
raise TestException()
541+
542+
assert rv_nested == [base_val_nested + 1]
543+
assert rv == [base_val + 1]
544+
545+
with pytest.raises(TestException):
546+
rv = ctx.with_resource(TestContext(base_val=base_val, handle_exception=False))
547+
rv_nested = ctx.with_resource(
548+
TestContext(base_val=base_val_nested, handle_exception=False)
549+
)
550+
raise TestException()
551+
552+
426553
def test_make_pass_decorator_args(runner):
427554
"""
428555
Test to check that make_pass_decorator doesn't consume arguments based on

0 commit comments

Comments
 (0)