Metadata-Version: 2.4
Name: interruptible
Version: 1.0.0
Summary: Guaranteed, transparent Ctrl+C for Python programs
Author-email: David Manthey <manthey@orbitals.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/manthey/interruptible
Project-URL: Repository, https://github.com/manthey/interruptible
Project-URL: Issues, https://github.com/manthey/interruptible/issues
Keywords: ctrl-c,sigint,signal,interrupt,keyboardinterrupt,subprocess,timeout
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Operating System Kernels
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# interruptible

**Guaranteed, transparent Ctrl+C for Python programs.**

`interruptible` makes Ctrl+C work *promptly* even when your program is blocked
inside a native C/Rust extension that never returns to the Python bytecode
evaluator -- for example an HTTP request through a library that does not poll
for interrupts, a database driver, or a long-running computation in NumPy.

```python
import sys
import interruptible

def main():
    # Anything at all here, including blocking C calls.
    ...
    return 0

if __name__ == "__main__":
    sys.exit(interruptible.run(main))
```

Press Ctrl+C and the program stops, now.

## Why this is needed

Python delivers signals by setting a flag that the interpreter checks *between
bytecodes*.  If your code is blocked inside a C extension, the interpreter is
never reached, so `KeyboardInterrupt` is not raised until the call returns --
which may be never.  On Windows it is worse: a blocking call is not interrupted
at all.

There is no way to fix this from inside the blocked process.  So `interruptible`
runs your `main` in a child process.  The parent does nothing but wait, so it is
always able to react to Ctrl+C immediately; on interrupt it forwards the signal
to the child and, if the child is one of the ill-behaved ones, forcibly
terminates the child's entire process tree.

## Transparency

The design goal is that a wrapped program is indistinguishable from an unwrapped
one, apart from interrupts always working:

| Behaviour | `interruptible` result |
| --- | --- |
| `main()` returns `0` | exit code `0` |
| `main()` returns `N` | exit code `N` |
| `main()` raises | traceback on stderr, exit code `1` |
| `sys.exit(N)` | exit code `N` |
| Ctrl+C, child exits on its own | exit code `130` (`128 + SIGINT`) |
| `SIGTERM` | exit code `143` (`128 + SIGTERM`) |
| Ctrl+C, child ignores it | child tree killed after `kill_timeout`; exit code `130` |
| `timeout=` expires | child signalled, exit code `127` |
| stdout/stderr | forwarded live to the parent's streams |

The signal the *user* sent determines the exit code, so Ctrl+C always reports
`130`, exactly as an unwrapped program would.

## Install

```sh
pip install interruptible
```

No runtime dependencies.  Python 3.10+.

## Usage

### Function

```python
sys.exit(interruptible.run(main, timeout=60, kill_timeout=5.0))
```

### Decorator

```python
@interruptible.interruptible(timeout=300)
def main():
    ...

if __name__ == "__main__":
    sys.exit(main())
```

### API

```python
def run(
    target: Callable[..., int | None],
    *args,
    timeout: float | None = None,
    kill_timeout: float = 5.0,
    passthrough_signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM),
    inherit_environ: bool = True,
    **kwargs,
) -> int: ...
```

* `target` -- the function to run.  On spawn platforms (Windows, macOS) it must
  be picklable, which means a module-level function in a real file; see the FAQ.
* `timeout` -- maximum runtime in seconds (`None` = unlimited).  On expiry the
  child is signalled and `127` is returned.
* `kill_timeout` -- seconds to wait for a graceful exit after forwarding a
  signal before force-killing the child's process tree.
* `passthrough_signals` -- signals received by the parent that are forwarded to
  the child.
* `inherit_environ` -- whether the child inherits the environment.

## FAQ

**Why a subprocess?**  Because there is no other way to be responsive while the
main thread is stuck inside native code.  A thread or an asyncio task cannot
help: they cannot preempt a blocked C call either.

**Doesn't `signal.set_wakeup_fd` / `faulthandler` solve this?**  Those change how
the *interpreter* notices signals; they do not make a blocked native call
return.

**What about child processes I spawn myself?**  On POSIX the child runs in its
own process group, and the whole group is signalled during cleanup.  On Windows
the child is attached to a Job Object configured to kill all member processes
when the job closes, so grandchildren die too.

**Nested use.** If `run()` is called from inside a child (detected via the
`INTERRUPTIBLE_CHILD` environment variable), the target runs inline instead of
spawning another process.

**Why did I get `Can't pickle local object`?**  On Windows, and on macOS since
Python 3.8, the child is started with `spawn`, which sends the target to the new
interpreter by pickling it *by module and name*.  Two things therefore cannot be
used as a target:

* a nested function, lambda, closure, or bound method defined inside another
  function;
* a function defined in a `python -c` string or an interactive session, because
  its `__main__` has no importable name.

Both fail with `AttributeError` once the child tries to unpickle the target.  Use
a module-level function in a real file:

```python
# do this
def main():
    ...

if __name__ == '__main__':
    sys.exit(interruptible.run(main))


# not this -- main cannot be pickled
if __name__ == '__main__':
    def main():
        ...

    sys.exit(interruptible.run(main))
```

## Platform notes

`run()` uses the platform's default `multiprocessing` start method.  That is
`spawn` on Windows and macOS and `fork` on Linux, and the difference matters:
`spawn` requires a picklable (module-level) target, while `fork` accepts
anything.  Forcing `fork` on Linux was deliberately rejected -- it is unsafe in
a process with threads and it would hide this requirement from anyone
developing on Linux.  To check your code under the stricter `spawn` rules on
Linux, set the start method before calling `run()`:

```python
import multiprocessing

multiprocessing.set_start_method('spawn')
```

* **Spawn platforms (Windows, macOS)** -- the target must be picklable.
* **POSIX** -- the parent forwards the exact signal it received (so the child
  sees a normal `KeyboardInterrupt`), then `SIGKILL`s the process group if the
  child has not exited within `kill_timeout`.
* **Windows** -- a child started by `multiprocessing` shares the parent's
  console process group, so the console delivers Ctrl+C to it directly; no
  explicit forwarding is needed.  The Job Object guarantees the whole tree is
  cleaned up if the child ignores it.

## License

MIT
