timeout-dead 0.1.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,6 @@
1
+ """timeout-dead — lightweight command timeout utility."""
2
+
3
+ from timeout_dead.cli import main
4
+
5
+
6
+ __all__ = ["main"]
@@ -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 = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
timeout_dead/cli.py ADDED
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """Легковесная утилита для запуска команд с таймаутом."""
4
+
5
+ import argparse
6
+ import os
7
+ import shutil
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import time
13
+
14
+
15
+ # MARK: Constants
16
+ # ------------------------------------------------
17
+
18
+
19
+ class _Const:
20
+ DEFAULT_TIMEOUT_S: int = 60
21
+ GRACE_PERIOD_S: float = 1.0
22
+ HEADER_SEPARATOR: str = "-" * 60
23
+
24
+ MSG_NO_COMMAND: str = "Error: no command specified"
25
+ MSG_BASH_NOT_FOUND: str = "bash not found in PATH — Git Bash is required"
26
+ MSG_TIMEOUT: str = "Timeout exceeded {} seconds"
27
+ MSG_EXEC_ERROR: str = "Execution error: {}"
28
+
29
+ SIGNAL_NAMES: tuple[str, ...] = ("TERM", "KILL", "HUP", "INT")
30
+
31
+ SIGNAL_MAP: dict[str, int] = {
32
+ "TERM": signal.SIGTERM,
33
+ "KILL": getattr(signal, "SIGKILL", signal.SIGTERM),
34
+ "HUP": getattr(signal, "SIGHUP", signal.SIGTERM),
35
+ "INT": signal.SIGINT,
36
+ }
37
+
38
+
39
+ # MARK: Private Helpers
40
+ # ------------------------------------------------
41
+
42
+
43
+ def _is_windows() -> bool:
44
+ return os.name == "nt"
45
+
46
+
47
+ # ------------------------------------------------
48
+
49
+
50
+ def _find_bash() -> str:
51
+ """
52
+ Ищет bash в PATH.
53
+
54
+ Returns:
55
+ str: путь к исполняемому файлу bash
56
+
57
+ Raises:
58
+ SystemExit: если bash не найден
59
+ """
60
+
61
+ bash_path = shutil.which("bash")
62
+
63
+ if bash_path is None:
64
+ print(f"\n{_Const.MSG_BASH_NOT_FOUND}", file=sys.stderr)
65
+ sys.exit(1)
66
+
67
+ return bash_path
68
+
69
+
70
+ # MARK: Process termination
71
+ # ------------------------------------------------
72
+
73
+
74
+ def _terminate_process(
75
+ process: subprocess.Popen[bytes] | subprocess.Popen[str],
76
+ *,
77
+ force: bool = False,
78
+ signal_num: int = signal.SIGTERM,
79
+ ) -> None:
80
+ """Завершает процесс — мягко (выбранным сигналом) или жёстко."""
81
+
82
+ if process.poll() is not None:
83
+ return
84
+
85
+ try:
86
+ if _is_windows():
87
+ if force:
88
+ process.kill()
89
+
90
+ elif signal_num == signal.SIGINT:
91
+ process.send_signal(signal.SIGINT)
92
+
93
+ else:
94
+ ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", signal.SIGTERM)
95
+ process.send_signal(ctrl_break)
96
+
97
+ else:
98
+ pgid = os.getpgid(process.pid) # pyright: ignore[reportAttributeAccessIssue]
99
+
100
+ if force:
101
+ os.killpg(pgid, signal.SIGKILL) # pyright: ignore[reportAttributeAccessIssue]
102
+
103
+ else:
104
+ os.killpg(pgid, signal_num) # pyright: ignore[reportAttributeAccessIssue]
105
+
106
+ except (ProcessLookupError, OSError):
107
+ pass
108
+
109
+
110
+ # ------------------------------------------------
111
+
112
+
113
+ def _kill_with_timeout(
114
+ process: subprocess.Popen[bytes] | subprocess.Popen[str],
115
+ timeout: int,
116
+ signal_num: int = signal.SIGTERM,
117
+ ) -> None:
118
+ """Убивает процесс по таймауту с двухэтапной логикой."""
119
+
120
+ if process.poll() is not None:
121
+ return
122
+
123
+ _terminate_process(process, signal_num=signal_num)
124
+ time.sleep(_Const.GRACE_PERIOD_S)
125
+
126
+ if process.poll() is None:
127
+ _terminate_process(process, force=True)
128
+
129
+ print(f"\n{_Const.MSG_TIMEOUT.format(timeout)}", file=sys.stderr)
130
+
131
+
132
+ # MARK: Public API
133
+ # ------------------------------------------------
134
+
135
+
136
+ def run_command(
137
+ command_string: str,
138
+ timeout: int = _Const.DEFAULT_TIMEOUT_S,
139
+ signal_name: str = "TERM",
140
+ no_output: bool = False,
141
+ ) -> int:
142
+ """
143
+ Запускает команду с таймаутом через bash.
144
+
145
+ Args:
146
+ command_string (str): команда для выполнения
147
+ timeout (int): таймаут в секундах
148
+ signal_name (str): имя сигнала для мягкого завершения
149
+ no_output (bool): подавлять ли обычный вывод
150
+
151
+ Returns:
152
+ int: код возврата процесса (-1 при ошибке запуска)
153
+ """
154
+
155
+ signal_num = _Const.SIGNAL_MAP.get(signal_name, signal.SIGTERM)
156
+ process: subprocess.Popen[bytes] | subprocess.Popen[str] | None = None
157
+ timer: threading.Timer | None = None
158
+
159
+ try:
160
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if _is_windows() else 0
161
+ start_new_session = not _is_windows()
162
+
163
+ process = subprocess.Popen(
164
+ [_find_bash(), "-c", command_string],
165
+ stdout=subprocess.PIPE,
166
+ stderr=subprocess.PIPE,
167
+ text=True,
168
+ creationflags=creationflags,
169
+ start_new_session=start_new_session,
170
+ )
171
+
172
+ timer = threading.Timer(
173
+ timeout,
174
+ _kill_with_timeout,
175
+ args=(process, timeout, signal_num),
176
+ )
177
+ timer.start()
178
+
179
+ stdout, stderr = process.communicate()
180
+ timer.cancel()
181
+
182
+ if not no_output:
183
+ if stdout:
184
+ print(stdout, end="")
185
+ if stderr:
186
+ print(stderr, end="", file=sys.stderr)
187
+
188
+ return process.returncode
189
+
190
+ except Exception as e:
191
+ if timer:
192
+ timer.cancel()
193
+ if process and process.poll() is None:
194
+ try:
195
+ _terminate_process(process, force=True)
196
+
197
+ except Exception:
198
+ pass
199
+
200
+ print(f"{_Const.MSG_EXEC_ERROR.format(e)}", file=sys.stderr)
201
+
202
+ return -1
203
+
204
+
205
+ # ------------------------------------------------
206
+
207
+
208
+ def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
209
+ """Разбирает аргументы командной строки."""
210
+
211
+ parser = argparse.ArgumentParser(
212
+ description="Lightweight command timeout utility.",
213
+ formatter_class=argparse.RawDescriptionHelpFormatter,
214
+ )
215
+
216
+ parser.add_argument(
217
+ "--sec",
218
+ type=int,
219
+ default=_Const.DEFAULT_TIMEOUT_S,
220
+ help=f"timeout in seconds (default: {_Const.DEFAULT_TIMEOUT_S})",
221
+ metavar="SECONDS",
222
+ )
223
+
224
+ parser.add_argument(
225
+ "--signal",
226
+ type=str.upper,
227
+ default="TERM",
228
+ choices=list(_Const.SIGNAL_NAMES),
229
+ help="signal to send on timeout (default: TERM)",
230
+ metavar="SIGNAL",
231
+ )
232
+
233
+ parser.add_argument(
234
+ "--no-output",
235
+ action="store_true",
236
+ default=False,
237
+ help="suppress normal output (stdout, stderr, header, footer)",
238
+ )
239
+
240
+ parser.add_argument(
241
+ "command",
242
+ nargs=argparse.REMAINDER,
243
+ help="command to execute",
244
+ metavar="COMMAND",
245
+ )
246
+
247
+ return parser.parse_args(argv)
248
+
249
+
250
+ # ------------------------------------------------
251
+
252
+
253
+ def print_header(command: str, timeout: int) -> None:
254
+ """Выводит заголовок выполнения."""
255
+
256
+ print(f"Running: {command}")
257
+ print(f"Timeout: {timeout} seconds")
258
+ print(_Const.HEADER_SEPARATOR)
259
+
260
+
261
+ # ------------------------------------------------
262
+
263
+
264
+ def print_footer(return_code: int) -> None:
265
+ """Выводит футер выполнения."""
266
+
267
+ print(_Const.HEADER_SEPARATOR)
268
+ print(f"Exit code: {return_code}")
269
+
270
+
271
+ # ------------------------------------------------
272
+
273
+
274
+ def main(argv: list[str] | None = None) -> None:
275
+ """Главная точка входа."""
276
+
277
+ args = parse_arguments(argv)
278
+
279
+ if not args.command:
280
+ print(f"{_Const.MSG_NO_COMMAND}", file=sys.stderr)
281
+ sys.exit(1)
282
+
283
+ command_string = " ".join(args.command)
284
+
285
+ if not args.no_output:
286
+ print_header(command_string, args.sec)
287
+
288
+ return_code = run_command(
289
+ command_string,
290
+ timeout=args.sec,
291
+ signal_name=args.signal,
292
+ no_output=args.no_output,
293
+ )
294
+
295
+ if not args.no_output:
296
+ print_footer(return_code)
297
+
298
+ sys.exit(return_code)
299
+
300
+
301
+ if __name__ == "__main__":
302
+ main()
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: timeout-dead
3
+ Version: 0.1.0
4
+ Summary: Lightweight command timeout utility with zero runtime dependencies
5
+ Author-email: Dmitry Krivoruchko <umbrella.leaf.for.work@gmail.com>
6
+ License-Expression: Unlicense
7
+ Project-URL: Repository, https://github.com/UmbrellaLeaf5/timeout-dead
8
+ Project-URL: Issues, https://github.com/UmbrellaLeaf5/timeout-dead/issues
9
+ Keywords: timeout,cli,command,process,kill,signal,developer-tools
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: System :: System Shells
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == "dev"
24
+ Requires-Dist: pyright>=1.1.409; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # timeout-dead
28
+
29
+ [![PyPI version](https://img.shields.io/pypi/v/timeout-dead)](https://pypi.org/project/timeout-dead/)
30
+ [![Python](https://img.shields.io/badge/Python-3.10+-yellow?logo=python)](https://python.org)
31
+ [![License](https://img.shields.io/badge/license-Unlicense-blue.svg)](LICENSE)
32
+ [![Tests](https://github.com/UmbrellaLeaf5/timeout-dead/actions/workflows/tests.yml/badge.svg)](https://github.com/UmbrellaLeaf5/timeout-dead/actions/workflows/tests.yml)
33
+ [![Ruff](https://github.com/UmbrellaLeaf5/timeout-dead/actions/workflows/ruff.yml/badge.svg)](https://github.com/UmbrellaLeaf5/timeout-dead/actions/workflows/ruff.yml)
34
+ [![Pyright](https://github.com/UmbrellaLeaf5/timeout-dead/actions/workflows/pyright.yml/badge.svg)](https://github.com/UmbrellaLeaf5/timeout-dead/actions/workflows/pyright.yml)
35
+
36
+ <img align="right" height="256" src="icon.png"/>
37
+
38
+ **Lightweight command timeout utility with zero runtime dependencies.**
39
+ Runs any shell command with a configurable time limit and termination signal.
40
+ If the command exceeds the timeout, `timeout-dead` sends the chosen signal,
41
+ waits a 1-second grace period, then force-kills the process.
42
+ Works on Linux, macOS, and Windows (Git Bash / WSL).
43
+
44
+ ## Installation
45
+
46
+ ```bash
47
+ pip install timeout-dead
48
+ ```
49
+
50
+ Or via uv:
51
+
52
+ ```bash
53
+ uv tool install timeout-dead
54
+ ```
55
+
56
+ Requires Python 3.10 or later. Zero runtime dependencies — pure Python standard library.
57
+
58
+ ## Quick start
59
+
60
+ ```bash
61
+ # Run a command with default 60s timeout
62
+ timeout-dead "python -c 'print(42)'"
63
+
64
+ # Short alias also works
65
+ time-d "echo hello"
66
+
67
+ # Specify a custom timeout
68
+ timeout-dead --sec 120 "npm run build"
69
+
70
+ # Use SIGINT instead of default SIGTERM
71
+ timeout-dead --signal INT --sec 30 "long-running-server"
72
+
73
+ # Run silently — suppress all normal output
74
+ timeout-dead --no-output "curl -s https://example.com"
75
+ ```
76
+
77
+ ## Usage
78
+
79
+ ```
80
+ usage: timeout-dead [-h] [--sec SECONDS] [--signal SIGNAL] [--no-output] COMMAND ...
81
+
82
+ Lightweight command timeout utility.
83
+
84
+ positional arguments:
85
+ COMMAND command to execute
86
+
87
+ options:
88
+ -h, --help show this help message and exit
89
+ --sec SECONDS timeout in seconds (default: 60)
90
+ --signal SIGNAL signal to send on timeout (TERM, KILL, HUP, INT)
91
+ --no-output suppress normal output (stdout, stderr, header, footer)
92
+ ```
93
+
94
+ ## How it works
95
+
96
+ 1. `timeout-dead` starts the command in a new process group (Unix) / console group (Windows).
97
+ 2. A background timer waits for the specified timeout.
98
+ 3. If the command finishes in time, its output and exit code are forwarded.
99
+ 4. If the timeout expires:
100
+ - The chosen signal is sent to the process group.
101
+ - After 1 second, if the process is still running, `SIGKILL` (Unix) or `process.kill()` (Windows) is sent.
102
+ - A `Timeout exceeded` message is printed to stderr.
103
+
104
+ ## Signal reference
105
+
106
+ | Signal | Unix | Windows |
107
+ |--------|------|---------|
108
+ | `TERM` | `SIGTERM` (15) — terminate gracefully | `CTRL_BREAK_EVENT` — console break |
109
+ | `KILL` | `SIGKILL` (9) — force kill | Falls back to `TerminateProcess` |
110
+ | `HUP` | `SIGHUP` (1) — hangup | Falls back to `TerminateProcess` |
111
+ | `INT` | `SIGINT` (2) — interrupt (Ctrl+C) | `CTRL_C_EVENT` — console interrupt |
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ git clone https://github.com/UmbrellaLeaf5/timeout-dead
117
+ cd timeout-dead
118
+ uv sync --extra dev
119
+ uv run pytest tests/ -v
120
+ uv run ruff check src/timeout_dead/ tests/
121
+ uv run ruff format --check .
122
+ uv run pyright src/timeout_dead/
123
+ ```
124
+
125
+ ## License
126
+
127
+ [Unlicense](LICENSE) — public domain.
128
+
129
+ <a href="https://www.flaticon.com/free-icons/timeout" title="timeout icons">Timeout icons created by Those Icons - Flaticon</a>
@@ -0,0 +1,9 @@
1
+ timeout_dead/__init__.py,sha256=dmORBMW91c-w2ZmNOTNP1scTT7Sj_I4Uiirv4UswMGM,116
2
+ timeout_dead/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
3
+ timeout_dead/cli.py,sha256=4Ox74_htWGjD9S6c2cbfdlV7LS8nAzQcP44Fyz6lsZs,7180
4
+ timeout_dead-0.1.0.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
5
+ timeout_dead-0.1.0.dist-info/METADATA,sha256=O-IYUmoKvSiEVNvrJVC8TrgTLNQas7qfoBbmXx5A8uY,4705
6
+ timeout_dead-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ timeout_dead-0.1.0.dist-info/entry_points.txt,sha256=pYdLZaqgi6T6EE6woFSUVY2_tlVeNmZL-ciU5zm0aYg,123
8
+ timeout_dead-0.1.0.dist-info/top_level.txt,sha256=86YL6ohLAQPyhX9lDRLqOVrhBozouE8w97PgfFRMaSc,13
9
+ timeout_dead-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ time-d = timeout_dead.cli:main
3
+ timeout-dead = timeout_dead.cli:main
4
+ timeout_dead = timeout_dead.cli:main
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
@@ -0,0 +1 @@
1
+ timeout_dead