calltimeout 0.2.0__tar.gz → 1.0.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 (111) hide show
  1. calltimeout-1.0.0/.gitignore +16 -0
  2. calltimeout-1.0.0/CHANGELOG.md +244 -0
  3. {calltimeout-0.2.0 → calltimeout-1.0.0}/CONTRIBUTING.md +4 -2
  4. calltimeout-1.0.0/GUIDE.md +292 -0
  5. calltimeout-1.0.0/PKG-INFO +185 -0
  6. calltimeout-1.0.0/README.md +148 -0
  7. {calltimeout-0.2.0 → calltimeout-1.0.0}/pyproject.toml +15 -7
  8. calltimeout-1.0.0/requirements-dev.txt +3 -0
  9. calltimeout-1.0.0/scripts/_tree.py +108 -0
  10. calltimeout-1.0.0/scripts/check.ps1 +17 -0
  11. calltimeout-1.0.0/scripts/fetch_upstream.py +40 -0
  12. calltimeout-1.0.0/scripts/mutants.json +422 -0
  13. calltimeout-1.0.0/scripts/mutate.py +67 -0
  14. calltimeout-1.0.0/scripts/release.ps1 +38 -0
  15. calltimeout-1.0.0/src/calltimeout/__init__.py +45 -0
  16. calltimeout-1.0.0/src/calltimeout/_api.py +125 -0
  17. calltimeout-1.0.0/src/calltimeout/_causes.py +42 -0
  18. calltimeout-1.0.0/src/calltimeout/_child.py +64 -0
  19. calltimeout-1.0.0/src/calltimeout/_decorator.py +155 -0
  20. calltimeout-1.0.0/src/calltimeout/_errors.py +88 -0
  21. calltimeout-1.0.0/src/calltimeout/_handoff.py +64 -0
  22. calltimeout-1.0.0/src/calltimeout/_parcel.py +186 -0
  23. calltimeout-1.0.0/src/calltimeout/_process.py +173 -0
  24. calltimeout-1.0.0/src/calltimeout/_signal.py +165 -0
  25. calltimeout-1.0.0/src/calltimeout/_text.py +53 -0
  26. {calltimeout-0.2.0 → calltimeout-1.0.0}/src/calltimeout/_thread.py +14 -5
  27. calltimeout-1.0.0/src/calltimeout/_timers.py +91 -0
  28. calltimeout-1.0.0/src/calltimeout/_validate.py +192 -0
  29. calltimeout-1.0.0/src/calltimeout/_wire.py +119 -0
  30. calltimeout-1.0.0/tests/_child_targets.py +109 -0
  31. calltimeout-1.0.0/tests/_consumer.py +78 -0
  32. calltimeout-1.0.0/tests/_counting.py +25 -0
  33. calltimeout-1.0.0/tests/_hostile_errors.py +189 -0
  34. calltimeout-1.0.0/tests/_lazy_targets.py +20 -0
  35. calltimeout-1.0.0/tests/_method_targets.py +27 -0
  36. calltimeout-1.0.0/tests/_result_targets.py +22 -0
  37. {calltimeout-0.2.0 → calltimeout-1.0.0}/tests/_targets.py +38 -0
  38. calltimeout-1.0.0/tests/_upgrade_consumer.py +69 -0
  39. calltimeout-1.0.0/tests/_upstream.py +36 -0
  40. calltimeout-1.0.0/tests/_wire_targets.py +191 -0
  41. calltimeout-1.0.0/tests/test_aliased_keywords.py +65 -0
  42. calltimeout-1.0.0/tests/test_bounded_causes.py +133 -0
  43. calltimeout-1.0.0/tests/test_cause_details.py +85 -0
  44. calltimeout-1.0.0/tests/test_child_outcomes.py +102 -0
  45. calltimeout-1.0.0/tests/test_contract.py +72 -0
  46. calltimeout-1.0.0/tests/test_contract_api.py +119 -0
  47. calltimeout-1.0.0/tests/test_contract_edges.py +129 -0
  48. {calltimeout-0.2.0 → calltimeout-1.0.0}/tests/test_decorator.py +40 -0
  49. calltimeout-1.0.0/tests/test_descriptor_limit.py +98 -0
  50. calltimeout-1.0.0/tests/test_docs.py +124 -0
  51. calltimeout-1.0.0/tests/test_e2e_wheel.py +156 -0
  52. calltimeout-1.0.0/tests/test_error_paths.py +136 -0
  53. calltimeout-1.0.0/tests/test_errors.py +94 -0
  54. calltimeout-1.0.0/tests/test_fuzz_arguments.py +154 -0
  55. calltimeout-1.0.0/tests/test_fuzz_calls.py +97 -0
  56. calltimeout-1.0.0/tests/test_handle_release.py +87 -0
  57. calltimeout-1.0.0/tests/test_handles.py +179 -0
  58. calltimeout-1.0.0/tests/test_hardening.py +146 -0
  59. calltimeout-1.0.0/tests/test_hostile_causes.py +97 -0
  60. calltimeout-1.0.0/tests/test_hostile_names.py +153 -0
  61. calltimeout-1.0.0/tests/test_keyword_wrappers.py +147 -0
  62. calltimeout-1.0.0/tests/test_messages.py +61 -0
  63. calltimeout-1.0.0/tests/test_nested_process_contract.py +44 -0
  64. calltimeout-1.0.0/tests/test_oracles.py +104 -0
  65. {calltimeout-0.2.0 → calltimeout-1.0.0}/tests/test_process.py +31 -3
  66. {calltimeout-0.2.0 → calltimeout-1.0.0}/tests/test_regressions.py +8 -3
  67. calltimeout-1.0.0/tests/test_result_bounds.py +87 -0
  68. calltimeout-1.0.0/tests/test_sending_calls.py +161 -0
  69. calltimeout-1.0.0/tests/test_signal_edges.py +193 -0
  70. calltimeout-1.0.0/tests/test_signal_floor.py +168 -0
  71. calltimeout-1.0.0/tests/test_signal_intervals.py +93 -0
  72. calltimeout-1.0.0/tests/test_signal_races.py +152 -0
  73. calltimeout-1.0.0/tests/test_signal_restore.py +61 -0
  74. calltimeout-1.0.0/tests/test_start_only_objects.py +160 -0
  75. {calltimeout-0.2.0 → calltimeout-1.0.0}/tests/test_thread.py +34 -1
  76. calltimeout-1.0.0/tests/test_timing_contracts.py +58 -0
  77. calltimeout-1.0.0/tests/test_transport_secrecy.py +62 -0
  78. {calltimeout-0.2.0 → calltimeout-1.0.0}/tests/test_typing.py +28 -0
  79. calltimeout-1.0.0/tests/test_unordered_timeouts.py +51 -0
  80. calltimeout-1.0.0/tests/test_upstream_processes.py +127 -0
  81. calltimeout-1.0.0/tests/test_upstream_threads.py +128 -0
  82. {calltimeout-0.2.0 → calltimeout-1.0.0}/tests/test_validation.py +82 -1
  83. calltimeout-1.0.0/tests/test_validation_contracts.py +180 -0
  84. calltimeout-1.0.0/tests/upstream/MANIFEST.json +74 -0
  85. calltimeout-0.2.0/.gitignore +0 -12
  86. calltimeout-0.2.0/CHANGELOG.md +0 -61
  87. calltimeout-0.2.0/IMPROVEMENTS-round.md +0 -160
  88. calltimeout-0.2.0/PKG-INFO +0 -233
  89. calltimeout-0.2.0/README.md +0 -198
  90. calltimeout-0.2.0/requirements-dev.txt +0 -3
  91. calltimeout-0.2.0/scripts/check.ps1 +0 -14
  92. calltimeout-0.2.0/scripts/release.ps1 +0 -28
  93. calltimeout-0.2.0/src/calltimeout/__init__.py +0 -11
  94. calltimeout-0.2.0/src/calltimeout/_api.py +0 -91
  95. calltimeout-0.2.0/src/calltimeout/_decorator.py +0 -77
  96. calltimeout-0.2.0/src/calltimeout/_errors.py +0 -30
  97. calltimeout-0.2.0/src/calltimeout/_process.py +0 -141
  98. calltimeout-0.2.0/src/calltimeout/_signal.py +0 -131
  99. calltimeout-0.2.0/src/calltimeout/_validate.py +0 -60
  100. calltimeout-0.2.0/src/calltimeout/_wire.py +0 -76
  101. calltimeout-0.2.0/tests/test_contract.py +0 -26
  102. calltimeout-0.2.0/tests/test_docs.py +0 -85
  103. calltimeout-0.2.0/tests/test_e2e_wheel.py +0 -104
  104. calltimeout-0.2.0/tests/test_errors.py +0 -39
  105. {calltimeout-0.2.0 → calltimeout-1.0.0}/.gitattributes +0 -0
  106. {calltimeout-0.2.0 → calltimeout-1.0.0}/.github/FUNDING.yml +0 -0
  107. {calltimeout-0.2.0 → calltimeout-1.0.0}/LICENSE +0 -0
  108. {calltimeout-0.2.0 → calltimeout-1.0.0}/src/calltimeout/py.typed +0 -0
  109. {calltimeout-0.2.0 → calltimeout-1.0.0}/tests/conftest.py +0 -0
  110. {calltimeout-0.2.0 → calltimeout-1.0.0}/tests/test_signal_posix.py +0 -0
  111. {calltimeout-0.2.0 → calltimeout-1.0.0}/tests/test_signal_windows.py +0 -0
