Skip to content

Commit d116cd9

Browse files
authored
Merge pull request #60 from mutating/develop
0.0.43
2 parents 2d2b4e7 + 9b70775 commit d116cd9

7 files changed

Lines changed: 481 additions & 68 deletions

File tree

cantok/tokens/abstract/abstract_token.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from abc import ABC, abstractmethod
22
from threading import RLock
33
from time import sleep
4-
from typing import Any, Dict, List, Optional, Union
4+
from typing import Any, Dict, List, Optional, Type, Union
55

66
from printo import describe_call, not_none
77

@@ -71,7 +71,7 @@ def __repr__(self) -> str:
7171
cancelled = report.from_token is self and report.cause == CancelCause.CANCELLED
7272

7373
return describe_call(
74-
type(self).__name__,
74+
type(self),
7575
[superpower, *self._tokens],
7676
{
7777
'cancelled': cancelled,
@@ -222,7 +222,7 @@ def cancel(self) -> 'AbstractToken':
222222
self._cancelled = True
223223
return self
224224

225-
def check(self) -> None:
225+
def check(self, *, exception: Optional[Union[Type[BaseException], BaseException]] = None) -> None:
226226
"""
227227
Raises an exception if the token is cancelled; does nothing otherwise.
228228
@@ -231,19 +231,47 @@ def check(self) -> None:
231231
- Automatic cancellation by a specific token type raises the corresponding
232232
subclass (e.g. TimeoutCancellationError for TimeoutToken).
233233
234+
:param exception: Optional keyword-only exception class or instance used
235+
instead of the standard cancellation exception. A class
236+
receives the standard cancellation message; an instance
237+
is raised as is.
238+
:raises ValueError: If exception is neither an exception class nor instance.
239+
234240
>>> token = SimpleToken()
235241
>>> token.check() # nothing happens
236242
>>> token.cancel()
237243
>>> token.check() # raises CancellationError
238244
"""
245+
if exception is not None and not (
246+
isinstance(exception, BaseException)
247+
or (isinstance(exception, type) and issubclass(exception, BaseException))
248+
):
249+
raise ValueError('Only an exception instance or an exception class can be passed.')
250+
239251
with self._lock:
240252
report = self._get_report()
241253

242-
if report.cause == CancelCause.CANCELLED:
243-
report.from_token._raise_cancelled_exception()
254+
if exception is None:
255+
if report.cause == CancelCause.CANCELLED:
256+
report.from_token._raise_cancelled_exception()
257+
258+
elif report.cause == CancelCause.SUPERPOWER:
259+
report.from_token._raise_superpower_exception()
260+
261+
elif report.cause != CancelCause.NOT_CANCELLED:
262+
if isinstance(exception, BaseException):
263+
raise exception
264+
265+
message = (
266+
'The token has been cancelled.'
267+
if report.cause == CancelCause.CANCELLED
268+
else report.from_token._get_superpower_exception_message()
269+
) + report.from_token._get_exception_message_suffix()
270+
271+
if issubclass(exception, CancellationError):
272+
raise exception(message, report.from_token)
244273

245-
elif report.cause == CancelCause.SUPERPOWER:
246-
report.from_token._raise_superpower_exception()
274+
raise exception(message)
247275

248276
def _get_report(self, direct: bool = True) -> CancellationReport:
249277
if self._cancelled:

docs/what_are_tokens/exceptions.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ Each type of token (except [`DefaultToken`](../types_of_tokens/DefaultToken.md))
3232

3333
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.
3434

35+
`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.
36+
37+
```python
38+
from cantok import SimpleToken
39+
40+
token = SimpleToken()
41+
token.cancel()
42+
token.check(exception=RuntimeError)
43+
#> ...
44+
#> RuntimeError: The token has been cancelled.
45+
```
46+
3547
`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:
3648

3749
```python

pyproject.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "cantok"
7-
version = "0.0.42"
7+
version = "0.0.43"
88
authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }]
99
description = 'Implementation of the "Cancellation Token" pattern'
1010
readme = "README.md"
1111
requires-python = ">=3.8"
1212
dependencies = [
13-
'printo>=0.0.28',
14-
'transfunctions>=0.0.12',
13+
'printo>=0.0.29',
1514
'typing_extensions>=4.5.0 ; python_version < "3.13"',
1615
]
1716
classifiers = [

tests/typing/tokens/abstract/test_abstract_token.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,53 @@
77
from typing import assert_type
88

99
import pytest
10+
from full_match import match
1011

1112
from cantok import AbstractToken, SimpleToken
1213

1314

1415
@pytest.mark.mypy_testing
15-
def test_abstract_token_exposes_doc_attribute_as_optional_string():
16+
def test_abstract_token_exposes_doc_and_check_type_contracts():
1617
"""
17-
`AbstractToken` exposes `doc` as an optional string in the type contract.
18+
Expose the public type contracts of `doc` and `check()`.
1819
19-
A concrete token with `doc` is assigned to an `AbstractToken` variable, and
20-
`assert_type` fixes the public attribute type.
20+
`doc` is `Optional[str]`. The keyword-only `exception` may be omitted or set
21+
to `None`, and accepts classes and instances throughout the `BaseException`
22+
hierarchy. Every `check()` call has inferred type `None`.
2123
"""
2224
token: AbstractToken = SimpleToken(doc='d')
2325

2426
assert_type(token.doc, Optional[str])
27+
assert_type(token.check(), None)
28+
assert_type(token.check(exception=None), None)
29+
assert_type(token.check(exception=RuntimeError), None)
30+
assert_type(token.check(exception=RuntimeError('custom message')), None)
31+
assert_type(token.check(exception=SystemExit), None)
32+
assert_type(token.check(exception=SystemExit('custom message')), None)
33+
34+
35+
@pytest.mark.mypy_testing
36+
def test_abstract_token_rejects_invalid_check_exception_types():
37+
"""
38+
Reject non-exception values and classes statically and at runtime.
39+
40+
At runtime, both invalid forms raise the documented `ValueError` with its
41+
exact message.
42+
"""
43+
token: AbstractToken = SimpleToken()
44+
expected_message = match('Only an exception instance or an exception class can be passed.')
45+
46+
with pytest.raises(ValueError, match=expected_message):
47+
token.check(exception=1) # E: [arg-type]
48+
49+
with pytest.raises(ValueError, match=expected_message):
50+
token.check(exception=object) # E: [arg-type]
51+
52+
53+
@pytest.mark.mypy_testing
54+
def test_abstract_token_check_exception_is_keyword_only():
55+
"""Reject positional exception overrides statically and at runtime."""
56+
token: AbstractToken = SimpleToken()
57+
58+
with pytest.raises(TypeError):
59+
token.check(ValueError) # E: [misc]

0 commit comments

Comments
 (0)