calltimeout 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- calltimeout/__init__.py +11 -0
- calltimeout/_api.py +91 -0
- calltimeout/_decorator.py +77 -0
- calltimeout/_errors.py +30 -0
- calltimeout/_process.py +141 -0
- calltimeout/_signal.py +131 -0
- calltimeout/_thread.py +52 -0
- calltimeout/_validate.py +60 -0
- calltimeout/_wire.py +76 -0
- calltimeout/py.typed +0 -0
- calltimeout-0.2.0.dist-info/METADATA +233 -0
- calltimeout-0.2.0.dist-info/RECORD +14 -0
- calltimeout-0.2.0.dist-info/WHEEL +4 -0
- calltimeout-0.2.0.dist-info/licenses/LICENSE +21 -0
calltimeout/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Run a callable with a wall-clock timeout and get a typed result or a clean error."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._api import Mode, run
|
|
6
|
+
from ._decorator import with_timeout
|
|
7
|
+
from ._errors import CallCrashed, CallTimeout
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["CallCrashed", "CallTimeout", "Mode", "run", "with_timeout"]
|
calltimeout/_api.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Public entry point: run a callable under a wall-clock timeout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Final, Literal, TypeVar, overload
|
|
8
|
+
|
|
9
|
+
from ._errors import CallTimeout, Expired
|
|
10
|
+
from ._process import run_in_process
|
|
11
|
+
from ._signal import check_signal_mode, run_with_signal
|
|
12
|
+
from ._thread import run_in_thread
|
|
13
|
+
from ._validate import check_call, check_mode, check_timeout
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
D = TypeVar("D")
|
|
17
|
+
|
|
18
|
+
Mode = Literal["thread", "process", "signal"]
|
|
19
|
+
|
|
20
|
+
RUNNERS: Final = {
|
|
21
|
+
"thread": run_in_thread,
|
|
22
|
+
"process": run_in_process,
|
|
23
|
+
"signal": run_with_signal,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Unset(Enum):
|
|
28
|
+
token = 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
UNSET: Final = _Unset.token
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@overload
|
|
35
|
+
def run(
|
|
36
|
+
fn: Callable[..., T],
|
|
37
|
+
timeout: float,
|
|
38
|
+
*,
|
|
39
|
+
args: tuple[object, ...] = ...,
|
|
40
|
+
kwargs: Mapping[str, object] | None = ...,
|
|
41
|
+
mode: Mode = ...,
|
|
42
|
+
) -> T: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@overload
|
|
46
|
+
def run(
|
|
47
|
+
fn: Callable[..., T],
|
|
48
|
+
timeout: float,
|
|
49
|
+
*,
|
|
50
|
+
args: tuple[object, ...] = ...,
|
|
51
|
+
kwargs: Mapping[str, object] | None = ...,
|
|
52
|
+
mode: Mode = ...,
|
|
53
|
+
default: D,
|
|
54
|
+
) -> T | D: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(
|
|
58
|
+
fn: Callable[..., T],
|
|
59
|
+
timeout: float,
|
|
60
|
+
*,
|
|
61
|
+
args: tuple[object, ...] = (),
|
|
62
|
+
kwargs: Mapping[str, object] | None = None,
|
|
63
|
+
mode: Mode = "thread",
|
|
64
|
+
default: D | _Unset = UNSET,
|
|
65
|
+
) -> T | D:
|
|
66
|
+
"""Run fn and return its result, or handle the timeout.
|
|
67
|
+
|
|
68
|
+
mode "thread" (the default) runs fn in a daemon thread and stops waiting on overrun;
|
|
69
|
+
the call itself cannot be killed and keeps running if it ignores the clock.
|
|
70
|
+
mode "process" runs fn in a child process that is terminated on overrun; fn, its
|
|
71
|
+
arguments and its result must be picklable, and a child that dies without a result
|
|
72
|
+
raises CallCrashed. mode "signal" (POSIX, main thread) raises CallTimeout inside fn.
|
|
73
|
+
|
|
74
|
+
A zero timeout raises at once without starting fn. If default is given it is
|
|
75
|
+
returned when this call's own budget runs out; otherwise CallTimeout is raised. A
|
|
76
|
+
CallTimeout from a nested call inside fn always propagates.
|
|
77
|
+
"""
|
|
78
|
+
seconds = check_timeout(timeout)
|
|
79
|
+
check_call(fn, args, kwargs)
|
|
80
|
+
check_mode(mode, RUNNERS)
|
|
81
|
+
runner = RUNNERS[mode]
|
|
82
|
+
if mode == "signal":
|
|
83
|
+
check_signal_mode() # before the zero-budget shortcut, so misuse always surfaces
|
|
84
|
+
try:
|
|
85
|
+
if seconds == 0:
|
|
86
|
+
raise Expired
|
|
87
|
+
return runner(fn, seconds, args, dict(kwargs or {}))
|
|
88
|
+
except Expired as expired:
|
|
89
|
+
if not isinstance(default, _Unset):
|
|
90
|
+
return default
|
|
91
|
+
raise CallTimeout(seconds) from expired.__cause__
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Decorator form of run that keeps the wrapped function's signature."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import ParamSpec, TypeVar, overload
|
|
9
|
+
|
|
10
|
+
from ._api import RUNNERS, UNSET, Mode, _Unset, run
|
|
11
|
+
from ._process import claim_child_target
|
|
12
|
+
from ._validate import check_call, check_mode, check_timeout
|
|
13
|
+
|
|
14
|
+
P = ParamSpec("P")
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
D = TypeVar("D")
|
|
17
|
+
R = TypeVar("R")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _by_name(obj: object) -> object:
|
|
21
|
+
"""Whatever pickle would find under obj's module and qualified name, or None."""
|
|
22
|
+
target: object = sys.modules.get(getattr(obj, "__module__", None) or "")
|
|
23
|
+
for part in str(getattr(obj, "__qualname__", "<>")).split("."):
|
|
24
|
+
target = getattr(target, part, None)
|
|
25
|
+
return target
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _process_target(wrapper: Callable[..., R], fn: Callable[..., R]) -> Callable[..., R]:
|
|
29
|
+
# The wrapper is sent when its name leads to it (the child then unwraps it); when
|
|
30
|
+
# the name still leads to the original (fast = with_timeout(...)(slow)), the
|
|
31
|
+
# original is sent. Under fork nothing is pickled and either works.
|
|
32
|
+
if _by_name(wrapper) is not wrapper and _by_name(fn) is fn:
|
|
33
|
+
return fn
|
|
34
|
+
return wrapper
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@overload
|
|
38
|
+
def with_timeout(
|
|
39
|
+
timeout: float, *, mode: Mode = ...
|
|
40
|
+
) -> Callable[[Callable[P, T]], Callable[P, T]]: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@overload
|
|
44
|
+
def with_timeout(
|
|
45
|
+
timeout: float, *, mode: Mode = ..., default: D
|
|
46
|
+
) -> Callable[[Callable[P, T]], Callable[P, T | D]]: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def with_timeout(
|
|
50
|
+
timeout: float, *, mode: Mode = "thread", default: D | _Unset = UNSET
|
|
51
|
+
) -> Callable[[Callable[P, T]], Callable[P, T | D]]:
|
|
52
|
+
"""Decorate a function so every call runs under run(fn, timeout, mode=...).
|
|
53
|
+
|
|
54
|
+
The decorated function keeps its parameters for type checkers. With default, a
|
|
55
|
+
timed-out call returns it instead of raising. In process mode, under the spawn and
|
|
56
|
+
forkserver start methods, the child imports the function by name, so the decorated
|
|
57
|
+
function or the original must be reachable under its own module and name.
|
|
58
|
+
"""
|
|
59
|
+
check_timeout(timeout)
|
|
60
|
+
check_mode(mode, RUNNERS)
|
|
61
|
+
|
|
62
|
+
def decorate(fn: Callable[P, T]) -> Callable[P, T | D]:
|
|
63
|
+
check_call(fn, (), None)
|
|
64
|
+
|
|
65
|
+
@functools.wraps(fn)
|
|
66
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | D:
|
|
67
|
+
if mode == "process" and claim_child_target(wrapper):
|
|
68
|
+
# Already inside the child started for this call: run the body.
|
|
69
|
+
return fn(*args, **kwargs)
|
|
70
|
+
target = _process_target(wrapper, fn) if mode == "process" else fn
|
|
71
|
+
if isinstance(default, _Unset):
|
|
72
|
+
return run(target, timeout, args=args, kwargs=kwargs, mode=mode)
|
|
73
|
+
return run(target, timeout, args=args, kwargs=kwargs, mode=mode, default=default)
|
|
74
|
+
|
|
75
|
+
return wrapper
|
|
76
|
+
|
|
77
|
+
return decorate
|
calltimeout/_errors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Exceptions raised when a call overruns its budget or its process dies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CallTimeout(TimeoutError):
|
|
7
|
+
"""Raised when a call does not finish within its allotted time."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, timeout: float) -> None:
|
|
10
|
+
self.timeout = timeout
|
|
11
|
+
super().__init__(f"call did not finish within {timeout} seconds")
|
|
12
|
+
|
|
13
|
+
def __reduce__(self) -> tuple[type[CallTimeout], tuple[float]]:
|
|
14
|
+
# The default reduce would rebuild from the message and lose the number.
|
|
15
|
+
return (type(self), (self.timeout,))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CallCrashed(RuntimeError):
|
|
19
|
+
"""Raised in process mode when the child exits without sending a result."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, exitcode: int | None) -> None:
|
|
22
|
+
self.exitcode = exitcode
|
|
23
|
+
super().__init__(f"the call's process exited with code {exitcode} before returning")
|
|
24
|
+
|
|
25
|
+
def __reduce__(self) -> tuple[type[CallCrashed], tuple[int | None]]:
|
|
26
|
+
return (type(self), (self.exitcode,))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Expired(Exception):
|
|
30
|
+
"""Private signal from a runner to the entry point: this call's own budget ran out."""
|
calltimeout/_process.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Run a callable in a separate process that can be truly terminated."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import multiprocessing
|
|
6
|
+
import pickle
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
9
|
+
from multiprocessing.connection import Connection, wait
|
|
10
|
+
from multiprocessing.process import BaseProcess
|
|
11
|
+
from typing import Any, Protocol, TypeVar, cast
|
|
12
|
+
|
|
13
|
+
from ._errors import CallCrashed, Expired
|
|
14
|
+
from ._thread import thread_name
|
|
15
|
+
from ._validate import reject_coroutine
|
|
16
|
+
from ._wire import pack_error, pack_result, prepickle, unpack
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
# Seconds a terminated child gets to exit before it is killed (SIGTERM can be ignored).
|
|
21
|
+
GRACE = 1.0
|
|
22
|
+
|
|
23
|
+
# Longest sleep between checks that the child is still alive.
|
|
24
|
+
POLL = 0.25
|
|
25
|
+
|
|
26
|
+
# The target this child process was started for, so a decorated function knows it is
|
|
27
|
+
# already inside its own child and must not start another one.
|
|
28
|
+
_targets: list[object] = []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def claim_child_target(fn: object) -> bool:
|
|
32
|
+
"""Return True once if fn is the call this child process was started to run."""
|
|
33
|
+
if _targets and _targets[-1] is fn:
|
|
34
|
+
_targets.pop()
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Reader(Protocol):
|
|
40
|
+
def poll(self) -> bool: ...
|
|
41
|
+
def recv_bytes(self) -> bytes: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _child(call: object, conn: Connection) -> None:
|
|
45
|
+
if isinstance(call, bytes):
|
|
46
|
+
call = pickle.loads(call)
|
|
47
|
+
fn, args, kwargs = cast(tuple[Callable[..., object], tuple[object, ...], dict[str, Any]], call)
|
|
48
|
+
_targets.append(fn)
|
|
49
|
+
try:
|
|
50
|
+
try:
|
|
51
|
+
data = pack_result(reject_coroutine(fn(*args, **kwargs)))
|
|
52
|
+
except BaseException as exc:
|
|
53
|
+
data = pack_error(exc)
|
|
54
|
+
conn.send_bytes(data)
|
|
55
|
+
finally:
|
|
56
|
+
conn.close()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _await(reader: Any, process: BaseProcess, timeout: float) -> list[object]:
|
|
60
|
+
"""Wait for a result or the child's exit, whichever comes first; [] on overrun."""
|
|
61
|
+
deadline = time.monotonic() + timeout
|
|
62
|
+
while True:
|
|
63
|
+
left = deadline - time.monotonic()
|
|
64
|
+
if left <= 0:
|
|
65
|
+
return []
|
|
66
|
+
ready = wait([reader, process.sentinel], min(left, POLL))
|
|
67
|
+
if ready:
|
|
68
|
+
return list(ready)
|
|
69
|
+
if not process.is_alive():
|
|
70
|
+
# A grandchild forked by the call inherits the pipe and, under fork and
|
|
71
|
+
# spawn, the sentinel too, hiding the exit from both; waitpid still sees it.
|
|
72
|
+
return [process.sentinel]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _exits_within(process: BaseProcess, seconds: float) -> bool:
|
|
76
|
+
# Not join(seconds): it waits on the sentinel, which a grandchild may hold open.
|
|
77
|
+
deadline = time.monotonic() + seconds
|
|
78
|
+
while process.is_alive() and time.monotonic() < deadline:
|
|
79
|
+
time.sleep(0.01)
|
|
80
|
+
return not process.is_alive()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _stop(process: BaseProcess, *, finished: bool) -> None:
|
|
84
|
+
"""End the child: a finished one first gets to exit on its own (atexit, flushes)."""
|
|
85
|
+
try:
|
|
86
|
+
if not (finished and _exits_within(process, GRACE)) and process.is_alive():
|
|
87
|
+
process.terminate()
|
|
88
|
+
_exits_within(process, GRACE)
|
|
89
|
+
finally:
|
|
90
|
+
# Also reached on KeyboardInterrupt during the grace period.
|
|
91
|
+
if process.is_alive():
|
|
92
|
+
process.kill()
|
|
93
|
+
process.join()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _receive(reader: _Reader, ready: Sequence[object]) -> bytes | None:
|
|
97
|
+
# The child may have sent and exited in the same instant: check the pipe either way.
|
|
98
|
+
if reader in ready or reader.poll():
|
|
99
|
+
try:
|
|
100
|
+
return reader.recv_bytes()
|
|
101
|
+
except EOFError:
|
|
102
|
+
return None
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_in_process(
|
|
107
|
+
fn: Callable[..., T],
|
|
108
|
+
timeout: float,
|
|
109
|
+
args: tuple[object, ...],
|
|
110
|
+
kwargs: Mapping[str, object],
|
|
111
|
+
) -> T:
|
|
112
|
+
"""Run fn in a child process, terminating it if it overruns the budget.
|
|
113
|
+
|
|
114
|
+
Unlike thread mode this truly stops the work on timeout, but fn, its arguments
|
|
115
|
+
and its result must be picklable so they can cross the process boundary. A child
|
|
116
|
+
that exits without a result raises CallCrashed as soon as it is gone.
|
|
117
|
+
"""
|
|
118
|
+
ctx = multiprocessing.get_context()
|
|
119
|
+
call = prepickle(ctx.get_start_method(), (fn, args, dict(kwargs)))
|
|
120
|
+
reader, writer = ctx.Pipe(duplex=False)
|
|
121
|
+
process = ctx.Process(target=_child, args=(call, writer), name=thread_name(fn))
|
|
122
|
+
started = finished = False
|
|
123
|
+
try:
|
|
124
|
+
process.start()
|
|
125
|
+
started = True
|
|
126
|
+
writer.close()
|
|
127
|
+
ready = _await(reader, process, timeout)
|
|
128
|
+
if not ready:
|
|
129
|
+
raise Expired
|
|
130
|
+
data = _receive(reader, ready)
|
|
131
|
+
if data is None:
|
|
132
|
+
process.join()
|
|
133
|
+
raise CallCrashed(process.exitcode)
|
|
134
|
+
finished = True
|
|
135
|
+
finally:
|
|
136
|
+
# Runs on every exit, including KeyboardInterrupt while waiting.
|
|
137
|
+
writer.close()
|
|
138
|
+
reader.close()
|
|
139
|
+
if started:
|
|
140
|
+
_stop(process, finished=finished)
|
|
141
|
+
return cast(T, unpack(data))
|
calltimeout/_signal.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Interrupt a call in place with SIGALRM (POSIX, main thread only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from types import FrameType
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
from ._errors import CallTimeout, Expired
|
|
14
|
+
from ._validate import reject_coroutine
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
# Smallest delay used to re-arm a timer that is already due: zero would disarm it.
|
|
19
|
+
_SOON = 1e-6
|
|
20
|
+
|
|
21
|
+
_NO_SIGALRM = "mode 'signal' needs SIGALRM, which Windows lacks; use 'thread' or 'process'"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_signal_mode() -> None:
|
|
25
|
+
"""Raise if this platform or thread cannot use SIGALRM."""
|
|
26
|
+
if sys.platform == "win32":
|
|
27
|
+
raise NotImplementedError(_NO_SIGALRM)
|
|
28
|
+
if threading.current_thread() is not threading.main_thread():
|
|
29
|
+
raise RuntimeError("mode 'signal' only works in the main thread")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _caused_by(exc: BaseException, raised: CallTimeout) -> bool:
|
|
33
|
+
"""True if exc is our timeout or an Exception raised while handling it."""
|
|
34
|
+
seen: set[int] = set()
|
|
35
|
+
current: BaseException | None = exc
|
|
36
|
+
while current is not None and id(current) not in seen:
|
|
37
|
+
if current is raised:
|
|
38
|
+
return True
|
|
39
|
+
if not isinstance(current, Exception):
|
|
40
|
+
return False # KeyboardInterrupt, SystemExit and friends always propagate
|
|
41
|
+
seen.add(id(current))
|
|
42
|
+
current = current.__cause__ or current.__context__
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _pass_on(previous: Any, signum: int, frame: FrameType | None) -> None:
|
|
47
|
+
"""Give an enclosing timer's tick to the handler that was there before."""
|
|
48
|
+
if callable(previous):
|
|
49
|
+
previous(signum, frame)
|
|
50
|
+
elif previous == signal.SIG_DFL:
|
|
51
|
+
# The default action ends the process, which is what that timer was set up for.
|
|
52
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
53
|
+
signal.raise_signal(signum)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_with_signal(
|
|
57
|
+
fn: Callable[..., T],
|
|
58
|
+
timeout: float,
|
|
59
|
+
args: tuple[object, ...],
|
|
60
|
+
kwargs: Mapping[str, object],
|
|
61
|
+
) -> T:
|
|
62
|
+
"""Run fn in this thread and raise CallTimeout inside it when the budget runs out.
|
|
63
|
+
|
|
64
|
+
Pure Python code and blocking system calls are interrupted where they are (PEP 475:
|
|
65
|
+
a handler that raises stops the retry). Code running in C without returning to the
|
|
66
|
+
interpreter is only interrupted once it does. A timer that was already armed keeps
|
|
67
|
+
its schedule and its handler still runs when it is due.
|
|
68
|
+
"""
|
|
69
|
+
check_signal_mode()
|
|
70
|
+
if sys.platform == "win32": # repeated so type checkers on Windows skip the rest
|
|
71
|
+
raise NotImplementedError(_NO_SIGALRM)
|
|
72
|
+
start = time.monotonic()
|
|
73
|
+
deadline = start + timeout
|
|
74
|
+
outer_delay, outer_interval = signal.getitimer(signal.ITIMER_REAL)
|
|
75
|
+
outer_due = start + outer_delay if outer_delay > 0 else None
|
|
76
|
+
fired = False
|
|
77
|
+
raised = CallTimeout(timeout)
|
|
78
|
+
|
|
79
|
+
def arm(now: float) -> None:
|
|
80
|
+
due = deadline if outer_due is None else min(deadline, outer_due)
|
|
81
|
+
signal.setitimer(signal.ITIMER_REAL, max(due - now, _SOON))
|
|
82
|
+
|
|
83
|
+
def expire(signum: int, frame: FrameType | None) -> None:
|
|
84
|
+
nonlocal fired, outer_due, outer_interval
|
|
85
|
+
now = time.monotonic()
|
|
86
|
+
if now >= deadline:
|
|
87
|
+
fired = True
|
|
88
|
+
raise raised
|
|
89
|
+
if outer_due is not None and now >= outer_due:
|
|
90
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
91
|
+
# This tick is consumed even if the handler raises: never deliver it twice.
|
|
92
|
+
outer_due = now + outer_interval if outer_interval > 0 else None
|
|
93
|
+
try:
|
|
94
|
+
_pass_on(previous, signum, frame)
|
|
95
|
+
finally:
|
|
96
|
+
# The enclosing handler may have re-armed the timer: its new schedule.
|
|
97
|
+
delay, interval = signal.getitimer(signal.ITIMER_REAL)
|
|
98
|
+
now = time.monotonic()
|
|
99
|
+
if delay > 0:
|
|
100
|
+
outer_due, outer_interval = now + delay, interval
|
|
101
|
+
arm(now)
|
|
102
|
+
return
|
|
103
|
+
arm(now) # woke a little early: wait for whichever is due next
|
|
104
|
+
|
|
105
|
+
if signal.getsignal(signal.SIGALRM) is None:
|
|
106
|
+
raise RuntimeError("SIGALRM has a handler set outside Python that could not be restored")
|
|
107
|
+
previous = signal.signal(signal.SIGALRM, expire)
|
|
108
|
+
try:
|
|
109
|
+
arm(start)
|
|
110
|
+
try:
|
|
111
|
+
value = fn(*args, **kwargs)
|
|
112
|
+
except BaseException as exc:
|
|
113
|
+
if fired and _caused_by(exc, raised):
|
|
114
|
+
raise Expired from exc
|
|
115
|
+
raise
|
|
116
|
+
finally:
|
|
117
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
118
|
+
except CallTimeout as exc:
|
|
119
|
+
# The alarm can land after the call returned but before the timer was disarmed.
|
|
120
|
+
if exc is raised:
|
|
121
|
+
raise Expired from exc
|
|
122
|
+
raise
|
|
123
|
+
finally:
|
|
124
|
+
signal.signal(signal.SIGALRM, signal.SIG_DFL if previous is None else previous)
|
|
125
|
+
if outer_due is not None:
|
|
126
|
+
left = outer_due - time.monotonic()
|
|
127
|
+
signal.setitimer(signal.ITIMER_REAL, max(left, _SOON), outer_interval)
|
|
128
|
+
if fired:
|
|
129
|
+
# The call caught the timeout and returned later; the budget was still exceeded.
|
|
130
|
+
raise Expired
|
|
131
|
+
return reject_coroutine(value)
|
calltimeout/_thread.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Run a callable in a daemon thread and wait for it with a timeout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
from ._errors import Expired
|
|
11
|
+
from ._validate import reject_coroutine
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def thread_name(fn: object) -> str:
|
|
17
|
+
"""Name a worker thread after its call so a lingering one is recognisable."""
|
|
18
|
+
name = getattr(fn, "__qualname__", None) or type(fn).__qualname__
|
|
19
|
+
return f"calltimeout:{name}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_in_thread(
|
|
23
|
+
fn: Callable[..., T],
|
|
24
|
+
timeout: float,
|
|
25
|
+
args: tuple[object, ...],
|
|
26
|
+
kwargs: Mapping[str, object],
|
|
27
|
+
) -> T:
|
|
28
|
+
"""Run fn in a daemon thread and return its result within the budget.
|
|
29
|
+
|
|
30
|
+
A timed-out call is not killed: Python cannot interrupt a running thread. The
|
|
31
|
+
caller stops waiting and raises, but a non-cooperative call keeps running in
|
|
32
|
+
the background until it returns on its own or the process exits.
|
|
33
|
+
"""
|
|
34
|
+
result: list[T] = []
|
|
35
|
+
error: list[BaseException] = []
|
|
36
|
+
# Same as asyncio.to_thread: the call sees the caller's context variables.
|
|
37
|
+
context = contextvars.copy_context()
|
|
38
|
+
|
|
39
|
+
def target() -> None:
|
|
40
|
+
try:
|
|
41
|
+
result.append(context.run(fn, *args, **kwargs))
|
|
42
|
+
except BaseException as exc:
|
|
43
|
+
error.append(exc)
|
|
44
|
+
|
|
45
|
+
thread = threading.Thread(target=target, name=thread_name(fn), daemon=True)
|
|
46
|
+
thread.start()
|
|
47
|
+
thread.join(timeout)
|
|
48
|
+
if thread.is_alive():
|
|
49
|
+
raise Expired
|
|
50
|
+
if error:
|
|
51
|
+
raise error[0]
|
|
52
|
+
return reject_coroutine(result[0])
|
calltimeout/_validate.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Argument checks shared by every mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import math
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
from numbers import Real
|
|
10
|
+
from typing import TypeVar
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
_ASYNC_HINT = "use asyncio.timeout() for coroutines; calltimeout runs blocking callables"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_timeout(timeout: object) -> float:
|
|
18
|
+
"""Return the budget as a float, or raise if it is not a usable number of seconds."""
|
|
19
|
+
if isinstance(timeout, bool) or not isinstance(timeout, Real):
|
|
20
|
+
raise TypeError(f"timeout must be a number of seconds, not {type(timeout).__name__}")
|
|
21
|
+
try:
|
|
22
|
+
seconds = float(timeout) + 0.0 # + 0.0 turns -0.0 into 0.0
|
|
23
|
+
except OverflowError:
|
|
24
|
+
raise ValueError(f"timeout must be at most {threading.TIMEOUT_MAX} seconds") from None
|
|
25
|
+
if math.isnan(seconds):
|
|
26
|
+
raise ValueError("timeout must be a number, not NaN")
|
|
27
|
+
if seconds < 0:
|
|
28
|
+
raise ValueError("timeout must not be negative")
|
|
29
|
+
if seconds > threading.TIMEOUT_MAX:
|
|
30
|
+
# Covers infinity too: every wait primitive rejects values above this limit.
|
|
31
|
+
raise ValueError(f"timeout must be at most {threading.TIMEOUT_MAX} seconds")
|
|
32
|
+
return seconds
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_call(fn: object, args: object, kwargs: object) -> None:
|
|
36
|
+
"""Reject arguments the call could never run with, before anything is started."""
|
|
37
|
+
if not callable(fn):
|
|
38
|
+
raise TypeError(f"fn must be callable, not {type(fn).__name__}")
|
|
39
|
+
if inspect.iscoroutinefunction(fn):
|
|
40
|
+
raise TypeError(f"{fn!r} is a coroutine function: {_ASYNC_HINT}")
|
|
41
|
+
if not isinstance(args, tuple):
|
|
42
|
+
raise TypeError(f"args must be a tuple, not {type(args).__name__}")
|
|
43
|
+
if kwargs is not None and not isinstance(kwargs, Mapping):
|
|
44
|
+
raise TypeError(f"kwargs must be a mapping, not {type(kwargs).__name__}")
|
|
45
|
+
if kwargs is not None and not all(isinstance(key, str) for key in kwargs):
|
|
46
|
+
raise TypeError("kwargs keys must be strings")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_mode(mode: object, modes: Iterable[str]) -> None:
|
|
50
|
+
"""Reject anything but one of the mode names, including unhashable values."""
|
|
51
|
+
if not isinstance(mode, str) or mode not in modes:
|
|
52
|
+
raise ValueError(f"unknown mode: {mode!r}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reject_coroutine(value: T) -> T:
|
|
56
|
+
"""Refuse a coroutine result: it would never run and warn later as unawaited."""
|
|
57
|
+
if inspect.iscoroutine(value):
|
|
58
|
+
value.close()
|
|
59
|
+
raise TypeError(f"the call returned a coroutine: {_ASYNC_HINT}")
|
|
60
|
+
return value
|
calltimeout/_wire.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""What crosses the process boundary: the call going in, the outcome coming back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pickle
|
|
6
|
+
import traceback
|
|
7
|
+
from multiprocessing.reduction import ForkingPickler
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RemoteTraceback(Exception):
|
|
12
|
+
"""Carries the child's formatted traceback as the cause of a re-raised error."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, text: str) -> None:
|
|
15
|
+
super().__init__(text)
|
|
16
|
+
self.text = text
|
|
17
|
+
|
|
18
|
+
def __str__(self) -> str:
|
|
19
|
+
return self.text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def prepickle(method: str, call: tuple[object, ...]) -> object:
|
|
23
|
+
"""Pickle the call before any process exists, so a start cannot fail half way.
|
|
24
|
+
|
|
25
|
+
A start that failed while pickling (seen on Windows) left a half-built Popen whose
|
|
26
|
+
finalizer later closed handle numbers reused by the next call's pipe.
|
|
27
|
+
"""
|
|
28
|
+
if method == "fork":
|
|
29
|
+
return call # nothing is pickled
|
|
30
|
+
try:
|
|
31
|
+
return bytes(ForkingPickler.dumps(call))
|
|
32
|
+
except RuntimeError:
|
|
33
|
+
# Locks, queues and the like only pickle inside Process.start(): pass them as is.
|
|
34
|
+
return call
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pack_error(exc: BaseException) -> bytes:
|
|
38
|
+
"""Pickle an exception with its traceback text, or a stand-in if it cannot travel."""
|
|
39
|
+
text = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
40
|
+
try:
|
|
41
|
+
data = pickle.dumps((False, exc, text))
|
|
42
|
+
pickle.loads(data) # an exception can pickle and still fail to rebuild
|
|
43
|
+
except Exception as problem:
|
|
44
|
+
name = f"{type(exc).__module__}.{type(exc).__qualname__}"
|
|
45
|
+
try:
|
|
46
|
+
message = str(exc)
|
|
47
|
+
except Exception:
|
|
48
|
+
message = "<str() failed>"
|
|
49
|
+
stand_in = RuntimeError(f"{name}: {message} (not picklable: {problem!r})")
|
|
50
|
+
data = pickle.dumps((False, stand_in, text))
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pack_result(value: object) -> bytes:
|
|
55
|
+
"""Pickle a result, or a PicklingError explaining why it cannot be sent back."""
|
|
56
|
+
try:
|
|
57
|
+
return pickle.dumps((True, value, ""))
|
|
58
|
+
except Exception as problem:
|
|
59
|
+
return pack_error(
|
|
60
|
+
pickle.PicklingError(f"the call's result cannot be sent back: {problem!r}")
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def unpack(data: bytes) -> object:
|
|
65
|
+
"""Return the child's result or raise its exception with the child's traceback."""
|
|
66
|
+
try:
|
|
67
|
+
ok, payload, text = pickle.loads(data)
|
|
68
|
+
except Exception as problem:
|
|
69
|
+
raise pickle.UnpicklingError(
|
|
70
|
+
f"the call's result cannot be rebuilt in this process: {problem!r}"
|
|
71
|
+
) from problem
|
|
72
|
+
if ok:
|
|
73
|
+
return cast(object, payload)
|
|
74
|
+
error = cast(BaseException, payload)
|
|
75
|
+
error.__cause__ = RemoteTraceback(text)
|
|
76
|
+
raise error
|
calltimeout/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: calltimeout
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Run a callable with a wall-clock timeout in a thread, a process or under SIGALRM, typed, with an honest choice of mechanism
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/calltimeout
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/calltimeout/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/calltimeout/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: deadline,decorator,multiprocessing,paramspec,sigalrm,signal,thread,timeout
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
33
|
+
Requires-Dist: twine; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# calltimeout
|
|
37
|
+
|
|
38
|
+
Run a callable with a wall-clock timeout and get a typed result or a clean
|
|
39
|
+
`TimeoutError`, with an honest choice of mechanism.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
pip install calltimeout
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Python 3.10 or newer, no dependencies.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import time
|
|
53
|
+
|
|
54
|
+
from calltimeout import CallTimeout, run
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fetch(delay: float) -> str:
|
|
58
|
+
time.sleep(delay)
|
|
59
|
+
return "payload"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# The result, when the call finishes in time.
|
|
63
|
+
print(run(fetch, 2.0, args=(0.01,))) # payload
|
|
64
|
+
|
|
65
|
+
# CallTimeout, a subclass of TimeoutError, when it does not.
|
|
66
|
+
try:
|
|
67
|
+
run(fetch, 0.05, args=(1.0,))
|
|
68
|
+
except CallTimeout as exc:
|
|
69
|
+
print(f"gave up after {exc.timeout} seconds") # gave up after 0.05 seconds
|
|
70
|
+
|
|
71
|
+
# Or a fallback instead of raising.
|
|
72
|
+
print(run(fetch, 0.05, args=(1.0,), default="cached")) # cached
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`run(fn, timeout, *, args=(), kwargs=None, mode="thread", default=...)` passes `args`
|
|
76
|
+
and `kwargs` to `fn`. The timeout is a number of seconds: an `int` or `float`, not
|
|
77
|
+
negative, not NaN and not infinite. A zero timeout raises at once without starting the
|
|
78
|
+
call. Exceptions raised by the call reach the caller (in process mode as a pickled copy,
|
|
79
|
+
see below). `default` is only used
|
|
80
|
+
for this call's own timeout: a `CallTimeout` from a nested call inside `fn` propagates.
|
|
81
|
+
`Mode` is the `Literal` type of the three mode names, for annotating your own wrappers.
|
|
82
|
+
|
|
83
|
+
### As a decorator
|
|
84
|
+
|
|
85
|
+
`with_timeout` keeps the decorated function's parameters, so type checkers still check
|
|
86
|
+
every call, and the result type becomes `T | D` when a default is given. In process mode
|
|
87
|
+
under spawn or forkserver the child imports the function by name, so either the
|
|
88
|
+
decorated function or the original must be reachable under its module and name (a
|
|
89
|
+
plain `@with_timeout` at module level, or `fast = with_timeout(...)(slow)`):
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import time
|
|
93
|
+
|
|
94
|
+
from calltimeout import with_timeout
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@with_timeout(2.0)
|
|
98
|
+
def add(left: int, right: int) -> int:
|
|
99
|
+
return left + right
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@with_timeout(0.05, default=None)
|
|
103
|
+
def lookup(key: str) -> str:
|
|
104
|
+
time.sleep(1.0)
|
|
105
|
+
return key.upper()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
print(add(2, 3)) # 5
|
|
109
|
+
print(lookup("id")) # None
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Choosing a mechanism
|
|
113
|
+
|
|
114
|
+
The trade-off is the whole point of the library, so it is stated plainly:
|
|
115
|
+
|
|
116
|
+
| | `"thread"` (default) | `"process"` | `"signal"` |
|
|
117
|
+
|---|---|---|---|
|
|
118
|
+
| Stops a runaway call | No: it keeps running in a daemon thread | Yes: terminated, and killed if still alive 1 second later | Python code and blocking system calls: yes. A long call inside C: once it returns |
|
|
119
|
+
| Where it works | Everywhere | Everywhere | POSIX, main thread only |
|
|
120
|
+
| Pickling | Not needed | `fn`, its arguments and its result | Not needed |
|
|
121
|
+
| Cost | A thread | A new process, whose start counts against the budget | A timer |
|
|
122
|
+
| Context variables | A copy of the caller's, like `asyncio.to_thread` | Not shared | Shared: same thread |
|
|
123
|
+
|
|
124
|
+
**Thread mode** runs the call in a daemon thread named `calltimeout:<function>` and
|
|
125
|
+
stops waiting after the timeout. Python cannot stop a running thread, so a call that
|
|
126
|
+
ignores the clock (a tight loop, a blocking call without its own timeout) keeps running
|
|
127
|
+
after `run` has raised, until it returns or the process exits. Daemon threads are
|
|
128
|
+
stopped abruptly at shutdown, so do not leave one holding a file or a transaction.
|
|
129
|
+
|
|
130
|
+
**Process mode** runs the call in a child process and really stops it on overrun. A
|
|
131
|
+
child that exits without a result (a crash, `os._exit`, the out-of-memory killer)
|
|
132
|
+
raises `CallCrashed` with its exit code as soon as it is gone, instead of a timeout. An
|
|
133
|
+
exception from the child arrives with the child's traceback chained as its cause, and a
|
|
134
|
+
result that cannot be pickled raises `pickle.PicklingError` straight away (and one the
|
|
135
|
+
parent cannot rebuild, `pickle.UnpicklingError`). An exception that cannot be pickled
|
|
136
|
+
arrives as a `RuntimeError` naming it, and `sys.exit()` in the call raises `SystemExit`
|
|
137
|
+
in the caller like any other exception. After the result arrives the child gets up to
|
|
138
|
+
1 second to shut down on its own (atexit handlers, non-daemon threads) before it is
|
|
139
|
+
terminated. Locks, queues and other `multiprocessing` objects can be passed as
|
|
140
|
+
arguments. Processes started by the call itself are not stopped with it. Targets must be importable, so
|
|
141
|
+
define them at module level, and guard the entry point of a script with
|
|
142
|
+
`if __name__ == "__main__":` as `multiprocessing` requires.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
import math
|
|
146
|
+
import os
|
|
147
|
+
import time
|
|
148
|
+
|
|
149
|
+
from calltimeout import CallCrashed, run
|
|
150
|
+
|
|
151
|
+
print(run(math.factorial, 30.0, args=(20,), mode="process")) # 2432902008176640000
|
|
152
|
+
|
|
153
|
+
# The child is terminated, not left running.
|
|
154
|
+
print(run(time.sleep, 0.5, args=(60,), mode="process", default="stopped")) # stopped
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
run(os._exit, 30.0, args=(3,), mode="process")
|
|
158
|
+
except CallCrashed as exc:
|
|
159
|
+
print(f"exit code {exc.exitcode}") # exit code 3
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Signal mode** arms a `SIGALRM` timer and raises `CallTimeout` inside the call, at the
|
|
163
|
+
line it has reached. Since PEP 475 a signal handler that raises also stops blocking
|
|
164
|
+
system calls such as `time.sleep` or a socket read. It needs no thread and no pickling,
|
|
165
|
+
but it only works in the main thread on POSIX: elsewhere it raises `RuntimeError`, and
|
|
166
|
+
on Windows `NotImplementedError`. A call that catches the timeout and carries on still
|
|
167
|
+
ends in `CallTimeout`, but only once it returns: the timer fires once, so a loop that
|
|
168
|
+
keeps catching `OSError` (which `TimeoutError` is) runs on until it gives up. An
|
|
169
|
+
exception raised while handling the timeout counts as the timeout; `KeyboardInterrupt`,
|
|
170
|
+
`SystemExit` and unrelated errors propagate. Timers already armed with `setitimer` or
|
|
171
|
+
`alarm` keep their schedule, so signal-mode calls can be nested, but a call that resets
|
|
172
|
+
`ITIMER_REAL` itself cancels the deadline.
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
import sys
|
|
176
|
+
import time
|
|
177
|
+
|
|
178
|
+
from calltimeout import CallTimeout, run
|
|
179
|
+
|
|
180
|
+
if sys.platform != "win32":
|
|
181
|
+
try:
|
|
182
|
+
run(time.sleep, 0.1, args=(60,), mode="signal")
|
|
183
|
+
except CallTimeout as exc:
|
|
184
|
+
# The timeout was raised inside time.sleep and is chained as the cause.
|
|
185
|
+
print(f"interrupted: {type(exc.__cause__).__name__}") # interrupted: CallTimeout
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## In async code
|
|
189
|
+
|
|
190
|
+
Coroutines already have `asyncio.timeout()`, and `run` refuses coroutine functions
|
|
191
|
+
with a pointer to it. For a blocking call, run `run` in a worker thread so the event
|
|
192
|
+
loop stays free; process mode then really stops the call:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
import asyncio
|
|
196
|
+
import time
|
|
197
|
+
|
|
198
|
+
from calltimeout import run
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def main() -> str:
|
|
202
|
+
return await asyncio.to_thread(
|
|
203
|
+
run, time.sleep, 0.5, args=(60,), mode="process", default="stopped"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
print(asyncio.run(main())) # stopped
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Why
|
|
211
|
+
|
|
212
|
+
Running a function under a timeout is a common need, but the usual packages have
|
|
213
|
+
gone stale and ship no type information:
|
|
214
|
+
|
|
215
|
+
- [`func-timeout`](https://pypi.org/project/func-timeout/): last release 2019.
|
|
216
|
+
- [`stopit`](https://pypi.org/project/stopit/): last release 2018.
|
|
217
|
+
- [`timeout-decorator`](https://pypi.org/project/timeout-decorator/): last
|
|
218
|
+
release 2020.
|
|
219
|
+
|
|
220
|
+
Beyond age, most of them hide the mechanism and quietly imply they can always
|
|
221
|
+
cancel the work, which a thread cannot. `calltimeout` is small, fully typed
|
|
222
|
+
(`mypy --strict`, `py.typed`), depends on nothing, and makes the choice between
|
|
223
|
+
thread, process and signal explicit rather than a hidden surprise.
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
MIT. See [LICENSE](LICENSE).
|
|
228
|
+
|
|
229
|
+
## Links
|
|
230
|
+
|
|
231
|
+
- Website: https://lianes8studio.com
|
|
232
|
+
- GitHub: https://github.com/JaviLianes8
|
|
233
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
calltimeout/__init__.py,sha256=OREQxV36_n-z3t5tuI0QKZHIsUZngTX2fJSOnGnBI6s,332
|
|
2
|
+
calltimeout/_api.py,sha256=gA7zHKoGrSyKPiW8SpcQLi1qpAJ9IUyohFFoWhv3ZGU,2595
|
|
3
|
+
calltimeout/_decorator.py,sha256=id30guqNmUL1C3cZ4XhnWpB285YW8WrDZTj_9MGohuw,2824
|
|
4
|
+
calltimeout/_errors.py,sha256=MHh1ez9u_pQVFx1t91HLoXA3WbE1_Ix2UioAM3HNb0A,1103
|
|
5
|
+
calltimeout/_process.py,sha256=zRFx5tpkzBeotjv1-v17WJ5ndwCH-cmwziecWPIBMYo,4814
|
|
6
|
+
calltimeout/_signal.py,sha256=4jQXS7vIjglzyS1kXn-CSYioc5OxLPnsXoWK51xUfi8,5115
|
|
7
|
+
calltimeout/_thread.py,sha256=ZrYMPgEvJApSXbJ7fV9Vs5aX3e6D5w9D9S43PKjgBo0,1580
|
|
8
|
+
calltimeout/_validate.py,sha256=t89BMSrnelSwMJApEXgeT8dBd_pYTPDy57-QoGo1Z_o,2503
|
|
9
|
+
calltimeout/_wire.py,sha256=CHP0iioN01VAqa_UvES72pGKKS5NbSKjh7_yhbefK6Q,2647
|
|
10
|
+
calltimeout/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
calltimeout-0.2.0.dist-info/METADATA,sha256=33xMz6jGTfnhEhkctXvPN74z9tPv9LYVrhwYajY2-CQ,9023
|
|
12
|
+
calltimeout-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
calltimeout-0.2.0.dist-info/licenses/LICENSE,sha256=_WmABlQG__oqlx4Rzp-qDJ10xND5KCLScftbhXbLC_c,1070
|
|
14
|
+
calltimeout-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|