@@ -0,0 +1,16 @@
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/
13
+
14
+ # Third-party documents, fetched locally by scripts/fetch_upstream.py; only the manifest is tracked.
15
+ tests/upstream/*
16
+ !tests/upstream/MANIFEST.json
@@ -0,0 +1,244 @@
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
+ ## [1.0.0] - 2026-11-02
10
+
11
+ The public API is now stable: `run`, `with_timeout`, `CallTimeout`, `CallCrashed`,
12
+ `RemoteTraceback` and `Mode`, with their parameter names, defaults and exception types,
13
+ follow Semantic Versioning from this release on. The changes below are against PyPI
14
+ 0.2.0, published on 2026-10-31 from commit 9b6975c on `main`. Neither 0.1.0 nor 0.2.0
15
+ was tagged. Exceptions stored by 0.2.0 remain loadable after upgrading.
16
+
17
+ ### Added
18
+
19
+ - `RemoteTraceback`, the cause chained to an exception raised by a process-mode call,
20
+ whose text (`.text`) is the child's traceback, is public and shown and pickled as
21
+ `calltimeout.RemoteTraceback`. It was a private class, shown in every such traceback
22
+ as `calltimeout._wire.RemoteTraceback`.
23
+
24
+ ### Changed
25
+
26
+ - A `mode` that is not a `str` raises `TypeError` (it raised `ValueError`); an unknown
27
+ mode name still raises `ValueError`, and the message lists the three valid names. A
28
+ `str` subclass is read as the plain name.
29
+ - `CallTimeout` and `CallCrashed` are shown and pickled as `calltimeout.CallTimeout` and
30
+ `calltimeout.CallCrashed`, so a pickle never names a private module, and they keep
31
+ extra attributes (such as notes) through pickle and copy. `run` and `with_timeout` are
32
+ shown and pickled as `calltimeout.run` and `calltimeout.with_timeout` too (a stored
33
+ `functools.partial(run, ...)` named `calltimeout._api`).
34
+ - `CallCrashed.exitcode` is typed `int`: it was `int | None`, though `run` raises
35
+ `CallCrashed` only once the child has exited, with its code. A `RemoteTraceback` built
36
+ with a text that is not a `str` shows it bounded; its `str()` raised `TypeError`.
37
+ - Generator functions and async generator functions raise `TypeError` in `run` and
38
+ `with_timeout` (an async one with the pointer to `asyncio`), and so does a call that
39
+ returns an async generator. They were accepted: the call returned the generator at
40
+ once, and its body then ran with no time limit as it was iterated.
41
+ - Thread mode: a call that ends after the budget ran out is a timeout, even when the
42
+ caller notices only later. A call holding the GIL could end after its deadline and
43
+ before the caller ran again, and its value was returned (a 1 ms budget gave values
44
+ from calls that ran 3 to 10 ms).
45
+ - A real-number timeout whose conversion to `float` fails raises `TypeError` (the
46
+ conversion's own error is not retained as a cause or context).
47
+ - A negative timeout too large for a `float` (`-10**400`) raises "timeout must not be
48
+ negative"; it was reported as above `threading.TIMEOUT_MAX`.
49
+ - Tiny negative real-number timeouts are rejected before float conversion can round
50
+ them to zero, including `Fraction(-1, 10**400)` in both public entry points.
51
+ - Invalid mode messages list valid names without quoting the input. Timeout conversion
52
+ and keyword mapping failures use fixed, value-free diagnostics and retain no original
53
+ cause or context. Ordinary mapping-access failures now raise `TypeError`.
54
+ - Validation checks actual types without consulting overridable `__class__` properties,
55
+ while accepting genuine subclasses and registered real-number and mapping types.
56
+ - `kwargs` is read once into a plain `dict` before the budget starts. Keys are validated
57
+ and string subclasses normalized before insertion, so overridden hashes and equality
58
+ cannot cause quadratic insertion work. Mapping views cannot bypass key validation.
59
+ - Existing built-in keyword dictionaries are copied from their stored key/value pairs,
60
+ avoiding quadratic source lookups for colliding string-subclass keys, even with a
61
+ zero timeout. Keys are still normalized and values preserved.
62
+ - Dictionary subclasses with inherited read behavior and dictionary-backed mapping
63
+ proxies also avoid repeated source lookups for colliding keyword keys. Custom key
64
+ selection and value lookup retain their semantics, including through proxies.
65
+ - An instance's `keys()` selection is preserved when its dictionary subclass aliases
66
+ a parent's `__dict__` descriptor, including through nested mapping proxies. Such
67
+ aliases previously caused excluded keywords to be passed to the callable.
68
+ - Aliased instance namespace descriptors no longer force repeated keyword lookups
69
+ when dictionary reads are unmodified. Colliding string-subclass keys use stored
70
+ pairs directly, while instance `keys()` selection remains respected.
71
+ - Validation and transport errors do not copy arbitrary exception text or notes.
72
+ Callable and type names in other diagnostics remain bounded to 60 characters; a
73
+ stand-in for an unpicklable callable exception keeps at most 300 characters of its
74
+ message. Ordinary callable exceptions and remote tracebacks retain their text.
75
+ - Process mode: the budget also covers pickling the call and starting the child, and a
76
+ call whose pickling used it up is never started. A call that pickles in the caller but
77
+ cannot be loaded in the child (a function defined under the main guard, in
78
+ `python -c` or a notebook, or in a module the child cannot import; an argument whose
79
+ rebuild raises) raises `pickle.UnpicklingError` ("the call cannot be loaded in the
80
+ child process", with a safe category and library traceback chained as the cause)
81
+ instead of a crash with exit code 1. A child that dies while
82
+ sending its result raises `CallCrashed`, and waiting for a crashed child's exit code
83
+ never runs past the budget. A result that cannot be pickled raises
84
+ `pickle.PicklingError` with the child's library traceback, and a result that cannot
85
+ be rebuilt raises `pickle.UnpicklingError`. Both use value-free transport causes.
86
+ - Process mode: the budget also covers receiving the result. On POSIX a long result is
87
+ written in two parts, and a child that stalled between or during them (stopped, or
88
+ blocked in a profile hook) blocked `run` for good; it is now stopped at the deadline
89
+ (terminated, and killed 1 second later) and the call raises `CallTimeout`.
90
+ - Process mode: a child error whose pickle rebuilds as something other than an exception
91
+ arrives as the `RuntimeError` stand-in naming its type; `run` raised an
92
+ `AttributeError` or `TypeError` from inside calltimeout, and the child's error was lost.
93
+ - Process mode: a call whose function or arguments cannot be pickled raises
94
+ `pickle.PicklingError` before any process starts, chained from a fresh standard
95
+ exception with fixed text. The cause preserves only a safe standard error category;
96
+ custom errors use `RuntimeError`. Original text, notes, frames and exception chains
97
+ are discarded without mutating the original error. This also applies to call loading,
98
+ result sending and result rebuilding failures. A call
99
+ holding objects that `multiprocessing` only pickles while a child starts (locks,
100
+ queues, shared values), any number of them, is first rehearsed as the start will
101
+ pickle it, so one whose contents cannot pickle (a `Value` guarded by a
102
+ `threading.Lock`) fails the same way, and one that still fails when the start pickles
103
+ it again (an object that pickles only once, a manager proxy whose pickling raises)
104
+ raises the same error, its child
105
+ stopped before it runs anything; a `KeyboardInterrupt` or `SystemExit` raised there is
106
+ raised as it is, once that child is stopped, instead of escaping half way through the
107
+ start, which on Windows could break later calls passing pipe ends. Under forkserver, a
108
+ call holding more than 248 descriptors (pipe ends, sockets, two per queue, one per block
109
+ of shared memory behind `Value` and `Array` objects, all named in the message), more than
110
+ one message to the fork server carries, raises `pickle.PicklingError` before any
111
+ process starts instead of an undocumented `OSError` or `ValueError` from the start. An
112
+ argument whose pickling raised `NotImplementedError` (a pool), `RecursionError` or a
113
+ `RuntimeError` used to fail after the child was created, and on Windows it then broke
114
+ the next process-mode call.
115
+ - Process mode: the call's function and arguments are pickled once, before the start;
116
+ only its pipe ends, sockets and manager proxies, or the whole call when it holds locks
117
+ or queues, are pickled by the start itself. Pipe ends and sockets are handed to the
118
+ child by that start (POSIX passes the descriptors with it, Windows duplicates them
119
+ straight into the child), so none is left open in the caller, even when the budget
120
+ runs out while pickling the call or before the child has loaded it. Such a call left a
121
+ duplicate of each one open in the caller (on Windows also one of the result pipe,
122
+ whatever the call held; under forkserver the caller also printed `ConnectionResetError`
123
+ tracebacks), so the reader of that pipe never saw its end and handles ran out. A
124
+ manager proxy goes with its manager's authkey, as `multiprocessing` sends it: one of a
125
+ manager with its own authkey failed in the child with `AuthenticationError` unless the
126
+ call also held a lock. An argument whose `__class__` raises is sent as it pickles.
127
+ - Process mode frees the child's handles as soon as the call ends. A call that raised
128
+ kept its child's process object, and its two descriptors or handles, alive in a
129
+ reference cycle until the next garbage collection, so a loop of failing calls could
130
+ run out of file descriptors (`OSError: [Errno 24] Too many open files`).
131
+ - A generic process-mode `run` preserves a supplied callable's process timeout
132
+ decorator, including its independent deadline, fallback and zero-budget no-call rule.
133
+ Wrapper bypass is authorized once only by a child call initiated by that decorator.
134
+ - `with_timeout` in process mode sends a callable that pickles by value (a
135
+ `functools.partial`, a callable object) as it is, as `run` does; it failed with
136
+ `AttributeError` under spawn and forkserver.
137
+ - `with_timeout` in process mode works under a `classmethod` under spawn and forkserver
138
+ (the wrapper is sent through its class); it raised `pickle.PicklingError`.
139
+ - Process mode: a child error whose type's own attributes raise (its traceback cannot be
140
+ formatted, its type cannot be named or pickled) arrives as a `RuntimeError` quoting its
141
+ text; the child crashed with exit code 1 and `run` raised `CallCrashed`.
142
+ - A callable whose `__getattr__` raises something other than `AttributeError` is run
143
+ like any other; that error escaped from the coroutine check in `run` and
144
+ `with_timeout`.
145
+ - `timeout` is annotated `float | numbers.Real` in `run` and `with_timeout`, so type
146
+ checkers accept a `Fraction`, which already worked.
147
+ - Signal mode: an exception raised with an explicit cause while handling the timeout
148
+ (`raise E from other`) counts as the timeout, like `raise E` and `raise E from None`;
149
+ it escaped as `E`.
150
+ - Signal mode: no timer is armed for less than 1 ms, so a timer tick less than 1 ms
151
+ before the deadline ends the call there, a timeout under 1 ms ends it at once without
152
+ starting it (like a zero timeout), and an enclosing timer due sooner than 1 ms has its
153
+ handler run up to 1 ms late. A timer that short could fire before the interrupted
154
+ system call blocked again, so a blocking call overran by its whole length.
155
+ - Signal mode: a timer tick that lands after the call has finished can no longer re-arm
156
+ the timer.
157
+ - Signal mode: an interval timer that was already running keeps its own schedule. Each
158
+ tick due during a call was counted from when it ran instead of when it was due, so up
159
+ to 1 ms of lateness per tick added up and ticks were lost (400 calls of 5 ms under a
160
+ 10 ms timer: about 10 ticks lost, the last 120 to 130 ms late). On Linux a timer read
161
+ in the instant between a tick and its delivery shows no time left, and the call then
162
+ left it disarmed for good; it is now kept.
163
+ - Signal mode: a `SIGALRM` taken as a call starts, before its timer is set (a tick of an
164
+ enclosing timer already on its way, also from an enclosing signal-mode call), goes to
165
+ the handler that was there before. It reached the call's own handler half set up: that
166
+ raised `NameError` and stayed installed for good (later code, even a plain
167
+ `time.sleep`, raised `NameError`), or it took the tick as its own and dropped it, so an
168
+ enclosing signal-mode deadline could be lost and the call run forever. A `SIGALRM`
169
+ that is not the call's own timer's (sent with `kill`) also goes to that handler.
170
+ - Signal mode: an enclosing timer keeps its schedule over any number of calls. The call
171
+ reads and replaces it in one step, gives back the exact schedule it took, and waits
172
+ for a tick due within 0.2 ms before taking the timer (Linux 6.6 can drop a tick whose
173
+ timer is replaced in the instant it fires). Each call moved the timer by the time spent
174
+ reading and setting it, so over many calls a one-shot timer fired up to 18 ms early
175
+ (about 90,000 quick calls) and an interval timer drifted by milliseconds, lost ticks
176
+ (39 of 300 under calls that each timed out) and delivered some twice.
177
+ - Every mode starts its budget after argument validation and keyword copying. Those
178
+ caller-side hooks are not interrupted. Thread mode includes starting the thread.
179
+ - The coroutine error names `asyncio.wait_for()` as well as `asyncio.timeout()`, which
180
+ only exists from Python 3.11.
181
+ - Every public name has a docstring with an example that runs as a doctest, `Mode` in
182
+ the package docstring (typing caches `Literal` aliases, so a docstring of its own would
183
+ relabel the caller's identical aliases), and a new GUIDE covers each mode, every
184
+ error and use from async code. The process-mode examples guard their entry point and
185
+ are tested as scripts.
186
+ - `Development Status :: 5 - Production/Stable`.
187
+
188
+ ## [0.2.0] - 2026-10-12
189
+
190
+ Published on PyPI on 2026-10-31; no git tag (commit 9b6975c on `main`).
191
+
192
+ ### Added
193
+
194
+ - `mode="signal"`: a SIGALRM timer raises `CallTimeout` inside the call, which stops
195
+ pure Python code and blocking system calls in place. POSIX main thread only; other
196
+ threads get `RuntimeError` and Windows `NotImplementedError`. Timers already armed
197
+ keep their schedule (a raising outer handler runs once), so calls can be nested, and
198
+ a call that swallows the timeout still ends in `CallTimeout` when it returns, while
199
+ `KeyboardInterrupt`, `SystemExit` and unrelated errors propagate.
200
+ - `with_timeout(timeout, *, mode, default)`: a decorator that keeps the wrapped
201
+ function's signature for type checkers (`ParamSpec`), in every mode including process
202
+ mode for functions the child can import by name (decorated in place or assigned to a
203
+ new name).
204
+ - `CallCrashed`: raised in process mode when the child exits without a result, with its
205
+ exit code, as soon as it is gone.
206
+
207
+ ### Changed
208
+
209
+ - Process mode waits on the result pipe and the process together, pickles results and
210
+ errors in the child (an unpicklable result raises `pickle.PicklingError` at once, an
211
+ unpicklable exception arrives as a `RuntimeError` stand-in), chains the child's
212
+ traceback as the cause, kills a child that ignores SIGTERM after 1 second, and stops
213
+ the child whenever the caller leaves early, including on `KeyboardInterrupt`.
214
+ - Process mode notices a crashed child even while a grandchild it forked keeps the
215
+ pipes open.
216
+ - Process mode pickles the call before starting the child, so a failed start cannot
217
+ leave the next call reading from a stale pipe on Windows; locks and queues, which
218
+ only pickle during start, are still passed through.
219
+ - A finished child gets up to 1 second to exit on its own before it is terminated; a
220
+ result the parent cannot unpickle raises `pickle.UnpicklingError`.
221
+ - `default` is returned only for the call's own timeout; a `CallTimeout` from a nested
222
+ call propagates.
223
+ - Thread mode runs the call in a copy of the caller's context variables and names the
224
+ thread `calltimeout:<function>`.
225
+ - `timeout` must be a real number (not a bool), not NaN and at most
226
+ `threading.TIMEOUT_MAX`; a zero timeout raises without starting the call. Coroutine
227
+ functions and coroutine results are rejected with a pointer to `asyncio.timeout`.
228
+ `kwargs` must be a mapping, and `run` and `with_timeout` reject modes the same way.
229
+ - `CallTimeout` keeps its `timeout` through pickle and copy, and reports the validated
230
+ number of seconds.
231
+
232
+ ## [0.1.0] - 2026-10-11
233
+
234
+ Never published or tagged (commit a3b7c15).
235
+
236
+ - First version.
237
+ - `run(fn, timeout, *, args, kwargs, mode, default)` runs a callable under a
238
+ wall-clock timeout and returns its typed result.
239
+ - Raises `CallTimeout` (a subclass of `TimeoutError`) on overrun, or returns
240
+ `default` when one is given.
241
+ - `mode="thread"` (default) stops waiting but cannot kill a stuck,
242
+ non-cooperative call: the thread lingers until it returns or the process exits.
243
+ - `mode="process"` truly terminates the call on overrun, at the cost of needing
244
+ picklable arguments and result.
@@ -11,8 +11,10 @@ pip install -r requirements-dev.txt
11
11
  ## Before you open a pull request
12
12
 
13
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.
14
+ - Add a test for the behaviour you change. The library's promise is about real time, so
15
+ the suite measures real budgets with real threads, child processes and timers. Tests
16
+ use no network (sockets only as local pairs handed to a child), and write files only
17
+ under pytest's `tmp_path`.
16
18
  - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
19
  - One logical change per commit, imperative subject line.
18
20
 
@@ -0,0 +1,292 @@
1
+ # calltimeout guide
2
+
3
+ The [README](https://github.com/JaviLianes8/calltimeout/blob/main/README.md) shows the
4
+ basics. This guide covers each mechanism in depth, the errors, nesting and async code.
5
+ Every example here is run as a script by the tests.
6
+
7
+ ## The call
8
+
9
+ `run(fn, timeout, *, args=(), kwargs=None, mode="thread", default=...)` calls
10
+ `fn(*args, **kwargs)` and returns its result. `timeout` is a number of seconds: an `int`,
11
+ a `float` or another real number such as a `Fraction`, not a `bool`, not negative, not
12
+ NaN and at most `threading.TIMEOUT_MAX` (so not infinite). A zero timeout raises at once
13
+ without starting the call (in signal mode, so does any timeout under 1 ms). These
14
+ checks, and the ones on `fn`, `args`, `kwargs` and `mode`, happen before anything
15
+ starts, in every mode. `mode` is one of the three names of `Mode`,
16
+ `Literal["thread", "process", "signal"]`, which you can use to annotate your own
17
+ wrappers. `kwargs` is copied into a plain `dict` in the caller before the call starts, so
18
+ a mapping changed later does not affect the call. String-subclass keys are normalized
19
+ before insertion, so dictionary insertion uses base-string hashing and equality.
20
+ Ordinary failures while reading the mapping raise `TypeError`.
21
+
22
+ Validation and keyword copying precede the budget in every mode. User-defined numeric
23
+ conversion and mapping hooks run in the caller and are not interrupted. The budget
24
+ starts after those checks; thread startup is included.
25
+
26
+ The budget covers the call itself, so `fn` must do its work when called. A coroutine
27
+ function or an async generator function raises `TypeError` with a pointer to `asyncio`,
28
+ and so does a call that returns a coroutine or an async generator. A generator function
29
+ raises `TypeError` too: calling it only creates the generator, and its body would run as
30
+ it is iterated, after `run` returned, outside the budget. Run a function that consumes it
31
+ instead. A callable that returns some other lazy object (`map`, a file read later) is
32
+ timed only until it returns that object.
33
+
34
+ When the budget runs out, `run` raises `CallTimeout`, or returns `default` if one was
35
+ given (`None` is a valid default). An exception raised by the call reaches the caller. A
36
+ `CallTimeout` from a nested call inside `fn` is not this call's timeout: it propagates
37
+ even when this call has a default.
38
+
39
+ ```python
40
+ import time
41
+
42
+ from calltimeout import CallTimeout, run
43
+
44
+
45
+ def inner() -> None:
46
+ return run(time.sleep, 0.05, args=(10,))
47
+
48
+
49
+ try:
50
+ run(inner, 5.0, default="outer default")
51
+ except CallTimeout as exc:
52
+ print(f"the inner {exc.timeout} second budget ran out") # the inner 0.05 second budget ran out
53
+ ```
54
+
55
+ ## Thread mode
56
+
57
+ The default. The call runs in a daemon thread named `calltimeout:<function>`, in a copy
58
+ of the caller's context variables (as `asyncio.to_thread` does), and `run` stops waiting
59
+ when the budget runs out. Python cannot stop a running thread, so a call that ignores the
60
+ clock keeps running after `run` has raised, until it returns or the process exits. Daemon
61
+ threads are stopped abruptly at shutdown, so do not leave one holding a file or a
62
+ transaction. Use thread mode for calls that end on their own, eventually.
63
+
64
+ Thread and process mode measure the budget with `time.monotonic()`, whose resolution
65
+ `time.get_clock_info("monotonic")` reports. On Windows it is `GetTickCount64()`, about
66
+ 15.6 ms, up to Python 3.12, and `QueryPerformanceCounter()` from 3.13. In thread mode the
67
+ GIL limits precision more than the clock: a call running Python code holds it, and lets
68
+ the waiting caller run only every switch interval (`sys.getswitchinterval()`, 5 ms by
69
+ default). So `CallTimeout` can come up to about two intervals after the budget ran out. A
70
+ call that ends after the budget ran out, even before the caller noticed, is a timeout:
71
+ what it returned or raised then is dropped, as in process mode. Free-threaded builds have
72
+ no GIL and no such delay.
73
+
74
+ ## Process mode
75
+
76
+ The call runs in a child process that is terminated when the budget runs out (SIGTERM on
77
+ POSIX, `TerminateProcess` on Windows), and killed if it is still alive 1 second later.
78
+ The budget covers pickling the call, starting the child and receiving its result.
79
+ Pickling runs in the caller and is not interrupted: a slow `__reduce__` overruns the
80
+ budget by as long as it takes (and one that never returns blocks `run`), but a call whose
81
+ pickling used up the budget is never started. A result still arriving when the budget
82
+ runs out (a huge one, or one whose child stalled half way through sending it) ends the
83
+ call like any overrun: the child is stopped and `CallTimeout` raised. Rebuilding the
84
+ result also runs in the caller, once it has arrived, and is neither counted against the
85
+ budget nor interrupted: a result whose rebuild is slow delays `run` by as long (and one
86
+ that never returns blocks it).
87
+
88
+ - `fn`, its arguments and its result cross the process boundary, so they must pickle
89
+ (under fork only the result is pickled, see below).
90
+ Targets must be importable: define them at module level, and guard the entry point of
91
+ a script with `if __name__ == "__main__":` as `multiprocessing` requires. Locks, queues,
92
+ pipe ends, sockets, manager proxies and other `multiprocessing` objects can be passed as
93
+ arguments, any number of them, except under forkserver (below). Pipe ends and sockets
94
+ are handed over by the start itself (on Windows, duplicated straight into the child), so
95
+ none is left open in the caller, even when the budget runs out before the child has
96
+ loaded the call.
97
+ - A forkserver start sends every descriptor of the call to the fork server in one
98
+ message, which Linux caps. So under forkserver a call carries at most 248 descriptors:
99
+ one per pipe end or socket, two per queue, and one per block of the shared memory that
100
+ `Value` and `Array` objects live in (many share one). A call holding more raises
101
+ `pickle.PicklingError` before any process starts.
102
+ - Under spawn and forkserver a call that cannot be pickled (a lambda, a thread lock, a
103
+ pool, an argument nested too deeply) raises `pickle.PicklingError` before any process
104
+ starts, chained from a fresh exception containing only a safe category and fixed text.
105
+ A call holding locks or queues is rehearsed as the start will pickle it, so one whose
106
+ contents cannot pickle (a `Value` guarded by a `threading.Lock`) fails the same way.
107
+ One that still fails when the start pickles it again (an object that pickles only once,
108
+ a manager proxy whose pickling raises) raises the same error, and the child created for
109
+ it is stopped before it runs anything. A `KeyboardInterrupt` or other exception that is
110
+ not an `Exception`, raised there, is raised as it is once that child is stopped. Under
111
+ fork nothing is pickled on the way in.
112
+ - Under spawn and forkserver a call that pickles in the caller but cannot be loaded in
113
+ the child (a function defined under `if __name__ == "__main__":`, in `python -c` or a
114
+ notebook, or in a module the child cannot import; an argument whose rebuild raises)
115
+ raises `pickle.UnpicklingError` with a safe error category and the child's
116
+ library traceback chained as its cause.
117
+ - The child process is named `calltimeout:<function>`, like the thread of thread mode.
118
+ - A child that exits without a result (a crash, `os._exit`, the out-of-memory killer)
119
+ raises `CallCrashed` with its exit code as soon as it is gone, not a timeout, and never
120
+ the default. The code is the one `multiprocessing` reports: the value given to
121
+ `os._exit`, or `-N` for a child killed by signal `N` on POSIX.
122
+ - An exception from the child arrives with the child's traceback chained as its cause:
123
+ a `RemoteTraceback`, whose text (also its `text` attribute) is that traceback. One that
124
+ cannot be pickled, or that would arrive as something other than an exception, arrives
125
+ as a `RuntimeError` naming its type and quoting its text.
126
+ `sys.exit()` in the call raises `SystemExit` in the caller like any other exception.
127
+ - A result that cannot be pickled raises `pickle.PicklingError` at once, and one the
128
+ parent cannot rebuild raises `pickle.UnpicklingError`.
129
+ - After the result arrives the child gets up to 1 second to shut down on its own before
130
+ it is terminated: its non-daemon threads finish, and its atexit handlers run where
131
+ `multiprocessing` runs them in a child (under spawn, and under fork and forkserver from
132
+ Python 3.13).
133
+ - Processes started by the call itself are not stopped with it.
134
+
135
+ ```python
136
+ import os
137
+
138
+ from calltimeout import CallCrashed, run
139
+
140
+
141
+ def main() -> None:
142
+ try:
143
+ run(os._exit, 30.0, args=(3,), mode="process")
144
+ except CallCrashed as exc:
145
+ print(f"exit code {exc.exitcode}") # exit code 3
146
+
147
+ try:
148
+ run(int, 30.0, args=("ten",), mode="process")
149
+ except ValueError as exc:
150
+ print(type(exc.__cause__).__name__) # RemoteTraceback
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()
155
+ ```
156
+
157
+ ## Signal mode
158
+
159
+ A `SIGALRM` timer raises `CallTimeout` inside the call, at the line it has reached. Since
160
+ PEP 475 a signal handler that raises also stops blocking system calls such as
161
+ `time.sleep` or a socket read. It needs no thread and no pickling, but only works in the
162
+ main thread on POSIX: in another thread it raises `RuntimeError`, and on Windows
163
+ `NotImplementedError`. Some C code checks for signals as it runs (a regular expression
164
+ match does), but a long call inside C that does not (sorting a huge list, `sum()` over a
165
+ long `range`) is only interrupted once it returns to Python.
166
+
167
+ - A call that catches the timeout and carries on still ends in `CallTimeout`, once it
168
+ returns. The timer fires once, so a loop that keeps catching `OSError` (which
169
+ `TimeoutError` is) runs on until it gives up.
170
+ - An exception raised while handling the timeout counts as the timeout, also one raised
171
+ `from` another cause; `KeyboardInterrupt`, `SystemExit` and unrelated errors propagate.
172
+ - No timer is armed for less than 1 ms: a tick due that soon could land before an
173
+ interrupted system call blocks again and be lost, letting a blocking call run to its
174
+ end. So a tick that arrives less than 1 ms before the deadline (an enclosing timer, a
175
+ stray `SIGALRM`) ends the call there, and a timeout under 1 ms ends it at once without
176
+ starting it.
177
+ - Timers already armed with `setitimer` or `alarm` keep their schedule and their handler
178
+ still runs when due (up to 1 ms late, by the rule above), so signal-mode calls can be
179
+ nested. An interval timer's ticks stay on its own schedule, over any number of calls:
180
+ none is lost or delivered twice, and the lateness of one tick does not carry over to
181
+ the next. A call that starts within 0.2 ms of such a tick lets it through first (Linux
182
+ can lose a tick whose timer is replaced in the instant it fires), which counts against
183
+ its budget. A `SIGALRM` that is not the call's own (a tick already on its way as the call
184
+ starts, or one sent with `kill`) goes to the handler that was there before. A call that
185
+ resets `ITIMER_REAL` itself, or installs
186
+ its own `SIGALRM` handler (`SIG_IGN` included, as an alarm-based helper may), cancels
187
+ the deadline: its result is then returned even if the budget ran out. A `SIGALRM`
188
+ handler installed outside Python is refused, because it could not be restored.
189
+
190
+ ```python
191
+ import sys
192
+ import time
193
+
194
+ from calltimeout import CallTimeout, run
195
+
196
+ if sys.platform != "win32":
197
+ try:
198
+ run(time.sleep, 0.1, args=(60,), mode="signal")
199
+ except CallTimeout as exc:
200
+ # The timeout was raised inside time.sleep and is chained as the cause.
201
+ print(f"interrupted: {type(exc.__cause__).__name__}") # interrupted: CallTimeout
202
+ ```
203
+
204
+ ## The decorator
205
+
206
+ `with_timeout(timeout, *, mode="thread", default=...)` makes every call of the decorated
207
+ function go through `run`. Type checkers keep checking each call against the original
208
+ parameters, and the result type becomes `T | D` with a default. The settings are checked
209
+ when the decorator is made, and the function when it is applied. Signal mode's platform
210
+ and main-thread checks run at each call instead, since a decorated function can be called
211
+ from any thread: on Windows the decorator is made, and each call raises
212
+ `NotImplementedError`.
213
+
214
+ In process mode under spawn or forkserver the child imports the function by name, so
215
+ either the decorated function or the original must be reachable under its module and
216
+ name: a plain `@with_timeout` at module level, or `fast = with_timeout(...)(slow)`. A
217
+ method of a module-level class works too, as an instance method, a `staticmethod` or a
218
+ `classmethod` (with `with_timeout` applied first, under the method decorator). A
219
+ callable that pickles by value (a `functools.partial`, a callable object) is sent as it is.
220
+ Each call runs in its own child, also a call made from inside the function's own child
221
+ (a recursive function gets a new child, with its own budget, for every level).
222
+ Passing a decorated function to `run(..., mode="process")` preserves the decorator's
223
+ independent timeout, fallback and zero-budget guarantee. Only the child started by
224
+ that decorator itself may bypass its wrapper once to execute the body.
225
+
226
+ ```python
227
+ import time
228
+
229
+ from calltimeout import with_timeout
230
+
231
+
232
+ @with_timeout(0.05, default=None)
233
+ def lookup(key: str) -> str:
234
+ time.sleep(1.0)
235
+ return key.upper()
236
+
237
+
238
+ print(lookup("id")) # None
239
+ ```
240
+
241
+ ## Errors
242
+
243
+ | Raised | When |
244
+ |---|---|
245
+ | `CallTimeout` (a `TimeoutError`) | The budget ran out and no default was given |
246
+ | `CallCrashed` (a `RuntimeError`) | Process mode: the child exited without a result |
247
+ | `TypeError` | `timeout` is not a real number (or one that fails to convert to `float`), `fn` is not callable or is a coroutine, async generator or generator function, `args` is not a tuple, `kwargs` is not a readable mapping of `str` keys, `mode` is not a `str`, or the call returned a coroutine or an async generator |
248
+ | `ValueError` | `timeout` is negative, NaN or above `threading.TIMEOUT_MAX`, or `mode` is not one of the three names |
249
+ | `RuntimeError` | Signal mode outside the main thread, or with a `SIGALRM` handler set outside Python; process mode: the call raised an exception that cannot be pickled, or that would arrive as something other than an exception (raised in its place, naming its type) |
250
+ | `NotImplementedError` | Signal mode on Windows |
251
+ | `pickle.PicklingError` | Process mode: `fn` or an argument cannot be sent to the child (raised before the call runs: before any process starts, or once the child is stopped for a call that fails only when the start pickles it), a forkserver call holds more than 248 descriptors, or the result cannot be sent back |
252
+ | `pickle.UnpicklingError` | Process mode: the call cannot be loaded in the child (it pickles in the caller, but the child cannot import its function or rebuild an argument), or the result cannot be rebuilt in the caller |
253
+
254
+ Both exceptions keep their attribute through pickle and copy, so they can cross process
255
+ boundaries themselves. Invalid mode errors list the accepted names without echoing the
256
+ supplied value. Failed timeout conversions and mapping reads produce fixed `TypeError`
257
+ messages without retaining the original exception as a cause or context.
258
+
259
+ Transport diagnostics identify the operation and a safe standard exception category.
260
+ Their causes contain only fixed text: original messages, notes, traceback frames and
261
+ exception chains are discarded, including errors from pickling or rebuilding either
262
+ the call or its result. Custom exception types become `RuntimeError` causes. Original
263
+ exceptions are not modified. Truncation alone would still expose short secrets.
264
+
265
+ These protections apply to library-generated validation and transport diagnostics.
266
+ Exceptions deliberately raised by the callable retain their own messages and, in
267
+ process mode, their remote traceback; that text may contain caller data. Callable and
268
+ type names used in other diagnostics are bounded to 60 characters, and the stand-in
269
+ for an unpicklable callable exception keeps up to 300 characters of its message.
270
+
271
+ ## In async code
272
+
273
+ Coroutines already have `asyncio.timeout()` (Python 3.11+) and `asyncio.wait_for()`, and
274
+ `run` refuses coroutine and async generator functions with a pointer to them. For a blocking call, run `run`
275
+ in a worker thread so the event loop stays free; process mode then really stops the call:
276
+
277
+ ```python
278
+ import asyncio
279
+ import time
280
+
281
+ from calltimeout import run
282
+
283
+
284
+ async def main() -> str | None:
285
+ return await asyncio.to_thread(
286
+ run, time.sleep, 0.5, args=(60,), mode="process", default="stopped"
287
+ )
288
+
289
+
290
+ if __name__ == "__main__":
291
+ print(asyncio.run(main())) # stopped
292
+ ```