interruptible 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.
- interruptible-1.0.0/.gitignore +25 -0
- interruptible-1.0.0/LICENSE +21 -0
- interruptible-1.0.0/MANIFEST.in +10 -0
- interruptible-1.0.0/PKG-INFO +214 -0
- interruptible-1.0.0/README.md +186 -0
- interruptible-1.0.0/examples/manual_ctrlc.py +58 -0
- interruptible-1.0.0/pyproject.toml +93 -0
- interruptible-1.0.0/setup.cfg +4 -0
- interruptible-1.0.0/src/interruptible/__init__.py +35 -0
- interruptible-1.0.0/src/interruptible/_child.py +118 -0
- interruptible-1.0.0/src/interruptible/_posix.py +38 -0
- interruptible-1.0.0/src/interruptible/_version.py +24 -0
- interruptible-1.0.0/src/interruptible/_windows.py +186 -0
- interruptible-1.0.0/src/interruptible/core.py +331 -0
- interruptible-1.0.0/src/interruptible.egg-info/PKG-INFO +214 -0
- interruptible-1.0.0/src/interruptible.egg-info/SOURCES.txt +28 -0
- interruptible-1.0.0/src/interruptible.egg-info/dependency_links.txt +1 -0
- interruptible-1.0.0/src/interruptible.egg-info/scm_file_list.json +32 -0
- interruptible-1.0.0/src/interruptible.egg-info/scm_version.json +8 -0
- interruptible-1.0.0/src/interruptible.egg-info/top_level.txt +1 -0
- interruptible-1.0.0/tests/__init__.py +0 -0
- interruptible-1.0.0/tests/conftest.py +24 -0
- interruptible-1.0.0/tests/scripts/__init__.py +83 -0
- interruptible-1.0.0/tests/scripts/ignore_signals.py +23 -0
- interruptible-1.0.0/tests/scripts/sleep_forever.py +25 -0
- interruptible-1.0.0/tests/scripts/tree_forever.py +48 -0
- interruptible-1.0.0/tests/targets.py +111 -0
- interruptible-1.0.0/tests/test_interruptible.py +127 -0
- interruptible-1.0.0/tests/test_signals.py +72 -0
- interruptible-1.0.0/tests/test_tree_cleanup.py +46 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Build artefacts
|
|
2
|
+
build/
|
|
3
|
+
dist/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
|
|
7
|
+
# Generated version file (setuptools-scm)
|
|
8
|
+
src/interruptible/_version.py
|
|
9
|
+
|
|
10
|
+
# Python
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.py[cod]
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
|
|
16
|
+
# Tooling
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
htmlcov/
|
|
20
|
+
.tox/
|
|
21
|
+
.mypy_cache/
|
|
22
|
+
.ruff_cache/
|
|
23
|
+
|
|
24
|
+
# Editor swap files
|
|
25
|
+
*.swp
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Manthey
|
|
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,10 @@
|
|
|
1
|
+
# Include the manual platform check in the sdist. README.md and LICENSE are
|
|
2
|
+
# included automatically by setuptools. PLAN.md is developer documentation and
|
|
3
|
+
# deliberately not shipped, as are the developer tooling configs.
|
|
4
|
+
recursive-include examples *.py
|
|
5
|
+
exclude PLAN.md
|
|
6
|
+
exclude .flake8
|
|
7
|
+
exclude .pre-commit-config.yaml
|
|
8
|
+
exclude .ruff.toml
|
|
9
|
+
recursive-exclude .github *
|
|
10
|
+
recursive-exclude .tox *
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: interruptible
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Guaranteed, transparent Ctrl+C for Python programs
|
|
5
|
+
Author-email: David Manthey <manthey@orbitals.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/manthey/interruptible
|
|
8
|
+
Project-URL: Repository, https://github.com/manthey/interruptible
|
|
9
|
+
Project-URL: Issues, https://github.com/manthey/interruptible/issues
|
|
10
|
+
Keywords: ctrl-c,sigint,signal,interrupt,keyboardinterrupt,subprocess,timeout
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Operating System Kernels
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# interruptible
|
|
30
|
+
|
|
31
|
+
**Guaranteed, transparent Ctrl+C for Python programs.**
|
|
32
|
+
|
|
33
|
+
`interruptible` makes Ctrl+C work *promptly* even when your program is blocked
|
|
34
|
+
inside a native C/Rust extension that never returns to the Python bytecode
|
|
35
|
+
evaluator -- for example an HTTP request through a library that does not poll
|
|
36
|
+
for interrupts, a database driver, or a long-running computation in NumPy.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import sys
|
|
40
|
+
import interruptible
|
|
41
|
+
|
|
42
|
+
def main():
|
|
43
|
+
# Anything at all here, including blocking C calls.
|
|
44
|
+
...
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
sys.exit(interruptible.run(main))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Press Ctrl+C and the program stops, now.
|
|
52
|
+
|
|
53
|
+
## Why this is needed
|
|
54
|
+
|
|
55
|
+
Python delivers signals by setting a flag that the interpreter checks *between
|
|
56
|
+
bytecodes*. If your code is blocked inside a C extension, the interpreter is
|
|
57
|
+
never reached, so `KeyboardInterrupt` is not raised until the call returns --
|
|
58
|
+
which may be never. On Windows it is worse: a blocking call is not interrupted
|
|
59
|
+
at all.
|
|
60
|
+
|
|
61
|
+
There is no way to fix this from inside the blocked process. So `interruptible`
|
|
62
|
+
runs your `main` in a child process. The parent does nothing but wait, so it is
|
|
63
|
+
always able to react to Ctrl+C immediately; on interrupt it forwards the signal
|
|
64
|
+
to the child and, if the child is one of the ill-behaved ones, forcibly
|
|
65
|
+
terminates the child's entire process tree.
|
|
66
|
+
|
|
67
|
+
## Transparency
|
|
68
|
+
|
|
69
|
+
The design goal is that a wrapped program is indistinguishable from an unwrapped
|
|
70
|
+
one, apart from interrupts always working:
|
|
71
|
+
|
|
72
|
+
| Behaviour | `interruptible` result |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `main()` returns `0` | exit code `0` |
|
|
75
|
+
| `main()` returns `N` | exit code `N` |
|
|
76
|
+
| `main()` raises | traceback on stderr, exit code `1` |
|
|
77
|
+
| `sys.exit(N)` | exit code `N` |
|
|
78
|
+
| Ctrl+C, child exits on its own | exit code `130` (`128 + SIGINT`) |
|
|
79
|
+
| `SIGTERM` | exit code `143` (`128 + SIGTERM`) |
|
|
80
|
+
| Ctrl+C, child ignores it | child tree killed after `kill_timeout`; exit code `130` |
|
|
81
|
+
| `timeout=` expires | child signalled, exit code `127` |
|
|
82
|
+
| stdout/stderr | forwarded live to the parent's streams |
|
|
83
|
+
|
|
84
|
+
The signal the *user* sent determines the exit code, so Ctrl+C always reports
|
|
85
|
+
`130`, exactly as an unwrapped program would.
|
|
86
|
+
|
|
87
|
+
## Install
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
pip install interruptible
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
No runtime dependencies. Python 3.10+.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
### Function
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
sys.exit(interruptible.run(main, timeout=60, kill_timeout=5.0))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Decorator
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
@interruptible.interruptible(timeout=300)
|
|
107
|
+
def main():
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### API
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
def run(
|
|
118
|
+
target: Callable[..., int | None],
|
|
119
|
+
*args,
|
|
120
|
+
timeout: float | None = None,
|
|
121
|
+
kill_timeout: float = 5.0,
|
|
122
|
+
passthrough_signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM),
|
|
123
|
+
inherit_environ: bool = True,
|
|
124
|
+
**kwargs,
|
|
125
|
+
) -> int: ...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
* `target` -- the function to run. On spawn platforms (Windows, macOS) it must
|
|
129
|
+
be picklable, which means a module-level function in a real file; see the FAQ.
|
|
130
|
+
* `timeout` -- maximum runtime in seconds (`None` = unlimited). On expiry the
|
|
131
|
+
child is signalled and `127` is returned.
|
|
132
|
+
* `kill_timeout` -- seconds to wait for a graceful exit after forwarding a
|
|
133
|
+
signal before force-killing the child's process tree.
|
|
134
|
+
* `passthrough_signals` -- signals received by the parent that are forwarded to
|
|
135
|
+
the child.
|
|
136
|
+
* `inherit_environ` -- whether the child inherits the environment.
|
|
137
|
+
|
|
138
|
+
## FAQ
|
|
139
|
+
|
|
140
|
+
**Why a subprocess?** Because there is no other way to be responsive while the
|
|
141
|
+
main thread is stuck inside native code. A thread or an asyncio task cannot
|
|
142
|
+
help: they cannot preempt a blocked C call either.
|
|
143
|
+
|
|
144
|
+
**Doesn't `signal.set_wakeup_fd` / `faulthandler` solve this?** Those change how
|
|
145
|
+
the *interpreter* notices signals; they do not make a blocked native call
|
|
146
|
+
return.
|
|
147
|
+
|
|
148
|
+
**What about child processes I spawn myself?** On POSIX the child runs in its
|
|
149
|
+
own process group, and the whole group is signalled during cleanup. On Windows
|
|
150
|
+
the child is attached to a Job Object configured to kill all member processes
|
|
151
|
+
when the job closes, so grandchildren die too.
|
|
152
|
+
|
|
153
|
+
**Nested use.** If `run()` is called from inside a child (detected via the
|
|
154
|
+
`INTERRUPTIBLE_CHILD` environment variable), the target runs inline instead of
|
|
155
|
+
spawning another process.
|
|
156
|
+
|
|
157
|
+
**Why did I get `Can't pickle local object`?** On Windows, and on macOS since
|
|
158
|
+
Python 3.8, the child is started with `spawn`, which sends the target to the new
|
|
159
|
+
interpreter by pickling it *by module and name*. Two things therefore cannot be
|
|
160
|
+
used as a target:
|
|
161
|
+
|
|
162
|
+
* a nested function, lambda, closure, or bound method defined inside another
|
|
163
|
+
function;
|
|
164
|
+
* a function defined in a `python -c` string or an interactive session, because
|
|
165
|
+
its `__main__` has no importable name.
|
|
166
|
+
|
|
167
|
+
Both fail with `AttributeError` once the child tries to unpickle the target. Use
|
|
168
|
+
a module-level function in a real file:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
# do this
|
|
172
|
+
def main():
|
|
173
|
+
...
|
|
174
|
+
|
|
175
|
+
if __name__ == '__main__':
|
|
176
|
+
sys.exit(interruptible.run(main))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# not this -- main cannot be pickled
|
|
180
|
+
if __name__ == '__main__':
|
|
181
|
+
def main():
|
|
182
|
+
...
|
|
183
|
+
|
|
184
|
+
sys.exit(interruptible.run(main))
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Platform notes
|
|
188
|
+
|
|
189
|
+
`run()` uses the platform's default `multiprocessing` start method. That is
|
|
190
|
+
`spawn` on Windows and macOS and `fork` on Linux, and the difference matters:
|
|
191
|
+
`spawn` requires a picklable (module-level) target, while `fork` accepts
|
|
192
|
+
anything. Forcing `fork` on Linux was deliberately rejected -- it is unsafe in
|
|
193
|
+
a process with threads and it would hide this requirement from anyone
|
|
194
|
+
developing on Linux. To check your code under the stricter `spawn` rules on
|
|
195
|
+
Linux, set the start method before calling `run()`:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
import multiprocessing
|
|
199
|
+
|
|
200
|
+
multiprocessing.set_start_method('spawn')
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
* **Spawn platforms (Windows, macOS)** -- the target must be picklable.
|
|
204
|
+
* **POSIX** -- the parent forwards the exact signal it received (so the child
|
|
205
|
+
sees a normal `KeyboardInterrupt`), then `SIGKILL`s the process group if the
|
|
206
|
+
child has not exited within `kill_timeout`.
|
|
207
|
+
* **Windows** -- a child started by `multiprocessing` shares the parent's
|
|
208
|
+
console process group, so the console delivers Ctrl+C to it directly; no
|
|
209
|
+
explicit forwarding is needed. The Job Object guarantees the whole tree is
|
|
210
|
+
cleaned up if the child ignores it.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# interruptible
|
|
2
|
+
|
|
3
|
+
**Guaranteed, transparent Ctrl+C for Python programs.**
|
|
4
|
+
|
|
5
|
+
`interruptible` makes Ctrl+C work *promptly* even when your program is blocked
|
|
6
|
+
inside a native C/Rust extension that never returns to the Python bytecode
|
|
7
|
+
evaluator -- for example an HTTP request through a library that does not poll
|
|
8
|
+
for interrupts, a database driver, or a long-running computation in NumPy.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import sys
|
|
12
|
+
import interruptible
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
# Anything at all here, including blocking C calls.
|
|
16
|
+
...
|
|
17
|
+
return 0
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
sys.exit(interruptible.run(main))
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Press Ctrl+C and the program stops, now.
|
|
24
|
+
|
|
25
|
+
## Why this is needed
|
|
26
|
+
|
|
27
|
+
Python delivers signals by setting a flag that the interpreter checks *between
|
|
28
|
+
bytecodes*. If your code is blocked inside a C extension, the interpreter is
|
|
29
|
+
never reached, so `KeyboardInterrupt` is not raised until the call returns --
|
|
30
|
+
which may be never. On Windows it is worse: a blocking call is not interrupted
|
|
31
|
+
at all.
|
|
32
|
+
|
|
33
|
+
There is no way to fix this from inside the blocked process. So `interruptible`
|
|
34
|
+
runs your `main` in a child process. The parent does nothing but wait, so it is
|
|
35
|
+
always able to react to Ctrl+C immediately; on interrupt it forwards the signal
|
|
36
|
+
to the child and, if the child is one of the ill-behaved ones, forcibly
|
|
37
|
+
terminates the child's entire process tree.
|
|
38
|
+
|
|
39
|
+
## Transparency
|
|
40
|
+
|
|
41
|
+
The design goal is that a wrapped program is indistinguishable from an unwrapped
|
|
42
|
+
one, apart from interrupts always working:
|
|
43
|
+
|
|
44
|
+
| Behaviour | `interruptible` result |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `main()` returns `0` | exit code `0` |
|
|
47
|
+
| `main()` returns `N` | exit code `N` |
|
|
48
|
+
| `main()` raises | traceback on stderr, exit code `1` |
|
|
49
|
+
| `sys.exit(N)` | exit code `N` |
|
|
50
|
+
| Ctrl+C, child exits on its own | exit code `130` (`128 + SIGINT`) |
|
|
51
|
+
| `SIGTERM` | exit code `143` (`128 + SIGTERM`) |
|
|
52
|
+
| Ctrl+C, child ignores it | child tree killed after `kill_timeout`; exit code `130` |
|
|
53
|
+
| `timeout=` expires | child signalled, exit code `127` |
|
|
54
|
+
| stdout/stderr | forwarded live to the parent's streams |
|
|
55
|
+
|
|
56
|
+
The signal the *user* sent determines the exit code, so Ctrl+C always reports
|
|
57
|
+
`130`, exactly as an unwrapped program would.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
pip install interruptible
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
No runtime dependencies. Python 3.10+.
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
### Function
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
sys.exit(interruptible.run(main, timeout=60, kill_timeout=5.0))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Decorator
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
@interruptible.interruptible(timeout=300)
|
|
79
|
+
def main():
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
sys.exit(main())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### API
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
def run(
|
|
90
|
+
target: Callable[..., int | None],
|
|
91
|
+
*args,
|
|
92
|
+
timeout: float | None = None,
|
|
93
|
+
kill_timeout: float = 5.0,
|
|
94
|
+
passthrough_signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM),
|
|
95
|
+
inherit_environ: bool = True,
|
|
96
|
+
**kwargs,
|
|
97
|
+
) -> int: ...
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
* `target` -- the function to run. On spawn platforms (Windows, macOS) it must
|
|
101
|
+
be picklable, which means a module-level function in a real file; see the FAQ.
|
|
102
|
+
* `timeout` -- maximum runtime in seconds (`None` = unlimited). On expiry the
|
|
103
|
+
child is signalled and `127` is returned.
|
|
104
|
+
* `kill_timeout` -- seconds to wait for a graceful exit after forwarding a
|
|
105
|
+
signal before force-killing the child's process tree.
|
|
106
|
+
* `passthrough_signals` -- signals received by the parent that are forwarded to
|
|
107
|
+
the child.
|
|
108
|
+
* `inherit_environ` -- whether the child inherits the environment.
|
|
109
|
+
|
|
110
|
+
## FAQ
|
|
111
|
+
|
|
112
|
+
**Why a subprocess?** Because there is no other way to be responsive while the
|
|
113
|
+
main thread is stuck inside native code. A thread or an asyncio task cannot
|
|
114
|
+
help: they cannot preempt a blocked C call either.
|
|
115
|
+
|
|
116
|
+
**Doesn't `signal.set_wakeup_fd` / `faulthandler` solve this?** Those change how
|
|
117
|
+
the *interpreter* notices signals; they do not make a blocked native call
|
|
118
|
+
return.
|
|
119
|
+
|
|
120
|
+
**What about child processes I spawn myself?** On POSIX the child runs in its
|
|
121
|
+
own process group, and the whole group is signalled during cleanup. On Windows
|
|
122
|
+
the child is attached to a Job Object configured to kill all member processes
|
|
123
|
+
when the job closes, so grandchildren die too.
|
|
124
|
+
|
|
125
|
+
**Nested use.** If `run()` is called from inside a child (detected via the
|
|
126
|
+
`INTERRUPTIBLE_CHILD` environment variable), the target runs inline instead of
|
|
127
|
+
spawning another process.
|
|
128
|
+
|
|
129
|
+
**Why did I get `Can't pickle local object`?** On Windows, and on macOS since
|
|
130
|
+
Python 3.8, the child is started with `spawn`, which sends the target to the new
|
|
131
|
+
interpreter by pickling it *by module and name*. Two things therefore cannot be
|
|
132
|
+
used as a target:
|
|
133
|
+
|
|
134
|
+
* a nested function, lambda, closure, or bound method defined inside another
|
|
135
|
+
function;
|
|
136
|
+
* a function defined in a `python -c` string or an interactive session, because
|
|
137
|
+
its `__main__` has no importable name.
|
|
138
|
+
|
|
139
|
+
Both fail with `AttributeError` once the child tries to unpickle the target. Use
|
|
140
|
+
a module-level function in a real file:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
# do this
|
|
144
|
+
def main():
|
|
145
|
+
...
|
|
146
|
+
|
|
147
|
+
if __name__ == '__main__':
|
|
148
|
+
sys.exit(interruptible.run(main))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# not this -- main cannot be pickled
|
|
152
|
+
if __name__ == '__main__':
|
|
153
|
+
def main():
|
|
154
|
+
...
|
|
155
|
+
|
|
156
|
+
sys.exit(interruptible.run(main))
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Platform notes
|
|
160
|
+
|
|
161
|
+
`run()` uses the platform's default `multiprocessing` start method. That is
|
|
162
|
+
`spawn` on Windows and macOS and `fork` on Linux, and the difference matters:
|
|
163
|
+
`spawn` requires a picklable (module-level) target, while `fork` accepts
|
|
164
|
+
anything. Forcing `fork` on Linux was deliberately rejected -- it is unsafe in
|
|
165
|
+
a process with threads and it would hide this requirement from anyone
|
|
166
|
+
developing on Linux. To check your code under the stricter `spawn` rules on
|
|
167
|
+
Linux, set the start method before calling `run()`:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
import multiprocessing
|
|
171
|
+
|
|
172
|
+
multiprocessing.set_start_method('spawn')
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
* **Spawn platforms (Windows, macOS)** -- the target must be picklable.
|
|
176
|
+
* **POSIX** -- the parent forwards the exact signal it received (so the child
|
|
177
|
+
sees a normal `KeyboardInterrupt`), then `SIGKILL`s the process group if the
|
|
178
|
+
child has not exited within `kill_timeout`.
|
|
179
|
+
* **Windows** -- a child started by `multiprocessing` shares the parent's
|
|
180
|
+
console process group, so the console delivers Ctrl+C to it directly; no
|
|
181
|
+
explicit forwarding is needed. The Job Object guarantees the whole tree is
|
|
182
|
+
cleaned up if the child ignores it.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Manual Ctrl+C check.
|
|
2
|
+
|
|
3
|
+
Run this in a real console window and press Ctrl+C. It is the one check that
|
|
4
|
+
cannot be automated: CI runners are non-interactive and cannot generate a
|
|
5
|
+
genuine console interrupt, so this must be done by hand on each platform, and
|
|
6
|
+
especially on Windows.
|
|
7
|
+
|
|
8
|
+
Expected result either way: the program stops within about a second and exits
|
|
9
|
+
with code 130 (128 + SIGINT), leaving no surviving process.
|
|
10
|
+
|
|
11
|
+
The default target deliberately *ignores* SIGINT and SIGTERM, simulating the
|
|
12
|
+
native extension this package exists for. Pass ``--graceful`` to use a
|
|
13
|
+
well-behaved target instead: it must print a KeyboardInterrupt traceback, which
|
|
14
|
+
proves the child was interrupted rather than hard-killed.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python examples/manual_ctrlc.py # child ignores the signal
|
|
18
|
+
python examples/manual_ctrlc.py --graceful # child handles it
|
|
19
|
+
|
|
20
|
+
Then press Ctrl+C. To see the exit code:
|
|
21
|
+
cmd: ... ; echo exit=%errorlevel%
|
|
22
|
+
PowerShell: ... ; echo "exit=$LASTEXITCODE"
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import signal
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
import interruptible
|
|
32
|
+
|
|
33
|
+
# Kill after this many seconds if the child refuses to die. Kept short so the
|
|
34
|
+
# manual test is quick.
|
|
35
|
+
KILL_TIMEOUT = 1.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def stubborn_target() -> int:
|
|
39
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
40
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
41
|
+
print('Press Ctrl+C. The process should stop within a second.', flush=True)
|
|
42
|
+
while True:
|
|
43
|
+
time.sleep(0.05)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def graceful_target() -> int:
|
|
47
|
+
print('Press Ctrl+C. Expect a KeyboardInterrupt traceback, then exit 130.', flush=True)
|
|
48
|
+
while True:
|
|
49
|
+
time.sleep(0.05)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main() -> int:
|
|
53
|
+
target = graceful_target if '--graceful' in sys.argv[1:] else stubborn_target
|
|
54
|
+
return interruptible.run(target, kill_timeout=KILL_TIMEOUT)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == '__main__':
|
|
58
|
+
sys.exit(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "setuptools-scm>=8"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "interruptible"
|
|
7
|
+
description = "Guaranteed, transparent Ctrl+C for Python programs"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "David Manthey", email = "manthey@orbitals.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"ctrl-c",
|
|
15
|
+
"sigint",
|
|
16
|
+
"signal",
|
|
17
|
+
"interrupt",
|
|
18
|
+
"keyboardinterrupt",
|
|
19
|
+
"subprocess",
|
|
20
|
+
"timeout",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 5 - Production/Stable",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Operating System :: Microsoft :: Windows",
|
|
27
|
+
"Operating System :: POSIX",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
34
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
|
+
"Topic :: System :: Operating System Kernels",
|
|
36
|
+
]
|
|
37
|
+
dependencies = []
|
|
38
|
+
dynamic = ["version"]
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
test = [
|
|
42
|
+
"pytest>=8",
|
|
43
|
+
]
|
|
44
|
+
dev = [
|
|
45
|
+
{ include-group = "test" },
|
|
46
|
+
"build>=1.2",
|
|
47
|
+
"tox>=4.20",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.tox]
|
|
51
|
+
legacy_tox_ini = """
|
|
52
|
+
[tox]
|
|
53
|
+
env_list = py310, py311, py312, py313, py314, precommit
|
|
54
|
+
isolated_build = True
|
|
55
|
+
skip_missing_interpreters = True
|
|
56
|
+
|
|
57
|
+
[testenv]
|
|
58
|
+
package = editable
|
|
59
|
+
dependency_groups = test
|
|
60
|
+
commands = python -m pytest {posargs:-v}
|
|
61
|
+
|
|
62
|
+
[testenv:precommit]
|
|
63
|
+
package = skip
|
|
64
|
+
deps = pre-commit
|
|
65
|
+
description = run all pre-commit hooks
|
|
66
|
+
skip_install = True
|
|
67
|
+
commands = pre-commit run --all-files --show-diff-on-failure
|
|
68
|
+
|
|
69
|
+
[testenv:build]
|
|
70
|
+
package = skip
|
|
71
|
+
deps = build
|
|
72
|
+
description = build sdist and wheel
|
|
73
|
+
commands = python -m build
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
[project.urls]
|
|
77
|
+
Homepage = "https://github.com/manthey/interruptible"
|
|
78
|
+
Repository = "https://github.com/manthey/interruptible"
|
|
79
|
+
Issues = "https://github.com/manthey/interruptible/issues"
|
|
80
|
+
|
|
81
|
+
[tool.setuptools]
|
|
82
|
+
package-dir = { "" = "src" }
|
|
83
|
+
|
|
84
|
+
[tool.setuptools.packages.find]
|
|
85
|
+
where = ["src"]
|
|
86
|
+
|
|
87
|
+
[tool.setuptools_scm]
|
|
88
|
+
version_file = "src/interruptible/_version.py"
|
|
89
|
+
fallback_version = "0.0.0.dev0"
|
|
90
|
+
|
|
91
|
+
[tool.pytest.ini_options]
|
|
92
|
+
testpaths = ["tests"]
|
|
93
|
+
addopts = "-ra"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""``interruptible`` -- reliable Ctrl+C for Python programs.
|
|
2
|
+
|
|
3
|
+
Wrap a program's entry point to guarantee that Ctrl+C terminates it promptly,
|
|
4
|
+
even when it is blocked inside a native C/Rust extension that fails to honour
|
|
5
|
+
signals::
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import interruptible
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
... # may block in a C extension
|
|
12
|
+
return 0
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
sys.exit(interruptible.run(main))
|
|
16
|
+
|
|
17
|
+
The goal is transparency: the wrapped program behaves as though this module were
|
|
18
|
+
not present, except that interrupts always work.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from .core import DEFAULT_KILL_TIMEOUT, TIMEOUT_EXIT_CODE, interruptible, run
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
'run',
|
|
27
|
+
'interruptible',
|
|
28
|
+
'DEFAULT_KILL_TIMEOUT',
|
|
29
|
+
'TIMEOUT_EXIT_CODE',
|
|
30
|
+
]
|
|
31
|
+
try: # pragma: no cover - the version is generated by setuptools-scm
|
|
32
|
+
from ._version import __version__, __version_tuple__
|
|
33
|
+
except ImportError: # pragma: no cover
|
|
34
|
+
__version__ = '0.0.0.dev0'
|
|
35
|
+
__version_tuple__ = (0, 0, 0, 'dev0')
|