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.
@@ -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."""
@@ -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])
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.