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.
Files changed (37) hide show
  1. calltimeout-0.2.0/.gitattributes +1 -0
  2. calltimeout-0.2.0/.github/FUNDING.yml +1 -0
  3. calltimeout-0.2.0/.gitignore +12 -0
  4. calltimeout-0.2.0/CHANGELOG.md +61 -0
  5. calltimeout-0.2.0/CONTRIBUTING.md +19 -0
  6. calltimeout-0.2.0/IMPROVEMENTS-round.md +160 -0
  7. calltimeout-0.2.0/LICENSE +21 -0
  8. calltimeout-0.2.0/PKG-INFO +233 -0
  9. calltimeout-0.2.0/README.md +198 -0
  10. calltimeout-0.2.0/pyproject.toml +59 -0
  11. calltimeout-0.2.0/requirements-dev.txt +3 -0
  12. calltimeout-0.2.0/scripts/check.ps1 +14 -0
  13. calltimeout-0.2.0/scripts/release.ps1 +28 -0
  14. calltimeout-0.2.0/src/calltimeout/__init__.py +11 -0
  15. calltimeout-0.2.0/src/calltimeout/_api.py +91 -0
  16. calltimeout-0.2.0/src/calltimeout/_decorator.py +77 -0
  17. calltimeout-0.2.0/src/calltimeout/_errors.py +30 -0
  18. calltimeout-0.2.0/src/calltimeout/_process.py +141 -0
  19. calltimeout-0.2.0/src/calltimeout/_signal.py +131 -0
  20. calltimeout-0.2.0/src/calltimeout/_thread.py +52 -0
  21. calltimeout-0.2.0/src/calltimeout/_validate.py +60 -0
  22. calltimeout-0.2.0/src/calltimeout/_wire.py +76 -0
  23. calltimeout-0.2.0/src/calltimeout/py.typed +0 -0
  24. calltimeout-0.2.0/tests/_targets.py +151 -0
  25. calltimeout-0.2.0/tests/conftest.py +10 -0
  26. calltimeout-0.2.0/tests/test_contract.py +26 -0
  27. calltimeout-0.2.0/tests/test_decorator.py +73 -0
  28. calltimeout-0.2.0/tests/test_docs.py +85 -0
  29. calltimeout-0.2.0/tests/test_e2e_wheel.py +104 -0
  30. calltimeout-0.2.0/tests/test_errors.py +39 -0
  31. calltimeout-0.2.0/tests/test_process.py +135 -0
  32. calltimeout-0.2.0/tests/test_regressions.py +84 -0
  33. calltimeout-0.2.0/tests/test_signal_posix.py +180 -0
  34. calltimeout-0.2.0/tests/test_signal_windows.py +28 -0
  35. calltimeout-0.2.0/tests/test_thread.py +83 -0
  36. calltimeout-0.2.0/tests/test_typing.py +71 -0
  37. 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,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
@@ -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/