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.
Files changed (30) hide show
  1. interruptible-1.0.0/.gitignore +25 -0
  2. interruptible-1.0.0/LICENSE +21 -0
  3. interruptible-1.0.0/MANIFEST.in +10 -0
  4. interruptible-1.0.0/PKG-INFO +214 -0
  5. interruptible-1.0.0/README.md +186 -0
  6. interruptible-1.0.0/examples/manual_ctrlc.py +58 -0
  7. interruptible-1.0.0/pyproject.toml +93 -0
  8. interruptible-1.0.0/setup.cfg +4 -0
  9. interruptible-1.0.0/src/interruptible/__init__.py +35 -0
  10. interruptible-1.0.0/src/interruptible/_child.py +118 -0
  11. interruptible-1.0.0/src/interruptible/_posix.py +38 -0
  12. interruptible-1.0.0/src/interruptible/_version.py +24 -0
  13. interruptible-1.0.0/src/interruptible/_windows.py +186 -0
  14. interruptible-1.0.0/src/interruptible/core.py +331 -0
  15. interruptible-1.0.0/src/interruptible.egg-info/PKG-INFO +214 -0
  16. interruptible-1.0.0/src/interruptible.egg-info/SOURCES.txt +28 -0
  17. interruptible-1.0.0/src/interruptible.egg-info/dependency_links.txt +1 -0
  18. interruptible-1.0.0/src/interruptible.egg-info/scm_file_list.json +32 -0
  19. interruptible-1.0.0/src/interruptible.egg-info/scm_version.json +8 -0
  20. interruptible-1.0.0/src/interruptible.egg-info/top_level.txt +1 -0
  21. interruptible-1.0.0/tests/__init__.py +0 -0
  22. interruptible-1.0.0/tests/conftest.py +24 -0
  23. interruptible-1.0.0/tests/scripts/__init__.py +83 -0
  24. interruptible-1.0.0/tests/scripts/ignore_signals.py +23 -0
  25. interruptible-1.0.0/tests/scripts/sleep_forever.py +25 -0
  26. interruptible-1.0.0/tests/scripts/tree_forever.py +48 -0
  27. interruptible-1.0.0/tests/targets.py +111 -0
  28. interruptible-1.0.0/tests/test_interruptible.py +127 -0
  29. interruptible-1.0.0/tests/test_signals.py +72 -0
  30. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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')