Add strict parameter to cpu_bound and io_bound to propagate cancellation#5926
Add strict parameter to cpu_bound and io_bound to propagate cancellation#5926calebgregory wants to merge 1 commit intozauberzeug:mainfrom
Conversation
…cellation (zauberzeug#5925) `_run` silently returns `None` when the app is stopping, the executor is shut down, or the task is cancelled — violating the declared `-> R` return type. This causes two problems: (1) callers cannot distinguish a legitimate `None` return value from a cancellation, and (2) code that trusts the `R` return type hits downstream `TypeError`s at runtime when `R` is non-optional. Add a `strict` keyword parameter (default `False`) to `cpu_bound` and `io_bound`. When `True`, a `run.CancelledError` is raised instead of silently returning `None`. `Literal`-based overloads let callers opt into a `-> R` return type by passing `strict=True`. Note: this is a breaking type-level change. The default return type is now honestly `R | None`, so consumers will see new mypy errors where they previously did not. Runtime behavior is unchanged for callers that do not pass `strict`. Closes zauberzeug#5925
| class CancelledError(asyncio.CancelledError): | ||
| """Raised by ``cpu_bound`` / ``io_bound`` when *strict=True* and the | ||
| background work is cancelled or the app is shutting down. | ||
|
|
||
| A subclass of :class:`asyncio.CancelledError` so that existing | ||
| ``except CancelledError`` handlers continue to work. | ||
| """ |
There was a problem hiding this comment.
is there a strong reason to prefer a subclass vs just re-raising the existing exception... I suppose the one weird issue would be creating one in the first if strict pre-check.
But I'm generally hesitant to make new exceptions, and especially so when they have the same name as an underlying one. 🤔
There was a problem hiding this comment.
I appreciate the hesitancy to make new exceptions. I think that's why I had the impulse to name this one nicegui.run.CancelledError to match asyncio's name. But maybe it's better to give it a distinct name anyway, to prevent confusion.
I agree that raising a new asyncio.CancelledError() when the app is shutting down would be strange.
Defining the exception in nicegui.run offers some code-as-documentation + some consumer-convenience:
import asyncio
from nicegui import run
try:
run.cpu_bound(..., strict=True)
except asyncio.CancelledError:
...vs.
from nicegui import run
try:
run.cpu_bound(..., strict=True)
except run.CancelledError:
...
petergaultney
left a comment
There was a problem hiding this comment.
this looks pretty good to me. I have the one question about the exception definition, but no very strong preference there.
falkoschindler
left a comment
There was a problem hiding this comment.
Thanks for the PR, @calebgregory! The issue is real and the fix direction makes sense.
However, we're running into a mypy limitation with the current approach: you can't place additional keyword arguments between P.args and P.kwargs. That's why mypy reports "Arguments not allowed after ParamSpec.args" on every signature that mixes strict with ParamSpec.
We can work around this with overloads — using ParamSpec for the default (non-strict) overload and Callable[..., R] for the strict=True overload. That preserves argument type-checking for the default case. But strict=True loses ParamSpec checking, which is unfortunate since strict=True is meant to become the recommended mode (and eventually the default after the next major release).
Do you have ideas for how to keep ParamSpec argument checking in the strict=True case? Some options we've considered but aren't thrilled with:
- Separate functions (e.g.
cpu_bound_strict) instead of a parameter — preservesParamSpecbut clutters the API - A wrapper/decorator that converts the return type — might be too clever
- Waiting for PEP 612 extensions that allow extra kwargs alongside
ParamSpec— not available yet
Open to other ideas if you see a cleaner way to thread this needle.
Closes #5925
Summary
_runsilently returnsNonewhen the app is stopping, the executor is shut down, or the task is cancelled — violating the declared-> Rreturn type. This causes two problems:Nonereturn value from a cancellationRreturn type hits downstreamTypeErrors at runtime whenRis non-optionalThis PR adds a
strictkeyword parameter (defaultFalse) tocpu_boundandio_bound:strict=True→ raisesrun.CancelledError(subclass ofasyncio.CancelledError) instead of returningNone; return type isRstrict=False→ preserves current runtime behavior; return type is honestlyR | NoneLiteral-based overloads let the type checker narrow the return type based on the value ofstrict.Note: This is a breaking type-level change. The default return type is now
R | None, so consumers will see new mypy errors where they previously did not. Runtime behavior is unchanged for callers that do not passstrict.Test plan
test_run.pytests pass (no behavioral regression)test_returns_none_when_app_is_stopping— verifies default behavior returnsNone(parametrized acrosscpu_boundandio_bound)test_strict_raises_when_app_is_stopping— verifiesstrict=Trueraisesrun.CancelledError(parametrized acrosscpu_boundandio_bound)ruff checkandautopep8cleanCo-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com