calltimeout 0.2.0__tar.gz
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-0.2.0/.gitattributes +1 -0
- calltimeout-0.2.0/.github/FUNDING.yml +1 -0
- calltimeout-0.2.0/.gitignore +12 -0
- calltimeout-0.2.0/CHANGELOG.md +61 -0
- calltimeout-0.2.0/CONTRIBUTING.md +19 -0
- calltimeout-0.2.0/IMPROVEMENTS-round.md +160 -0
- calltimeout-0.2.0/LICENSE +21 -0
- calltimeout-0.2.0/PKG-INFO +233 -0
- calltimeout-0.2.0/README.md +198 -0
- calltimeout-0.2.0/pyproject.toml +59 -0
- calltimeout-0.2.0/requirements-dev.txt +3 -0
- calltimeout-0.2.0/scripts/check.ps1 +14 -0
- calltimeout-0.2.0/scripts/release.ps1 +28 -0
- calltimeout-0.2.0/src/calltimeout/__init__.py +11 -0
- calltimeout-0.2.0/src/calltimeout/_api.py +91 -0
- calltimeout-0.2.0/src/calltimeout/_decorator.py +77 -0
- calltimeout-0.2.0/src/calltimeout/_errors.py +30 -0
- calltimeout-0.2.0/src/calltimeout/_process.py +141 -0
- calltimeout-0.2.0/src/calltimeout/_signal.py +131 -0
- calltimeout-0.2.0/src/calltimeout/_thread.py +52 -0
- calltimeout-0.2.0/src/calltimeout/_validate.py +60 -0
- calltimeout-0.2.0/src/calltimeout/_wire.py +76 -0
- calltimeout-0.2.0/src/calltimeout/py.typed +0 -0
- calltimeout-0.2.0/tests/_targets.py +151 -0
- calltimeout-0.2.0/tests/conftest.py +10 -0
- calltimeout-0.2.0/tests/test_contract.py +26 -0
- calltimeout-0.2.0/tests/test_decorator.py +73 -0
- calltimeout-0.2.0/tests/test_docs.py +85 -0
- calltimeout-0.2.0/tests/test_e2e_wheel.py +104 -0
- calltimeout-0.2.0/tests/test_errors.py +39 -0
- calltimeout-0.2.0/tests/test_process.py +135 -0
- calltimeout-0.2.0/tests/test_regressions.py +84 -0
- calltimeout-0.2.0/tests/test_signal_posix.py +180 -0
- calltimeout-0.2.0/tests/test_signal_windows.py +28 -0
- calltimeout-0.2.0/tests/test_thread.py +83 -0
- calltimeout-0.2.0/tests/test_typing.py +71 -0
- calltimeout-0.2.0/tests/test_validation.py +114 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
buy_me_a_coffee: jlianesglrs
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.0]
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `mode="signal"`: a SIGALRM timer raises `CallTimeout` inside the call, which stops
|
|
14
|
+
pure Python code and blocking system calls in place. POSIX main thread only; other
|
|
15
|
+
threads get `RuntimeError` and Windows `NotImplementedError`. Timers already armed
|
|
16
|
+
keep their schedule (a raising outer handler runs once), so calls can be nested, and
|
|
17
|
+
a call that swallows the timeout still ends in `CallTimeout` when it returns, while
|
|
18
|
+
`KeyboardInterrupt`, `SystemExit` and unrelated errors propagate.
|
|
19
|
+
- `with_timeout(timeout, *, mode, default)`: a decorator that keeps the wrapped
|
|
20
|
+
function's signature for type checkers (`ParamSpec`), in every mode including process
|
|
21
|
+
mode for functions the child can import by name (decorated in place or assigned to a
|
|
22
|
+
new name).
|
|
23
|
+
- `CallCrashed`: raised in process mode when the child exits without a result, with its
|
|
24
|
+
exit code, as soon as it is gone.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Process mode waits on the result pipe and the process together, pickles results and
|
|
29
|
+
errors in the child (an unpicklable result raises `pickle.PicklingError` at once, an
|
|
30
|
+
unpicklable exception arrives as a `RuntimeError` stand-in), chains the child's
|
|
31
|
+
traceback as the cause, kills a child that ignores SIGTERM after 1 second, and stops
|
|
32
|
+
the child whenever the caller leaves early, including on `KeyboardInterrupt`.
|
|
33
|
+
- Process mode notices a crashed child even while a grandchild it forked keeps the
|
|
34
|
+
pipes open.
|
|
35
|
+
- Process mode pickles the call before starting the child, so a failed start cannot
|
|
36
|
+
leave the next call reading from a stale pipe on Windows; locks and queues, which
|
|
37
|
+
only pickle during start, are still passed through.
|
|
38
|
+
- A finished child gets up to 1 second to exit on its own before it is terminated; a
|
|
39
|
+
result the parent cannot unpickle raises `pickle.UnpicklingError`.
|
|
40
|
+
- `default` is returned only for the call's own timeout; a `CallTimeout` from a nested
|
|
41
|
+
call propagates.
|
|
42
|
+
- Thread mode runs the call in a copy of the caller's context variables and names the
|
|
43
|
+
thread `calltimeout:<function>`.
|
|
44
|
+
- `timeout` must be a real number (not a bool), not NaN and at most
|
|
45
|
+
`threading.TIMEOUT_MAX`; a zero timeout raises without starting the call. Coroutine
|
|
46
|
+
functions and coroutine results are rejected with a pointer to `asyncio.timeout`.
|
|
47
|
+
`kwargs` must be a mapping, and `run` and `with_timeout` reject modes the same way.
|
|
48
|
+
- `CallTimeout` keeps its `timeout` through pickle and copy, and reports the validated
|
|
49
|
+
number of seconds.
|
|
50
|
+
|
|
51
|
+
## [0.1.0]
|
|
52
|
+
|
|
53
|
+
- First release.
|
|
54
|
+
- `run(fn, timeout, *, args, kwargs, mode, default)` runs a callable under a
|
|
55
|
+
wall-clock timeout and returns its typed result.
|
|
56
|
+
- Raises `CallTimeout` (a subclass of `TimeoutError`) on overrun, or returns
|
|
57
|
+
`default` when one is given.
|
|
58
|
+
- `mode="thread"` (default) stops waiting but cannot kill a stuck,
|
|
59
|
+
non-cooperative call: the thread lingers until it returns or the process exits.
|
|
60
|
+
- `mode="process"` truly terminates the call on overrun, at the cost of needing
|
|
61
|
+
picklable arguments and result.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for taking the time. This project keeps a small, predictable shape.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install -r requirements-dev.txt
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Before you open a pull request
|
|
12
|
+
|
|
13
|
+
- Keep public API in `src/calltimeout/__init__.py`; internals live in `_`-prefixed modules.
|
|
14
|
+
- Add a test for the behaviour you change. Tests inject time, disk and network; they
|
|
15
|
+
never touch the real thing.
|
|
16
|
+
- Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
|
|
17
|
+
- One logical change per commit, imperative subject line.
|
|
18
|
+
|
|
19
|
+
Open an issue first if the change is large or shifts the public API.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# calltimeout improvement round
|
|
2
|
+
|
|
3
|
+
Progress:
|
|
4
|
+
- [x] Brainstorm ranked improvements (this file)
|
|
5
|
+
- [x] Validation: timeout type, NaN, infinity, TIMEOUT_MAX, zero budget, coroutine functions
|
|
6
|
+
- [x] Only this call's own timeout returns `default`; nested timeouts propagate
|
|
7
|
+
- [x] Process mode: crash detection, unpicklable results and errors, kill after a grace period, cleanup on interrupt, remote traceback
|
|
8
|
+
- [x] Thread mode: context variables, named threads, coroutine results
|
|
9
|
+
- [x] Signal mode for POSIX main threads, with nesting and swallowed timeouts
|
|
10
|
+
- [x] `with_timeout` decorator that keeps the signature (ParamSpec), process mode included
|
|
11
|
+
- [x] `CallTimeout` and `CallCrashed` pickle and copy
|
|
12
|
+
- [x] Contracts: typing, README examples, installed wheel e2e, POSIX run
|
|
13
|
+
- [x] Docs: README, CHANGELOG, docstrings, packaging metadata
|
|
14
|
+
- [x] Gates: ruff format, ruff check, mypy --strict, pytest
|
|
15
|
+
- [x] Independent review findings fixed with regression tests
|
|
16
|
+
- [x] Pull request opened (#2), guard clean
|
|
17
|
+
|
|
18
|
+
## Starting point
|
|
19
|
+
|
|
20
|
+
`0.1.0` runs a callable in a daemon thread or a child process and raises `CallTimeout`
|
|
21
|
+
or returns `default`. Reading it against the standard library documentation turned up
|
|
22
|
+
these gaps:
|
|
23
|
+
|
|
24
|
+
- A child process that dies without a result (a crash, `os._exit`, the OOM killer) is
|
|
25
|
+
reported as a timeout, and only after the whole budget has been waited out.
|
|
26
|
+
- A result or exception that cannot be pickled is lost in the queue's feeder thread: the
|
|
27
|
+
caller waits the full budget and gets `CallTimeout`, which is the wrong error.
|
|
28
|
+
- On POSIX `terminate()` sends SIGTERM. A call that ignores SIGTERM makes `run` hang
|
|
29
|
+
forever in `process.join()`, with the timeout already spent.
|
|
30
|
+
- If the waiting caller is interrupted (Ctrl+C, an outer timeout), the child process is
|
|
31
|
+
never terminated.
|
|
32
|
+
- An exception from the child arrives without its traceback.
|
|
33
|
+
- `default` is returned for any `CallTimeout` raised inside the call, including one from a
|
|
34
|
+
nested `run` that the outer call did not cause.
|
|
35
|
+
- `CallTimeout` does not survive pickling: `timeout` comes back as the message string.
|
|
36
|
+
- `timeout=nan` and `timeout=inf` fail with unrelated errors from `threading`, `True` is
|
|
37
|
+
accepted as one second, and a coroutine function returns an unawaited coroutine.
|
|
38
|
+
- Thread mode drops the caller's context variables, unlike `asyncio.to_thread`.
|
|
39
|
+
- There is no way to interrupt pure Python code in place on POSIX (SIGALRM), and no
|
|
40
|
+
decorator, so the call's arguments are never type-checked.
|
|
41
|
+
|
|
42
|
+
## Sources (fetched 2026-10-12)
|
|
43
|
+
|
|
44
|
+
- `threading`, https://docs.python.org/3/library/threading.html: `join()` always returns
|
|
45
|
+
None, so `is_alive()` decides whether it timed out; daemon threads are abruptly stopped
|
|
46
|
+
at shutdown and their resources may not be released.
|
|
47
|
+
- `signal`, https://docs.python.org/3/library/signal.html: handlers always run in the main
|
|
48
|
+
thread and only the main thread may set one; a long calculation in C runs uninterrupted
|
|
49
|
+
until it finishes; `setitimer(ITIMER_REAL, ...)` delivers SIGALRM, takes seconds rounded
|
|
50
|
+
up to microseconds, returns the old `(delay, interval)` and is cleared by zero seconds;
|
|
51
|
+
on Windows `signal()` only accepts SIGABRT, SIGFPE, SIGILL, SIGINT, SIGSEGV, SIGTERM and
|
|
52
|
+
SIGBREAK; an exception from a handler may be raised after any bytecode instruction.
|
|
53
|
+
- `multiprocessing`, https://docs.python.org/3/library/multiprocessing.html: `terminate()`
|
|
54
|
+
is SIGTERM on POSIX and TerminateProcess on Windows, skips finally clauses and orphans
|
|
55
|
+
descendant processes; `kill()` is SIGKILL; `sentinel` can be waited on with
|
|
56
|
+
`multiprocessing.connection.wait()`; since 3.14 the POSIX default start method is
|
|
57
|
+
forkserver.
|
|
58
|
+
- `asyncio`, https://docs.python.org/3/library/asyncio-task.html: `asyncio.timeout(delay)`
|
|
59
|
+
limits time spent waiting in a coroutine; `asyncio.to_thread` propagates the current
|
|
60
|
+
`contextvars.Context` to the thread.
|
|
61
|
+
- PEP 475, https://peps.python.org/pep-0475/: when a system call fails with EINTR and the
|
|
62
|
+
signal handler raises, the wrapper fails with that exception instead of retrying, so a
|
|
63
|
+
SIGALRM handler can interrupt `time.sleep` and blocking reads.
|
|
64
|
+
|
|
65
|
+
## Ranked improvements
|
|
66
|
+
|
|
67
|
+
1. **Process mode tells a crash from a timeout.** Wait on the result pipe and the process
|
|
68
|
+
sentinel together; a child that exits without a result raises `CallCrashed` with its
|
|
69
|
+
exit code at once, instead of `CallTimeout` after the full budget.
|
|
70
|
+
2. **Process mode never hangs and never leaks.** Results and errors are pickled in the
|
|
71
|
+
child before they are sent, so an unpicklable value becomes a clear error; on overrun
|
|
72
|
+
the child gets `terminate()`, then `kill()` after a short grace period; a `finally`
|
|
73
|
+
stops the child whenever the caller leaves early. The child's traceback is chained as
|
|
74
|
+
the cause, as `concurrent.futures` does.
|
|
75
|
+
3. **`default` only for this call's own timeout.** A `CallTimeout` from a nested call
|
|
76
|
+
propagates untouched.
|
|
77
|
+
4. **Strict validation.** `timeout` must be a real number (not a bool), finite, not
|
|
78
|
+
negative and at most `threading.TIMEOUT_MAX`. A zero budget raises at once without
|
|
79
|
+
starting the call, in every mode. Coroutine functions are rejected with a pointer to
|
|
80
|
+
`asyncio.timeout`.
|
|
81
|
+
5. **Signal mode.** `mode="signal"` arms `ITIMER_REAL` and raises `CallTimeout` inside the
|
|
82
|
+
call, which really stops pure Python code and blocking system calls in place, with no
|
|
83
|
+
thread and no pickling. Main thread on POSIX only, with clear errors elsewhere. The old
|
|
84
|
+
handler and any outer timer are restored with their remaining time, so nested calls
|
|
85
|
+
work; a call that swallows the timeout still ends in `CallTimeout`.
|
|
86
|
+
6. **Thread mode like `asyncio.to_thread`.** The call runs in a copy of the caller's
|
|
87
|
+
context; threads are named after the call so a leaked one is recognisable in
|
|
88
|
+
`threading.enumerate()`; a call that returns a coroutine is rejected.
|
|
89
|
+
7. **`with_timeout` decorator.** Keeps the wrapped signature with `ParamSpec`, so the
|
|
90
|
+
arguments are type-checked, and the result type becomes `T | D` with a default. It
|
|
91
|
+
works in process mode for module-level functions (the child runs the original).
|
|
92
|
+
8. **Exceptions that travel.** `CallTimeout` and `CallCrashed` survive pickle and copy.
|
|
93
|
+
9. **Typed public API, proven.** A test runs mypy on a snippet and checks inferred types,
|
|
94
|
+
the preserved signature, and rejection of wrong arguments.
|
|
95
|
+
10. **Docs with runnable examples.** Every Python block in the README runs in the test
|
|
96
|
+
suite with its output pinned; a mechanism table states what each mode can and cannot
|
|
97
|
+
stop on Windows and POSIX; async usage is documented.
|
|
98
|
+
11. **Packaging.** Classifiers for supported versions and operating systems, keywords,
|
|
99
|
+
version agreement with the metadata, and an installed-wheel e2e test.
|
|
100
|
+
|
|
101
|
+
## Findings while implementing
|
|
102
|
+
|
|
103
|
+
- On Windows, a process start that fails half way through pickling its arguments (a
|
|
104
|
+
lambda, say) left the next process-mode call of the same parent answering on a stale
|
|
105
|
+
pipe handle: the child ran, sent its result, and the parent saw a crash with exit code
|
|
106
|
+
0. Plain `multiprocessing` with a `Pipe` shows the same. Process mode now pickles the
|
|
107
|
+
call into bytes before it creates the pipe or the process, so a start cannot fail half
|
|
108
|
+
way (not under fork, where nothing is pickled).
|
|
109
|
+
- Under fork and spawn on POSIX, a grandchild forked by the call inherits both the result
|
|
110
|
+
pipe and the sentinel pipe, so neither reports the child's exit while the grandchild
|
|
111
|
+
lives. Waiting now also checks `is_alive()` (waitpid) every 0.25 seconds, and the grace
|
|
112
|
+
period after `terminate()` is measured the same way instead of with `join(timeout)`.
|
|
113
|
+
|
|
114
|
+
## Verification
|
|
115
|
+
|
|
116
|
+
- Windows, Python 3.13 and 3.11; Linux (WSL), Python 3.14 under forkserver, fork and
|
|
117
|
+
spawn. `mypy --strict --python-version 3.10` on both platforms.
|
|
118
|
+
- Mutations: each guard was removed in turn (pickle reducers, every validation check,
|
|
119
|
+
the zero-budget shortcut, the signal-mode platform check order, context copying,
|
|
120
|
+
thread names, the private expiry signal, up-front pickling, child cleanup, the rebuild
|
|
121
|
+
check, the remote traceback, result pickling, the decorator's child unwrapping and
|
|
122
|
+
`functools.wraps`; on Linux the waitpid fallback, the grace period, the kill, and in
|
|
123
|
+
signal mode the swallowed-timeout check, the outer re-arm, passing ticks on, handler
|
|
124
|
+
restore, disarming, the main-thread check and the deadline check). Every one made the
|
|
125
|
+
suite fail. Passing the process sentinel to `wait` is a latency optimisation: with the
|
|
126
|
+
waitpid fallback no test can fail without it.
|
|
127
|
+
|
|
128
|
+
## Review fixes
|
|
129
|
+
|
|
130
|
+
- Process mode passes locks, queues and other objects that only pickle inside
|
|
131
|
+
`Process.start()` instead of refusing them (pre-pickling falls back for them).
|
|
132
|
+
- A finished child is no longer terminated the moment its result arrives: it gets up to
|
|
133
|
+
1 second to exit on its own, so atexit handlers and non-daemon threads complete.
|
|
134
|
+
- An interrupt during the grace period still kills and reaps the child.
|
|
135
|
+
- A result the parent cannot unpickle raises `pickle.UnpicklingError` chained to the
|
|
136
|
+
cause; a stand-in error is built even when `str()` of the original fails.
|
|
137
|
+
- Signal mode delivers an outer one-shot tick once even when its handler raises;
|
|
138
|
+
`KeyboardInterrupt`, `SystemExit` and errors unrelated to the timeout propagate
|
|
139
|
+
instead of becoming the default; a SIGALRM handler set outside Python is refused
|
|
140
|
+
because it could not be restored.
|
|
141
|
+
- The process-mode decorator sends the original function when only the original is
|
|
142
|
+
reachable by name (`fast = with_timeout(...)(slow)`).
|
|
143
|
+
- Validation: a huge int is a `ValueError`, `kwargs` must be a mapping, `with_timeout`
|
|
144
|
+
rejects an unhashable mode like `run`, `CallTimeout` reports the validated float.
|
|
145
|
+
- The zero-budget test now proves the call never ran with a marker file, which works
|
|
146
|
+
across processes.
|
|
147
|
+
- Documented: a swallowed signal-mode timeout ends only when the call returns, a call
|
|
148
|
+
that resets `ITIMER_REAL` cancels the deadline, `sys.exit()` in process mode raises
|
|
149
|
+
`SystemExit` in the caller, exceptions in process mode are pickled copies.
|
|
150
|
+
|
|
151
|
+
## Out of scope
|
|
152
|
+
|
|
153
|
+
- Killing a thread: CPython has no safe way to do it. Thread mode documents the leak
|
|
154
|
+
instead of hiding it.
|
|
155
|
+
- A start method parameter: process mode uses the platform default, which is what
|
|
156
|
+
`multiprocessing` users already configure with `set_start_method`.
|
|
157
|
+
- Terminating grandchildren: `terminate()` orphans them by design; a call that spawns
|
|
158
|
+
processes must manage them itself.
|
|
159
|
+
- An async API: coroutines already have `asyncio.timeout`. For blocking calls the README
|
|
160
|
+
shows `asyncio.to_thread(run, ...)`.
|
|
@@ -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.
|
|
@@ -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/
|