interruptible 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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')
@@ -0,0 +1,118 @@
1
+ """Code that runs inside the child process.
2
+
3
+ This module must be importable by a spawned child. On platforms that use the
4
+ ``spawn`` start method (notably Windows and macOS since 3.8) the child
5
+ re-imports the target as a pickle reference, so everything here must be
6
+ importable without side effects and must not depend on the parent's state.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import signal
13
+ import sys
14
+ import traceback
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ if sys.platform == 'win32': # pragma: no cover - platform dispatch
19
+ from . import _windows as _platform
20
+ else:
21
+ from . import _posix as _platform
22
+
23
+ __all__ = ['child_main', 'run_inline']
24
+
25
+
26
+ def configure_child() -> None:
27
+ """Platform hook run in the child before the target starts."""
28
+ try:
29
+ _platform.configure_child()
30
+ except Exception: # pragma: no cover - never let setup break the child
31
+ pass
32
+
33
+
34
+ def child_main(
35
+ target: Callable[..., Any],
36
+ args: tuple[Any, ...],
37
+ kwargs: dict[str, Any],
38
+ inherit_environ: bool = True,
39
+ ) -> None:
40
+ """Entry point executed in the child process.
41
+
42
+ The target's return value becomes the child's exit code. ``multiprocessing``
43
+ ignores a target's return value, so the code is applied via ``sys.exit``.
44
+ A ``SystemExit`` raised by the target is honoured (its code is used), and any
45
+ other exception is reported to stderr with a traceback before exiting with
46
+ code 1 -- mirroring what an uncaught exception does in a plain Python
47
+ program.
48
+ """
49
+ # Mark the environment so nested ``interruptible.run`` calls run inline
50
+ # rather than spawning another child. This happens before any environment
51
+ # clearing so the marker is always present in the child.
52
+ os.environ['INTERRUPTIBLE_CHILD'] = '1'
53
+ if not inherit_environ:
54
+ # Present an empty environment to the target, matching what a program
55
+ # started with ``env -i`` would see. The marker is kept so nested runs
56
+ # are still detected.
57
+ marker = {key: os.environ[key] for key in ('INTERRUPTIBLE_CHILD',) if key in os.environ}
58
+ os.environ.clear()
59
+ os.environ.update(marker)
60
+ # Detach into our own process group (POSIX) so the parent can signal the
61
+ # whole subtree -- including any grandchildren -- during cleanup.
62
+ configure_child()
63
+ try:
64
+ result = target(*args, **kwargs)
65
+ except SystemExit as exc:
66
+ sys.exit(normalize_exit_code(exc.code))
67
+ except KeyboardInterrupt:
68
+ # Match CPython's behaviour for a program interrupted by Ctrl+C: print
69
+ # the traceback and exit with ``128 + SIGINT``. Reporting 1 here would
70
+ # break the transparency guarantee, since a plain program reports 130.
71
+ traceback.print_exc()
72
+ sys.stderr.flush()
73
+ sys.exit(128 + signal.SIGINT)
74
+ except BaseException:
75
+ traceback.print_exc()
76
+ sys.stderr.flush()
77
+ sys.exit(1)
78
+ sys.exit(normalize_exit_code(result))
79
+
80
+
81
+ def run_inline(target: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> int:
82
+ """Run ``target`` in the current process and return its exit code.
83
+
84
+ Used for nested ``run`` calls inside an existing child, where spawning
85
+ another process would be pointless. Exceptions behave as in ``child_main``
86
+ except that they are not swallowed: an uncaught exception is printed and
87
+ reported as exit code 1, matching what the surrounding program would see.
88
+ """
89
+ try:
90
+ result = target(*args, **kwargs)
91
+ except SystemExit as exc:
92
+ return normalize_exit_code(exc.code)
93
+ except KeyboardInterrupt:
94
+ # As in ``child_main``, an interrupt reports ``128 + SIGINT``.
95
+ traceback.print_exc()
96
+ sys.stderr.flush()
97
+ return 128 + signal.SIGINT
98
+ except BaseException:
99
+ traceback.print_exc()
100
+ sys.stderr.flush()
101
+ return 1
102
+ return normalize_exit_code(result)
103
+
104
+
105
+ def normalize_exit_code(code: Any) -> int:
106
+ """Coerce a user-provided exit code into an 8-bit process exit status."""
107
+ if code is None:
108
+ return 0
109
+ if isinstance(code, bool):
110
+ return int(code)
111
+ if isinstance(code, int):
112
+ # POSIX exit statuses are 8-bit; emulate that truncation so behaviour
113
+ # matches a plain ``sys.exit(n)``.
114
+ return code & 0xFF
115
+ # Non-integer, non-None values passed to SystemExit are printed and the
116
+ # process exits with status 1, exactly as CPython does.
117
+ print(code, file=sys.stderr)
118
+ return 1
@@ -0,0 +1,38 @@
1
+ """POSIX-specific process group helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess # noqa: F401 (imported for typing clarity only)
7
+ from typing import Any
8
+
9
+ __all__ = ['configure_child', 'kill_tree']
10
+
11
+
12
+ def configure_child() -> None:
13
+ """Run in the child, before the target, to detach its process group.
14
+
15
+ Making the child a process-group leader means we can signal the entire
16
+ group (the child plus anything it spawned) with a single call, which is
17
+ what makes cleanup reliable when the target spawns its own children.
18
+ """
19
+ try:
20
+ os.setpgid(0, 0)
21
+ except OSError:
22
+ # Already a group leader, or the platform lacks setpgid. Not fatal:
23
+ # we fall back to signalling the child pid directly.
24
+ pass
25
+
26
+
27
+ def kill_tree(process: Any, sig: int) -> None:
28
+ """Send ``sig`` to the child's process group, falling back to the pid."""
29
+ pid = process.pid
30
+ if pid is None: # pragma: no cover - defensive
31
+ return
32
+ try:
33
+ os.killpg(pid, sig)
34
+ except OSError:
35
+ try:
36
+ os.kill(pid, sig)
37
+ except OSError:
38
+ pass
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.0.0'
22
+ __version_tuple__ = version_tuple = (1, 0, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,186 @@
1
+ """Windows-specific helpers built on ctypes (no third-party dependencies).
2
+
3
+ Two problems must be solved on Windows:
4
+
5
+ 1. Ctrl+C must reach the child. A child started by ``multiprocessing`` on
6
+ Windows is created without ``CREATE_NEW_PROCESS_GROUP``, so it shares the
7
+ parent's console process group. A console Ctrl+C is therefore delivered by
8
+ Windows to the child *directly*, at the same moment the parent sees it, and
9
+ the child raises ``KeyboardInterrupt`` as a normal program would. No
10
+ explicit forwarding is needed on the normal path; ``send_ctrl_event``
11
+ exists only for a child deliberately started in its own group, and
12
+ ``forward`` does not call it.
13
+
14
+ 2. When the child ignores the signal (the entire reason this package exists), we
15
+ must terminate the *whole process tree*. ``Process.kill`` only kills the
16
+ direct child; grandchildren survive. We attach the child to a Windows Job
17
+ Object with ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE``, so the tree dies with the
18
+ job.
19
+
20
+ Everything here is imported unconditionally, so it must work (as inert stubs)
21
+ on non-Windows platforms.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import ctypes
27
+ import sys
28
+ from ctypes import wintypes
29
+ from typing import Any
30
+
31
+ __all__ = [
32
+ 'create_job',
33
+ 'assign_process_to_job',
34
+ 'close_job',
35
+ 'configure_child',
36
+ 'kill_tree',
37
+ 'send_ctrl_event',
38
+ 'IS_WINDOWS',
39
+ ]
40
+ IS_WINDOWS = sys.platform == 'win32'
41
+
42
+ # Access rights used by OpenProcess.
43
+ PROCESS_SET_QUOTA = 0x0100
44
+ PROCESS_TERMINATE = 0x0001
45
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
46
+
47
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
48
+ JobObjectExtendedLimitInformation = 9
49
+ CTRL_C_EVENT = 0
50
+ CTRL_BREAK_EVENT = 1
51
+ if IS_WINDOWS: # pragma: no cover - Windows only
52
+ _kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
53
+
54
+ class _IO_COUNTERS(ctypes.Structure):
55
+ _fields_ = [
56
+ ('ReadOperationCount', ctypes.c_ulonglong),
57
+ ('WriteOperationCount', ctypes.c_ulonglong),
58
+ ('OtherOperationCount', ctypes.c_ulonglong),
59
+ ('ReadTransferCount', ctypes.c_ulonglong),
60
+ ('WriteTransferCount', ctypes.c_ulonglong),
61
+ ('OtherTransferCount', ctypes.c_ulonglong),
62
+ ]
63
+
64
+ class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
65
+ _fields_ = [
66
+ ('PerProcessUserTimeLimit', wintypes.LARGE_INTEGER),
67
+ ('PerJobUserTimeLimit', wintypes.LARGE_INTEGER),
68
+ ('LimitFlags', wintypes.DWORD),
69
+ ('MinimumWorkingSetSize', ctypes.c_size_t),
70
+ ('MaximumWorkingSetSize', ctypes.c_size_t),
71
+ ('ActiveProcessLimit', wintypes.DWORD),
72
+ ('Affinity', ctypes.c_size_t),
73
+ ('PriorityClass', wintypes.DWORD),
74
+ ('SchedulingClass', wintypes.DWORD),
75
+ ]
76
+
77
+ class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
78
+ _fields_ = [
79
+ ('BasicLimitInformation', _JOBOBJECT_BASIC_LIMIT_INFORMATION),
80
+ ('IoInfo', _IO_COUNTERS),
81
+ ('ProcessMemoryLimit', ctypes.c_size_t),
82
+ ('JobMemoryLimit', ctypes.c_size_t),
83
+ ('PeakProcessMemoryUsed', ctypes.c_size_t),
84
+ ('PeakJobMemoryUsed', ctypes.c_size_t),
85
+ ]
86
+
87
+ _kernel32.CreateJobObjectW.restype = wintypes.HANDLE
88
+ _kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR]
89
+ _kernel32.SetInformationJobObject.restype = wintypes.BOOL
90
+ _kernel32.SetInformationJobObject.argtypes = [
91
+ wintypes.HANDLE,
92
+ ctypes.c_int,
93
+ wintypes.LPVOID,
94
+ wintypes.DWORD,
95
+ ]
96
+ _kernel32.OpenProcess.restype = wintypes.HANDLE
97
+ _kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
98
+ _kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
99
+ _kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
100
+ _kernel32.TerminateJobObject.restype = wintypes.BOOL
101
+ _kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
102
+ _kernel32.CloseHandle.restype = wintypes.BOOL
103
+ _kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
104
+ _kernel32.GenerateConsoleCtrlEvent.restype = wintypes.BOOL
105
+ _kernel32.GenerateConsoleCtrlEvent.argtypes = [wintypes.DWORD, wintypes.DWORD]
106
+
107
+
108
+ def create_job() -> int | None:
109
+ """Create a kill-on-close job object; return its handle, or ``None``."""
110
+ if not IS_WINDOWS: # pragma: no cover
111
+ return None
112
+ handle = _kernel32.CreateJobObjectW(None, None)
113
+ if not handle:
114
+ return None
115
+ info = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
116
+ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
117
+ ok = _kernel32.SetInformationJobObject(
118
+ handle,
119
+ JobObjectExtendedLimitInformation,
120
+ ctypes.byref(info),
121
+ ctypes.sizeof(info),
122
+ )
123
+ if not ok:
124
+ _kernel32.CloseHandle(handle)
125
+ return None
126
+ return handle
127
+
128
+
129
+ def assign_process_to_job(job: int | None, pid: int) -> bool:
130
+ """Attach the process (and thus its future children) to ``job``."""
131
+ if not IS_WINDOWS or job is None: # pragma: no cover
132
+ return False
133
+ access = PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION
134
+ process_handle = _kernel32.OpenProcess(access, False, pid)
135
+ if not process_handle:
136
+ return False
137
+ try:
138
+ return bool(_kernel32.AssignProcessToJobObject(job, process_handle))
139
+ finally:
140
+ _kernel32.CloseHandle(process_handle)
141
+
142
+
143
+ def close_job(job: int | None) -> None:
144
+ """Close the job handle, which kills any surviving processes in it."""
145
+ if not IS_WINDOWS or job is None: # pragma: no cover
146
+ return
147
+ _kernel32.CloseHandle(job)
148
+
149
+
150
+ def send_ctrl_event(process_group_id: int, sig: int) -> bool:
151
+ """Send a console control event to a process group.
152
+
153
+ ``GenerateConsoleCtrlEvent`` addresses a process *group* id, and it only
154
+ succeeds when the caller and the target group share a console. Returns
155
+ ``True`` when the event was delivered.
156
+
157
+ Only meaningful for a group created with ``CREATE_NEW_PROCESS_GROUP``, whose
158
+ group id is the pid of the process that created it. ``multiprocessing``
159
+ does not create such a group on Windows, so this is not used for the normal
160
+ path -- the console delivers Ctrl+C to the whole group on its own.
161
+ """
162
+ if not IS_WINDOWS: # pragma: no cover
163
+ return False
164
+ event = CTRL_C_EVENT if sig == 2 else CTRL_BREAK_EVENT
165
+ return bool(_kernel32.GenerateConsoleCtrlEvent(event, process_group_id))
166
+
167
+
168
+ def configure_child() -> None:
169
+ """No-op on Windows; console/group handling is done by the parent."""
170
+ return
171
+
172
+
173
+ def kill_tree(process: Any, sig: int) -> None:
174
+ """Force-terminate the child tree.
175
+
176
+ Used only after the child has ignored the graceful signal for
177
+ ``kill_timeout`` seconds. The job object is closed by the caller, which
178
+ kills the whole tree; this additionally kills the direct child so a failure
179
+ to create/assign the job still cleans up.
180
+ """
181
+ if not IS_WINDOWS: # pragma: no cover
182
+ return
183
+ try:
184
+ process.kill()
185
+ except Exception:
186
+ pass
interruptible/core.py ADDED
@@ -0,0 +1,331 @@
1
+ """Core implementation of the transparent Ctrl+C guarantee.
2
+
3
+ Design principle: *the module should be invisible*. A program wrapped with
4
+ ``interruptible.run`` must behave, from the outside, exactly like a program
5
+ without it -- same exit codes, same output, same signal semantics -- with the
6
+ single addition that Ctrl+C is honoured *promptly* even when the target is stuck
7
+ inside a native extension that never returns to the bytecode evaluator.
8
+
9
+ How that is achieved:
10
+
11
+ * The target runs in a child process. The parent does almost nothing except
12
+ wait, so it is never blocked inside a native call and always reacts to signals
13
+ immediately.
14
+ * On SIGINT/SIGTERM the parent forwards the *same* signal to the child. A
15
+ well-behaved program (including plain CPython) turns SIGINT into
16
+ ``KeyboardInterrupt`` and unwinds normally -- indistinguishable from no
17
+ wrapper at all.
18
+ * If the child does not exit within ``kill_timeout`` -- the case this package
19
+ exists for -- the parent escalates to SIGKILL (POSIX, killing the whole
20
+ process group) or job-object termination (Windows, killing the whole tree).
21
+ * The reported exit code follows the shell convention ``128 + signum`` for
22
+ signal-induced death, matching what the user would have seen without the
23
+ wrapper.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import multiprocessing
29
+ import os
30
+ import signal
31
+ import sys
32
+ import time
33
+ from collections.abc import Callable
34
+ from typing import Any
35
+
36
+ from . import _child
37
+
38
+ if sys.platform == 'win32': # pragma: no cover - platform dispatch
39
+ from . import _windows as _platform
40
+ else:
41
+ from . import _posix as _platform
42
+
43
+ __all__ = ['run', 'interruptible']
44
+ #: Set in the child environment to detect that a run is already inside a
45
+ #: child, so nested ``run`` calls execute inline rather than spawning.
46
+ CHILD_ENV_VAR = 'INTERRUPTIBLE_CHILD'
47
+ #: Exit code reported when the ``timeout`` argument expires.
48
+ TIMEOUT_EXIT_CODE = 127
49
+ #: Default number of seconds to wait for the child to exit gracefully before
50
+ #: escalating to a forced kill.
51
+ DEFAULT_KILL_TIMEOUT = 5.0
52
+ #: How often the parent wakes to check for pending signals or deadline expiry.
53
+ #: Small enough to feel instant to a human pressing Ctrl+C.
54
+ POLL_INTERVAL = 0.05
55
+
56
+
57
+ def run(
58
+ target: Callable[..., int | None],
59
+ *args: Any,
60
+ timeout: float | None = None,
61
+ kill_timeout: float = DEFAULT_KILL_TIMEOUT,
62
+ passthrough_signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM),
63
+ inherit_environ: bool = True,
64
+ **kwargs: Any,
65
+ ) -> int:
66
+ """Run ``target(*args, **kwargs)`` with guaranteed interruptibility.
67
+
68
+ Args:
69
+ target: The function to run, typically a program's ``main``.
70
+ *args: Positional arguments forwarded to ``target``.
71
+ timeout: Maximum runtime in seconds, or ``None`` for unlimited. On
72
+ expiry the child is signalled and ``127`` is returned.
73
+ kill_timeout: Seconds to wait after forwarding a signal before forcing
74
+ the child (and its process tree) to die.
75
+ passthrough_signals: Signals that, when received by the parent, are
76
+ forwarded to the child (and escalated if ignored).
77
+ inherit_environ: Whether the child inherits the parent's environment.
78
+ **kwargs: Keyword arguments forwarded to ``target``.
79
+
80
+ Returns:
81
+ The child's exit code. Signal-induced deaths are reported as
82
+ ``128 + signum`` (SIGINT -> 130, SIGTERM -> 143), matching shell and
83
+ CPython conventions. A timeout returns ``127``.
84
+
85
+ Nested calls (i.e. ``run`` executed inside a child) run ``target`` inline.
86
+
87
+ Note:
88
+ On platforms whose start method is ``spawn`` -- Windows, and macOS since
89
+ Python 3.8 -- ``target`` must be **picklable**, which in practice means
90
+ a module-level function. A nested function, lambda, closure, or bound
91
+ method defined inside another function cannot be sent to the child and
92
+ raises ``AttributeError: Can't pickle local object``. Pass a
93
+ module-level function instead::
94
+
95
+ def main():
96
+ ...
97
+
98
+ if __name__ == '__main__':
99
+ sys.exit(interruptible.run(main))
100
+
101
+ """
102
+ if os.environ.get(CHILD_ENV_VAR):
103
+ # Already inside a child process; do not nest. Run the target inline
104
+ # and return its exit code, exactly as CPython would from a plain
105
+ # ``main()`` call.
106
+ return _child.run_inline(target, args, kwargs)
107
+
108
+ # Use the platform's default start method. Windows and macOS (since 3.8)
109
+ # use ``spawn``, which requires the target to be picklable -- see the
110
+ # docstring on ``run``. Forcing ``fork`` on POSIX was rejected: it is
111
+ # unsafe in a process with threads, it makes behaviour differ between
112
+ # platforms, and it hides this requirement from anyone developing on Linux.
113
+ ctx = multiprocessing.get_context()
114
+ process = ctx.Process(
115
+ target=_child.child_main,
116
+ args=(target, args, kwargs, inherit_environ),
117
+ daemon=False,
118
+ )
119
+ job = None
120
+ if sys.platform == 'win32': # pragma: no cover - Windows only
121
+ job = windows_job()
122
+
123
+ process.start()
124
+ if job is not None: # pragma: no cover - Windows only
125
+ windows_assign(job, process.pid)
126
+
127
+ try:
128
+ return wait(process, timeout, kill_timeout, passthrough_signals, job)
129
+ finally:
130
+ # Never leave a zombie or a live grandchild behind.
131
+ cleanup(process, job)
132
+
133
+
134
+ def wait(
135
+ process: Any,
136
+ timeout: float | None,
137
+ kill_timeout: float,
138
+ passthrough_signals: tuple[int, ...],
139
+ job: Any,
140
+ ) -> int:
141
+ """Wait for the child, forwarding signals, with timeout and escalation."""
142
+ state: dict[str, Any] = {'pending_signal': None}
143
+
144
+ def handler(signum: int, _frame: Any) -> None:
145
+ state['pending_signal'] = signum
146
+
147
+ installed: list[tuple[int, Any]] = []
148
+ for sig in passthrough_signals:
149
+ try:
150
+ previous = signal.signal(sig, handler)
151
+ except (OSError, ValueError):
152
+ # Signals that cannot be installed in this context (e.g. SIGTERM is
153
+ # fine, but a non-main thread or an unsupported signum is not).
154
+ continue
155
+ installed.append((sig, previous))
156
+
157
+ try:
158
+ # Wait for the child. Poll in short slices so that a signal arriving
159
+ # mid-wait is noticed promptly rather than only when a large timeout
160
+ # expires. ``deadline`` is None for an unlimited wait.
161
+ deadline = None if timeout is None else time.monotonic() + timeout
162
+ while True:
163
+ if join(process, POLL_INTERVAL):
164
+ return process.exitcode & 0xFF
165
+ if state['pending_signal'] is not None:
166
+ break
167
+ if deadline is not None and time.monotonic() >= deadline:
168
+ break
169
+
170
+ pending = state['pending_signal']
171
+ if pending is None:
172
+ # Timed out with no signal: treat as a timeout.
173
+ escalate(process, signal.SIGTERM, kill_timeout, job)
174
+ return TIMEOUT_EXIT_CODE
175
+
176
+ # A signal arrived: forward it, then wait for a graceful exit.
177
+ forward(process, pending, job)
178
+ if join(process, kill_timeout):
179
+ return signal_exit_code(process, pending)
180
+
181
+ # The child ignored the forwarded signal -- force it.
182
+ force_kill(process, job)
183
+ join(process, kill_timeout)
184
+ return signal_exit_code(process, pending)
185
+ finally:
186
+ for sig, previous in installed:
187
+ try:
188
+ signal.signal(sig, previous)
189
+ except (OSError, ValueError): # pragma: no cover - defensive
190
+ pass
191
+
192
+
193
+ def join(process: Any, timeout: float | None) -> bool:
194
+ """Join the child, returning ``True`` if it exited."""
195
+ process.join(timeout)
196
+ return not process.is_alive()
197
+
198
+
199
+ def forward(process: Any, signum: int, job: Any) -> None:
200
+ """React to ``signum`` by making the child stop.
201
+
202
+ On Windows a child spawned by ``multiprocessing`` is created in the
203
+ parent's console process group, so a console Ctrl+C is delivered to the
204
+ child *by the console itself*, at the same moment the parent sees it.
205
+ Forwarding is therefore unnecessary for the common case and harmful when
206
+ attempted incorrectly: ``GenerateConsoleCtrlEvent`` takes a process *group*
207
+ id, not a pid, and an invalid id makes the call fail.
208
+
209
+ So the child is given the chance to handle the interrupt on its own, which
210
+ is what a well-behaved program does (raising ``KeyboardInterrupt``). If it
211
+ is still alive when the caller's ``kill_timeout`` expires, the caller
212
+ force-kills the process tree.
213
+
214
+ The one case needing an explicit event is a child that is *not* in our
215
+ console group, which is what ``GenerateConsoleCtrlEvent`` is for; it is
216
+ attempted with the child's process group id, and a failure simply leaves
217
+ the escalation path to clean up.
218
+ """
219
+ if sys.platform == 'win32': # pragma: no cover - Windows only
220
+ # Nothing to forward: see the docstring above. The child has already
221
+ # been signalled by the console if it shares our console group, and the
222
+ # caller escalates if it is still alive after ``kill_timeout``.
223
+ return
224
+
225
+ _platform.kill_tree(process, signum)
226
+
227
+
228
+ def escalate(process: Any, signum: int, kill_timeout: float, job: Any) -> None:
229
+ """Forward ``signum`` and force-kill if the child lingers."""
230
+ forward(process, signum, job)
231
+ if not join(process, kill_timeout):
232
+ force_kill(process, job)
233
+ join(process, kill_timeout)
234
+
235
+
236
+ def force_kill(process: Any, job: Any) -> None:
237
+ """Unconditionally terminate the child and its descendants."""
238
+ if sys.platform == 'win32': # pragma: no cover - Windows only
239
+ windows_close_job(job)
240
+ _platform.kill_tree(process, signal.SIGTERM)
241
+ return
242
+ _platform.kill_tree(process, signal.SIGKILL)
243
+
244
+
245
+ def signal_exit_code(process: Any, signum: int) -> int:
246
+ """Return the exit code to report after a signal-triggered shutdown.
247
+
248
+ Transparency means reporting what a plain program would have reported: the
249
+ signal *the user sent* (so Ctrl+C always yields 130), regardless of which
250
+ signal happened to actually kill the child during cleanup.
251
+ """
252
+ return 128 + int(signum)
253
+
254
+
255
+ def cleanup(process: Any, job: Any) -> None:
256
+ """Ensure no child or grandchild survives this call."""
257
+ if process.is_alive(): # pragma: no cover - defensive
258
+ force_kill(process, job)
259
+ join(process, DEFAULT_KILL_TIMEOUT)
260
+ if sys.platform == 'win32': # pragma: no cover - Windows only
261
+ windows_close_job(job)
262
+
263
+
264
+ def interruptible(
265
+ *dargs: Any,
266
+ timeout: float | None = None,
267
+ kill_timeout: float = DEFAULT_KILL_TIMEOUT,
268
+ passthrough_signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM),
269
+ inherit_environ: bool = True,
270
+ **dkwargs: Any,
271
+ ) -> Callable[..., Any]:
272
+ """Decorator form of :func:`run`.
273
+
274
+ Can be used bare (::
275
+
276
+ @interruptible
277
+ def main(): ...
278
+
279
+ ) or with configuration::
280
+
281
+ @interruptible(timeout=60)
282
+ def main(): ...
283
+
284
+ The decorated function returns the child's exit code as an ``int``.
285
+ """
286
+
287
+ def decorate(func: Callable[..., Any]) -> Callable[..., Any]:
288
+ def wrapper(*args: Any, **kwargs: Any) -> int:
289
+ return run(
290
+ func,
291
+ *args,
292
+ timeout=timeout,
293
+ kill_timeout=kill_timeout,
294
+ passthrough_signals=passthrough_signals,
295
+ inherit_environ=inherit_environ,
296
+ **kwargs,
297
+ )
298
+
299
+ wrapper.__wrapped__ = func # type: ignore[attr-defined]
300
+ wrapper.__name__ = getattr(func, '__name__', 'wrapper')
301
+ wrapper.__doc__ = getattr(func, '__doc__', None)
302
+ wrapper.__module__ = getattr(func, '__module__', wrapper.__module__)
303
+ return wrapper
304
+
305
+ if len(dargs) == 1 and callable(dargs[0]) and not dkwargs:
306
+ # Used bare: @interruptible
307
+ return decorate(dargs[0])
308
+ return decorate
309
+
310
+
311
+ # Imported here rather than at module import time so that a missing/broken
312
+ # ctypes binding cannot break the package on Windows until it is actually used.
313
+
314
+
315
+ def windows_job() -> Any: # pragma: no cover - Windows only
316
+ from . import _windows
317
+
318
+ return _windows.create_job()
319
+
320
+
321
+ def windows_assign(job: Any, pid: int | None) -> None: # pragma: no cover
322
+ from . import _windows
323
+
324
+ if pid is not None:
325
+ _windows.assign_process_to_job(job, pid)
326
+
327
+
328
+ def windows_close_job(job: Any) -> None: # pragma: no cover - Windows only
329
+ from . import _windows
330
+
331
+ _windows.close_job(job)
@@ -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,11 @@
1
+ interruptible/__init__.py,sha256=IONX1mMHo5ghuoeTdERIkd6b-PTkDSRWncbewvSQ4d0,1009
2
+ interruptible/_child.py,sha256=Tl5YAdKiOBKKQsCDySDIerMAUl53IBIan42nH2wZc1U,4501
3
+ interruptible/_posix.py,sha256=NLcbdMCPFAfDEGy1ONwIQEqnpRHjmqJfkhdKUxtNCyM,1127
4
+ interruptible/_version.py,sha256=JAAyU3al4wmBf3BF-Umm1J_GrCIT-yS_yWEGHqKRVHc,520
5
+ interruptible/_windows.py,sha256=0TBy8kZQvzcOjdA6PS1Y4_OItIWre0kWiY6oWfij3jY,7092
6
+ interruptible/core.py,sha256=uqG2PPHEXmgAs8zWk9TaTMtiNcW_BU6j_fqv506AwxA,12573
7
+ interruptible-1.0.0.dist-info/licenses/LICENSE,sha256=7KeTyrmHpDRxetbz5o2eMV6Ss4rcf2A9mG_UqTgZbf0,1070
8
+ interruptible-1.0.0.dist-info/METADATA,sha256=eowfPcoO3BfazrskKVUPUxMtAUUvFtUt0KkhoOvQ15A,7446
9
+ interruptible-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ interruptible-1.0.0.dist-info/top_level.txt,sha256=rNthht8IykZLVulnlj_RWLcf4BSvoi2EsE5sKYZXlEA,14
11
+ interruptible-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ interruptible