diff --git a/cantok/tokens/abstract/abstract_token.py b/cantok/tokens/abstract/abstract_token.py index e4dcd54..caefd9b 100644 --- a/cantok/tokens/abstract/abstract_token.py +++ b/cantok/tokens/abstract/abstract_token.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from threading import RLock from time import sleep -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Type, Union from printo import describe_call, not_none @@ -71,7 +71,7 @@ def __repr__(self) -> str: cancelled = report.from_token is self and report.cause == CancelCause.CANCELLED return describe_call( - type(self).__name__, + type(self), [superpower, *self._tokens], { 'cancelled': cancelled, @@ -222,7 +222,7 @@ def cancel(self) -> 'AbstractToken': self._cancelled = True return self - def check(self) -> None: + def check(self, *, exception: Optional[Union[Type[BaseException], BaseException]] = None) -> None: """ Raises an exception if the token is cancelled; does nothing otherwise. @@ -231,19 +231,47 @@ def check(self) -> None: - Automatic cancellation by a specific token type raises the corresponding subclass (e.g. TimeoutCancellationError for TimeoutToken). + :param exception: Optional keyword-only exception class or instance used + instead of the standard cancellation exception. A class + receives the standard cancellation message; an instance + is raised as is. + :raises ValueError: If exception is neither an exception class nor instance. + >>> token = SimpleToken() >>> token.check() # nothing happens >>> token.cancel() >>> token.check() # raises CancellationError """ + if exception is not None and not ( + isinstance(exception, BaseException) + or (isinstance(exception, type) and issubclass(exception, BaseException)) + ): + raise ValueError('Only an exception instance or an exception class can be passed.') + with self._lock: report = self._get_report() - if report.cause == CancelCause.CANCELLED: - report.from_token._raise_cancelled_exception() + if exception is None: + if report.cause == CancelCause.CANCELLED: + report.from_token._raise_cancelled_exception() + + elif report.cause == CancelCause.SUPERPOWER: + report.from_token._raise_superpower_exception() + + elif report.cause != CancelCause.NOT_CANCELLED: + if isinstance(exception, BaseException): + raise exception + + message = ( + 'The token has been cancelled.' + if report.cause == CancelCause.CANCELLED + else report.from_token._get_superpower_exception_message() + ) + report.from_token._get_exception_message_suffix() + + if issubclass(exception, CancellationError): + raise exception(message, report.from_token) - elif report.cause == CancelCause.SUPERPOWER: - report.from_token._raise_superpower_exception() + raise exception(message) def _get_report(self, direct: bool = True) -> CancellationReport: if self._cancelled: diff --git a/docs/what_are_tokens/exceptions.md b/docs/what_are_tokens/exceptions.md index a657bf8..5d2a12d 100644 --- a/docs/what_are_tokens/exceptions.md +++ b/docs/what_are_tokens/exceptions.md @@ -32,6 +32,18 @@ Each type of token (except [`DefaultToken`](../types_of_tokens/DefaultToken.md)) When you call the `check()` method on any token, one of two things will happen. If it (or any of the tokens nested in it) has been cancelled by calling the `cancel()` method, `CancellationError` will always be raised. But if the cancellation occurred as a result of the unique ability of the token, such as timeout expiration for `TimeoutToken`, then an exception specific to this type of token will be raised. +`check()` also accepts a keyword-only `exception` argument. Omit it or pass `None` to keep the behavior described above. Pass an exception class, and `check()` raises an instance of it with the standard message for the cancellation cause; pass an existing exception object, and `check()` raises that object as is. If the token is not cancelled, `check()` still does nothing and the override is not used. + +```python +from cantok import SimpleToken + +token = SimpleToken() +token.cancel() +token.check(exception=RuntimeError) +#> ... +#> RuntimeError: The token has been cancelled. +``` + `ConditionCancellationError`, `TimeoutCancellationError`, and `CounterCancellationError` are inherited from `CancellationError`, so if you're not sure which specific exception you're catching, catch `CancellationError`. All of the listed exceptions can also be imported separately: ```python diff --git a/pyproject.toml b/pyproject.toml index 73cc7fa..df4d458 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "cantok" -version = "0.0.42" +version = "0.0.43" authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }] description = 'Implementation of the "Cancellation Token" pattern' readme = "README.md" requires-python = ">=3.8" dependencies = [ - 'printo>=0.0.28', - 'transfunctions>=0.0.12', + 'printo>=0.0.29', 'typing_extensions>=4.5.0 ; python_version < "3.13"', ] classifiers = [ diff --git a/tests/typing/tokens/abstract/test_abstract_token.py b/tests/typing/tokens/abstract/test_abstract_token.py index 3772a57..49f3108 100644 --- a/tests/typing/tokens/abstract/test_abstract_token.py +++ b/tests/typing/tokens/abstract/test_abstract_token.py @@ -7,18 +7,53 @@ from typing import assert_type import pytest +from full_match import match from cantok import AbstractToken, SimpleToken @pytest.mark.mypy_testing -def test_abstract_token_exposes_doc_attribute_as_optional_string(): +def test_abstract_token_exposes_doc_and_check_type_contracts(): """ - `AbstractToken` exposes `doc` as an optional string in the type contract. + Expose the public type contracts of `doc` and `check()`. - A concrete token with `doc` is assigned to an `AbstractToken` variable, and - `assert_type` fixes the public attribute type. + `doc` is `Optional[str]`. The keyword-only `exception` may be omitted or set + to `None`, and accepts classes and instances throughout the `BaseException` + hierarchy. Every `check()` call has inferred type `None`. """ token: AbstractToken = SimpleToken(doc='d') assert_type(token.doc, Optional[str]) + assert_type(token.check(), None) + assert_type(token.check(exception=None), None) + assert_type(token.check(exception=RuntimeError), None) + assert_type(token.check(exception=RuntimeError('custom message')), None) + assert_type(token.check(exception=SystemExit), None) + assert_type(token.check(exception=SystemExit('custom message')), None) + + +@pytest.mark.mypy_testing +def test_abstract_token_rejects_invalid_check_exception_types(): + """ + Reject non-exception values and classes statically and at runtime. + + At runtime, both invalid forms raise the documented `ValueError` with its + exact message. + """ + token: AbstractToken = SimpleToken() + expected_message = match('Only an exception instance or an exception class can be passed.') + + with pytest.raises(ValueError, match=expected_message): + token.check(exception=1) # E: [arg-type] + + with pytest.raises(ValueError, match=expected_message): + token.check(exception=object) # E: [arg-type] + + +@pytest.mark.mypy_testing +def test_abstract_token_check_exception_is_keyword_only(): + """Reject positional exception overrides statically and at runtime.""" + token: AbstractToken = SimpleToken() + + with pytest.raises(TypeError): + token.check(ValueError) # E: [misc] diff --git a/tests/units/tokens/abstract/test_abstract_token.py b/tests/units/tokens/abstract/test_abstract_token.py index 70138c2..0be3416 100644 --- a/tests/units/tokens/abstract/test_abstract_token.py +++ b/tests/units/tokens/abstract/test_abstract_token.py @@ -30,6 +30,7 @@ ALL_TOKENS_FABRICS = [partial(token_class, *arguments) for token_class, arguments in zip(ALL_TOKEN_CLASSES, ALL_ARGUMENTS_FOR_TOKEN_CLASSES)] ALL_TOKENS_FABRICS_WITH_CANCELLING_SUPERPOWER = [partial(token_class, *arguments) for token_class, arguments in zip(ALL_SUPERPOWER_TOKEN_CLASSES, ALL_CANCELLING_ARGUMENTS_FOR_TOKEN_CLASSES_WITH_SUPERPOWERS)] ALL_TOKENS_FABRICS_WITH_NOT_CANCELLING_SUPERPOWER = [partial(token_class, *arguments) for token_class, arguments in zip(ALL_SUPERPOWER_TOKEN_CLASSES, ALL_NOT_CANCELLING_ARGUMENTS_FOR_TOKEN_CLASSES_WITH_SUPERPOWERS)] +ALL_TOKENS_FABRICS_WITH_MANUAL_CANCELLATION = [partial(token_fabric, cancelled=True) for token_fabric in ALL_TOKENS_FABRICS] def test_cant_instantiate_abstract_token(): @@ -276,22 +277,45 @@ def test_str_with_doc_is_unchanged_for_superpower_cancelled_tokens(token_fabric) 'token_fabric', ALL_TOKENS_FABRICS, ) -def test_manual_cancellation_message_includes_doc_only_when_present(token_fabric, doc_kwargs, expected_suffix): +@pytest.mark.parametrize( + 'check_kwargs', + [ + {}, + {'exception': None}, + {'exception': RuntimeError}, + {'exception': SystemExit}, + ], +) +def test_manual_cancellation_message_includes_doc_only_when_present( + token_fabric, + doc_kwargs, + expected_suffix, + check_kwargs, +): """ - Manual cancellation messages append `doc` only when it is present. + Include `doc` in manual cancellation messages only when it is present. - Omitted `doc` and explicit `None` keep the old exact message; valid text - adds the shared token-description suffix while preserving token identity - and surrounding whitespace. + Omitting `doc` or passing `doc=None` preserves the original message; + otherwise its text appears verbatim in the suffix. `check()` and + `check(exception=None)` raise the standard exception with its source token. + Exception class overrides carry the same message without a + `.token` attribute. """ token = token_fabric(**doc_kwargs) token.cancel() + expected_message = 'The token has been cancelled.' + expected_suffix + exception_class_override = check_kwargs.get('exception') + expected_exception_class = CancellationError if exception_class_override is None else exception_class_override - with pytest.raises(CancellationError, match=match('The token has been cancelled.' + expected_suffix)) as exc_info: - token.check() + with pytest.raises(expected_exception_class, match=match(expected_message)) as exc_info: + token.check(**check_kwargs) - assert type(exc_info.value) is CancellationError - assert exc_info.value.token is token + assert type(exc_info.value) is expected_exception_class + assert exc_info.value.args == (expected_message, ) + if issubclass(expected_exception_class, CancellationError): + assert exc_info.value.token is token + else: + assert not hasattr(exc_info.value, 'token') @pytest.mark.parametrize( @@ -336,20 +360,38 @@ def test_manual_cancellation_message_overrides_active_superpower_with_optional_d ) def test_superpower_and_impossible_cancel_messages_include_doc_only_when_present(doc_kwargs, expected_suffix): """ - Type-specific cancellation messages append `doc` only when it is present. + Append `doc` to type-specific cancellation messages only when present. - The same suffix rule is fixed for Condition, Counter, Timeout, and both - `DefaultToken` impossible-cancel paths. + This covers Condition, Counter, Timeout, and both `DefaultToken` + impossible-cancel paths. For superpower cancellation, `check()` and + `check(exception=None)` raise the standard exception with its source token. + Exceptions from ordinary class overrides keep the message without `.token`. """ for token_fabric in ALL_TOKENS_FABRICS_WITH_CANCELLING_SUPERPOWER: token = token_fabric(**doc_kwargs) + expected_message = token._get_superpower_exception_message() + expected_suffix - with pytest.raises(token.exception, match=match(token._get_superpower_exception_message() + expected_suffix)) as exc_info: + with pytest.raises(token.exception, match=match(expected_message)) as exc_info: token.check() assert type(exc_info.value) is token.exception + assert exc_info.value.args == (expected_message, ) + assert exc_info.value.token is token + + with pytest.raises(token.exception, match=match(expected_message)) as exc_info: + token.check(exception=None) + + assert type(exc_info.value) is token.exception + assert exc_info.value.args == (expected_message, ) assert exc_info.value.token is token + with pytest.raises(RuntimeError, match=match(expected_message)) as exc_info: + token.check(exception=RuntimeError) + + assert type(exc_info.value) is RuntimeError + assert exc_info.value.args == (expected_message, ) + assert not hasattr(exc_info.value, 'token') + expected_impossible_message = match('You cannot cancel a default token.' + expected_suffix) token = DefaultToken(**doc_kwargs) @@ -387,22 +429,51 @@ def test_superpower_and_impossible_cancel_messages_include_doc_only_when_present 'parent_token_fabric', ALL_TOKENS_FABRICS, ) -def test_nested_cancellation_message_uses_causing_token_doc(parent_token_fabric, nested_token_fabric, doc_case): +@pytest.mark.parametrize( + 'check_kwargs', + [ + {}, + {'exception': None}, + {'exception': RuntimeError}, + { + 'exception': type( + 'CancellationErrorSubclass', + (CancellationError,), + {}, + ), + }, + ], +) +def test_nested_cancellation_message_uses_causing_token_doc( + parent_token_fabric, + nested_token_fabric, + doc_case, + check_kwargs, +): """ - Nested cancellation messages describe the token that caused cancellation. + Use the actual nested source in cancellation messages. - Parent `doc` must not leak into the message when a nested token is the - cancellation source. + Parent `doc` never leaks. Exceptions raised by `check()` and + `check(exception=None)`, or created from a `CancellationError` subclass, + retain the nested source token. An ordinary class override produces the + same message without `.token`. """ doc, expected_suffix = doc_case nested_token = nested_token_fabric(doc=doc) token = parent_token_fabric(nested_token, doc='parent-doc') + expected_message = nested_token._get_superpower_exception_message() + expected_suffix + exception_class_override = check_kwargs.get('exception') + expected_exception_class = nested_token.exception if exception_class_override is None else exception_class_override - with pytest.raises(nested_token.exception, match=match(nested_token._get_superpower_exception_message() + expected_suffix)) as exc_info: - token.check() + with pytest.raises(expected_exception_class, match=match(expected_message)) as exc_info: + token.check(**check_kwargs) - assert type(exc_info.value) is nested_token.exception - assert exc_info.value.token is nested_token + assert type(exc_info.value) is expected_exception_class + assert exc_info.value.args == (expected_message, ) + if issubclass(expected_exception_class, CancellationError): + assert exc_info.value.token is nested_token + else: + assert not hasattr(exc_info.value, 'token') @pytest.mark.parametrize( @@ -1306,18 +1377,216 @@ def test_check_cancelled_token(token_fabric): @pytest.mark.parametrize( 'token_fabric', - [*ALL_TOKENS_FABRICS, DefaultToken], + ALL_TOKENS_FABRICS, +) +@pytest.mark.parametrize( + 'check_kwargs', + [ + {}, + {'exception': None}, + {'exception': UnicodeDecodeError}, + { + 'exception': type( + 'NonInstantiableCancellationError', + (CancellationError,), + {'__new__': staticmethod(len)}, + ), + }, + ], ) -def test_check_superpower_not_raised(token_fabric): +def test_check_returns_none_without_instantiating_exception_classes_for_active_token( + token_fabric, + check_kwargs, +): """ - Fresh public tokens pass `check()` while they are still active. + For active regular tokens, `check()` returns `None` without instantiating + exception classes. - This covers regular tokens and `DefaultToken`, including token classes with no - superpower to trigger. + This covers an omitted `exception`, explicit `None`, an ordinary exception + class, and a `CancellationError` subclass. """ token = token_fabric() - assert token.check() is None + assert token.check(**check_kwargs) is None + + +@pytest.mark.parametrize( + 'token_fabric', + ALL_TOKENS_FABRICS, +) +@pytest.mark.parametrize( + 'exception_fabric', + [ + partial(RuntimeError, 'unused'), + lambda: type( + 'CancellationErrorSubclass', + (CancellationError,), + {}, + )('unused', SimpleToken()), + ], +) +def test_check_does_not_modify_or_raise_exception_instance_for_active_token( + token_fabric, + exception_fabric, +): + """ + Active regular tokens leave supplied exception objects untouched. + + `check()` returns `None` without raising the object or changing its + traceback, arguments, custom state, or `.token` attribute. A local sentinel + represents a missing `.token`, so one identity assertion covers both its + continued absence and preservation of an existing source token. + """ + token = token_fabric() + exception = exception_fabric() + exception.marker = 'preserved' + original_args = exception.args + missing_token = object() + original_source_token = getattr(exception, 'token', missing_token) + + assert exception.__traceback__ is None + assert token.check(exception=exception) is None + assert exception.__traceback__ is None + assert exception.args == original_args + assert exception.marker == 'preserved' + assert getattr(exception, 'token', missing_token) is original_source_token + + +@pytest.mark.parametrize( + 'token_fabric', + [ + *ALL_TOKENS_FABRICS_WITH_CANCELLING_SUPERPOWER, + partial(SimpleToken, cancelled=True), + ], +) +@pytest.mark.parametrize( + 'exception_fabric', + [ + partial(RuntimeError, 'custom message'), + lambda: type( + 'FalsyBaseException', + (BaseException,), + {'__bool__': lambda _self: False}, + )('custom message'), + lambda: type( + 'CancellationErrorSubclass', + (CancellationError,), + {}, + )('custom message', SimpleToken(doc='original-source')), + ], +) +def test_check_raises_passed_exception_instance_as_is(token_fabric, exception_fabric): + """ + Raise the supplied instance for manual and superpower cancellation. + + An ordinary exception, a falsy instance derived directly from + `BaseException`, and a `CancellationError` preserve identity, arguments, + and custom state. A local sentinel represents a missing `.token`, allowing + one identity assertion to prove that `CancellationError` retains its source + token while the other exceptions do not gain the attribute. + """ + token = token_fabric() + exception = exception_fabric() + exception.marker = 'preserved' + missing_token = object() + original_source_token = getattr(exception, 'token', missing_token) + + assert original_source_token is not token + + with pytest.raises(type(exception)) as exc_info: + token.check(exception=exception) + + assert exc_info.value is exception + assert exc_info.value.args == ('custom message', ) + assert exc_info.value.marker == 'preserved' + assert getattr(exc_info.value, 'token', missing_token) is original_source_token + + +@pytest.mark.parametrize( + 'token_fabric', + [ + *ALL_TOKENS_FABRICS_WITH_MANUAL_CANCELLATION, + DefaultToken, + ], +) +@pytest.mark.parametrize( + 'invalid_exception', + [ + 0, + object, + ], +) +def test_check_rejects_invalid_exception_regardless_of_token_state(token_fabric, invalid_exception): + """ + Reject invalid exception values regardless of token state. + + Manually cancelled regular tokens and the permanently active `DefaultToken` + all raise the same `ValueError`, including its exact message. + """ + token = token_fabric() + + with pytest.raises( + ValueError, + match=match('Only an exception instance or an exception class can be passed.'), + ) as exc_info: + token.check(exception=invalid_exception) + + assert type(exc_info.value) is ValueError + + +@pytest.mark.parametrize( + 'token_class', + [ + ConditionToken, + CounterToken, + ], +) +@pytest.mark.parametrize( + 'invalid_exception', + [ + 0, + object, + ], +) +def test_check_validates_exception_before_polling_token(token_class, invalid_exception): + """ + Validate invalid exception overrides before token-specific polling. + + A ConditionToken callback is not invoked and a CounterToken attempt is not + consumed when check() rejects its exception argument. + """ + condition_calls = [] + + def condition(): + condition_calls.append(None) + return False + + token = token_class(condition) if token_class is ConditionToken else token_class(5) + + with pytest.raises( + ValueError, + match=match('Only an exception instance or an exception class can be passed.'), + ) as exc_info: + token.check(exception=invalid_exception) + + assert type(exc_info.value) is ValueError + + if token_class is ConditionToken: + assert condition_calls == [] + else: + assert token.counter == 5 + + +@pytest.mark.parametrize( + 'token_fabric', + [*ALL_TOKENS_FABRICS, DefaultToken], +) +def test_check_rejects_positional_exception(token_fabric): + """Require exception overrides to be passed by keyword for every token.""" + token = token_fabric() + + with pytest.raises(TypeError): + token.check(ValueError) @pytest.mark.parametrize( diff --git a/tests/units/tokens/test_counter_token.py b/tests/units/tokens/test_counter_token.py index 5922a11..7530a98 100644 --- a/tests/units/tokens/test_counter_token.py +++ b/tests/units/tokens/test_counter_token.py @@ -218,12 +218,15 @@ def test_get_report_cancelled_nested(counter, counter_nested, from_token_is_nest @pytest.mark.parametrize( - 'function', + ('poll', 'expected_exception_type'), [ - lambda token: token.check(), - lambda token: token.is_cancelled(), - lambda token: token.cancelled, - lambda token: token.keep_on(), + (lambda token: token.check(), CounterCancellationError), + (lambda token: token.check(exception=None), CounterCancellationError), + (lambda token: token.check(exception=RuntimeError), RuntimeError), + (lambda token: token.check(exception=RuntimeError('custom message')), RuntimeError), + (lambda token: token.is_cancelled(), None), + (lambda token: token.cancelled, None), + (lambda token: token.keep_on(), None), ], ) @pytest.mark.parametrize( @@ -235,15 +238,39 @@ def test_get_report_cancelled_nested(counter, counter_nested, from_token_is_nest (0, 0), ], ) -def test_check_is_decrementing_counter(function, initial_counter, final_counter): - """Direct CounterToken checks consume one attempt without decrementing below zero.""" +def test_direct_status_operations_poll_counter_once( + poll, + expected_exception_type, + initial_counter, + final_counter, +): + """ + Poll the counter once for each tested direct status operation. + + `check()` covers omitted, `None`, class, and instance exception forms + alongside `is_cancelled()`, `cancelled`, and `keep_on()`. Positive counters + decrement once and zero remains zero. At zero, `check()` raises the exact + expected class while the other operations do not raise. + """ token = CounterToken(initial_counter) + poll_calls = [] + original_get_report = token._get_report - try: - function(token) - except CounterCancellationError: - pass + def record_poll_and_get_report(*args, **kwargs): + poll_calls.append(None) + return original_get_report(*args, **kwargs) + + token._get_report = record_poll_and_get_report + + if initial_counter == 0 and expected_exception_type is not None: + with pytest.raises(expected_exception_type) as exc_info: + poll(token) + + assert type(exc_info.value) is expected_exception_type + else: + poll(token) + assert poll_calls == [None] assert token.counter == final_counter diff --git a/tests/units/tokens/test_default_token.py b/tests/units/tokens/test_default_token.py index 8e77cec..6c4ce61 100644 --- a/tests/units/tokens/test_default_token.py +++ b/tests/units/tokens/test_default_token.py @@ -6,8 +6,17 @@ from cantok import DefaultToken, ImpossibleCancelError, SimpleToken -def test_dafault_token_is_not_cancelled_by_default(): - """DefaultToken starts active across the public status API.""" +@pytest.mark.parametrize( + 'check', + [ + lambda token: token.check(), + lambda token: token.check(exception=None), + lambda token: token.check(exception=UnicodeDecodeError), + lambda token: token.check(exception=RuntimeError('unused')), + ], +) +def test_default_token_is_not_cancelled_by_default(check): + """`DefaultToken` starts active, and every supported `check()` call returns `None`.""" token = DefaultToken() assert bool(token) @@ -15,15 +24,24 @@ def test_dafault_token_is_not_cancelled_by_default(): assert token.is_cancelled() == False assert token.keep_on() == True - token.check() + assert check(token) is None -def test_you_can_set_cancelled_attribute_as_false(): +@pytest.mark.parametrize( + 'check', + [ + lambda token: token.check(), + lambda token: token.check(exception=None), + lambda token: token.check(exception=UnicodeDecodeError), + lambda token: token.check(exception=RuntimeError('unused')), + ], +) +def test_you_can_set_cancelled_attribute_as_false(check): """ - Allow assigning False to DefaultToken.cancelled as a no-op. + Setting `DefaultToken.cancelled` to `False` is a no-op. - The token should still expose the never-cancelled state through every public - status API, and check() should not raise. + All status APIs still report an active token, and every supported `check()` + call returns `None`. """ token = DefaultToken() @@ -34,32 +52,57 @@ def test_you_can_set_cancelled_attribute_as_false(): assert token.is_cancelled() == False assert token.keep_on() == True - token.check() + assert check(token) is None -def test_you_cant_set_true_as_cancelled_attribute(): +@pytest.mark.parametrize( + 'check', + [ + lambda token: token.check(), + lambda token: token.check(exception=None), + lambda token: token.check(exception=UnicodeDecodeError), + lambda token: token.check(exception=RuntimeError('unused')), + ], +) +def test_you_cant_set_true_as_cancelled_attribute(check): """ - Reject cancellation attempts made through the DefaultToken.cancelled setter. + Setting `DefaultToken.cancelled` to `True` raises `ImpossibleCancelError`. - Setting the attribute to True must raise ImpossibleCancelError and leave the - token uncancelled. + The token remains active, so every supported `check()` call returns `None`. """ token = DefaultToken() - with pytest.raises(ImpossibleCancelError, match=match('You cannot cancel a default token.')): + with pytest.raises(ImpossibleCancelError, match=match('You cannot cancel a default token.')) as exc_info: token.cancelled = True + assert type(exc_info.value) is ImpossibleCancelError assert token.cancelled == False + assert check(token) is None -def test_you_cannot_cancel_default_token_by_standard_way(): - """`DefaultToken.cancel()` raises without changing its permanent non-cancelled state.""" +@pytest.mark.parametrize( + 'check', + [ + lambda token: token.check(), + lambda token: token.check(exception=None), + lambda token: token.check(exception=UnicodeDecodeError), + lambda token: token.check(exception=RuntimeError('unused')), + ], +) +def test_you_cannot_cancel_default_token_by_standard_way(check): + """ + `DefaultToken.cancel()` raises `ImpossibleCancelError`. + + The token remains active, and every supported `check()` call returns `None`. + """ token = DefaultToken() - with pytest.raises(ImpossibleCancelError, match=match('You cannot cancel a default token.')): + with pytest.raises(ImpossibleCancelError, match=match('You cannot cancel a default token.')) as exc_info: token.cancel() + assert type(exc_info.value) is ImpossibleCancelError assert token.cancelled == False + assert check(token) is None def test_str_for_default_token():