Async/Sync cancellation scope context manager
There are often times when you have nested code and a timeout can be used in multiple calls at any layer. To keep to an overall timeout for the entire operation you might pass along the start time of the operation and then recalculate the remaining timeout to use for other calls. This can quickly grow tedious.
This package seeks to solve this problem and others related to cancellation with the following features:
- decide exactly when to
check()
theCancelScope
, soCancelledError
does not show up in awkward points in the code, making graceful/clean shutdown easier - default to applying cancellation signals to only the descendants created in the context of the
CancelScope
being cancelled - optionally bubble up & out cancellation signals from children up to parents, making it easy to cancel everything in context if any operation fails
- optionally shield a
CancelScope
and its descendants from cancellation by parents, ensuring a critical child operation is not interrupted except by its own cancellation or timeout signals - works with sync/async code, so it can be used everywhere
Documentation consists of what you see here and the docs in the code.
- Python >=3.6
- This package uses
contextvars
, so allcontextvars
-aware concurrency libraries can use this package. If a concurrency package is not aware ofcontextvars
, then new threads/tasks may createCancelScope
instances outside the parentCancelScope
and cancellations wont get applied to those children. - There is going to be a small performance hit when async/sync are mixed together because async calling sync must push the call to a thread and sync calling async must push off to a running event loop. This seems to be unavoidable. If someone has an alternative, I am all ears.
The first example demonstrates how the timeout of a parent affects its children both in the unshielded and shielded cases.
import time
from cancel_scope import CancelScope
def work1():
with CancelScope(timeout=3, exc=Exception('work1 cancelled!')) as cs:
time.sleep(1)
cs.check()
time.sleep(1)
cs.check()
def work2():
with CancelScope(exc=Exception('work2 cancelled!'), shield=True) as cs:
time.sleep(1)
cs.check()
time.sleep(1)
cs.check()
# example using cancel scopes in child operations with one of them shielded
# and the timeout cancellation getting skipped
try:
started = time.time()
with CancelScope(timeout=3) as cs:
print(f'timeout: {cs.timeout()}')
work1()
print(f'timeout: {cs.timeout()}')
print(f'elapsed: {time.time() - started}')
work2()
print(f'timeout: {cs.timeout()}')
print(f'elapsed: {time.time() - started}')
work1()
except Exception as exc:
print(exc)
timeout: 3.0
timeout: 0.978079080581665
elapsed: 2.021920919418335
timeout: 0
elapsed: 4.038066625595093
work1 cancelled!
This example demonstrates how a manual cancellation from the parent affects the shielded and unshielded children.
import time
from concurrent.futures import CancelledError
from cancel_scope import CancelScope
def work3():
with CancelScope(exc=CancelledError('work3 cancelled!')) as cs:
time.sleep(1)
cs.check()
time.sleep(1)
cs.check()
def work4():
with CancelScope(exc=CancelledError('work4 cancelled!'), shield=True) as cs:
time.sleep(1)
cs.check()
time.sleep(1)
cs.check()
# example of parent cancelling child operations manually
try:
started = time.time()
with CancelScope(exc=CancelledError('Parent scope cancelled!')) as cs:
print(f'timeout: {cs.timeout()}')
work3()
cs.cancel()
print(f'timeout: {cs.timeout()}')
print(f'elapsed: {time.time() - started}')
work4()
print(f'timeout: {cs.timeout()}')
print(f'elapsed: {time.time() - started}')
work3()
except Exception as exc:
print(exc)
timeout: inf
timeout: 0
elapsed: 2.0253379344940186
timeout: 0
elapsed: 4.046663999557495
work3 cancelled!
This demonstrates how a cancellation of one child operation can trigger the cancellation of all descendents under a common parent, making it easier to cancel everything when one thing fails.
import asyncio
from cancel_scope import AsyncCancelScope
async def work5():
async with AsyncCancelScope(timeout=3, exc=asyncio.CancelledError('work5 cancelled!')) as pcs:
print(f'work5-parent cancelled={pcs.cancelled}')
async with AsyncCancelScope(timeout=3, exc=asyncio.CancelledError('work5 cancelled!')) as ccs:
print(f'work5-child cancelled={ccs.cancelled}')
print(f'cancel work5-child')
await ccs.cancel()
print(f'work5-child cancelled={ccs.cancelled}')
print(f'work5-parent cancelled={pcs.cancelled}')
async def work6():
async with AsyncCancelScope(exc=asyncio.CancelledError('work6 cancelled!')) as cs:
print(f'work6 cancelled={cs.cancelled}')
await asyncio.sleep(0)
print(f'work6 cancelled={cs.cancelled}')
async def main():
try:
async with AsyncCancelScope(bubble=True) as cs:
tasks = []
tasks.append(asyncio.create_task(work6()))
tasks.append(asyncio.create_task(work5()))
await asyncio.wait(tasks)
print(f'main cancelled={cs.cancelled}')
except Exception as exc:
print(exc)
asyncio.run(main())
work6 cancelled=False
work5-parent cancelled=False
work5-child cancelled=False
cancel work5-child
work5-child cancelled=True
work5-parent cancelled=True
work6 cancelled=True
main cancelled=True
the synchronous/thread code is able to bubble up a cancellation to the async code
import asyncio
import time
from cancel_scope import AsyncCancelScope, CancelScope
def sync_work1():
with CancelScope(exc=asyncio.CancelledError('sync_work1 cancelled!')) as cs:
print('sync_work1 started')
time.sleep(0.1)
cs.cancel()
print('sync_work1 cancelled manually')
def sync_work2():
with CancelScope(exc=asyncio.CancelledError('sync_work2 cancelled!')) as cs:
print('sync_work2 started')
time.sleep(0.3)
print(f'sync_work2 cancelled={cs.cancelled}')
async def async_work():
try:
async with AsyncCancelScope(exc=asyncio.CancelledError('async_work cancelled!'), bubble=True) as cs:
print('async_work started')
await asyncio.create_task(asyncio.sleep(0))
await asyncio.gather(asyncio.to_thread(sync_work1), asyncio.to_thread(sync_work2))
await cs.check()
except asyncio.CancelledError as exc:
print(exc)
asyncio.run(async_work())
async_work started
sync_work1 started
sync_work2 started
sync_work1 cancelled manually
sync_work2 cancelled=True
async_work cancelled!