timeout-dead 0.1.1__tar.gz → 0.2.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 (24) hide show
  1. timeout_dead-0.2.0/.vscode/settings.json +8 -0
  2. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/AGENTS.md +25 -13
  3. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/PKG-INFO +41 -8
  4. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/README.md +40 -7
  5. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/src/timeout_dead/_version.py +3 -3
  6. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/src/timeout_dead/cli.py +49 -25
  7. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/src/timeout_dead.egg-info/SOURCES.txt +1 -0
  8. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/tests/conftest.py +1 -1
  9. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/tests/test_cli.py +92 -7
  10. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/.github/workflows/publish.yml +0 -0
  11. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/.github/workflows/pyright.yml +0 -0
  12. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/.github/workflows/ruff.yml +0 -0
  13. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/.github/workflows/tests.yml +0 -0
  14. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/.gitignore +0 -0
  15. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/CODE-STYLE.md +0 -0
  16. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/CONTRIBUTING.md +0 -0
  17. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/LICENSE +0 -0
  18. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/MANIFEST.in +0 -0
  19. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/icon.png +0 -0
  20. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/pyproject.toml +0 -0
  21. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/ruff.toml +0 -0
  22. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/setup.cfg +0 -0
  23. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/src/timeout_dead/__init__.py +0 -0
  24. {timeout_dead-0.1.1 → timeout_dead-0.2.0}/uv.lock +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "cSpell.words": [
3
+ "creationflags",
4
+ "getpgid",
5
+ "killpg",
6
+ "pgid"
7
+ ]
8
+ }
@@ -37,20 +37,32 @@ If the user asks "what should the commit message be?" — **suggest a message bu
37
37
 
38
38
  ## Workflow & Verification Commands
39
39
 
40
+ ### Setup
41
+
42
+ ```bash
43
+ uv tool install timeout-dead
44
+ ```
45
+
46
+ Если уже установлена — убедись что работает:
47
+
48
+ ```bash
49
+ time-d -h
50
+ ```
51
+
40
52
  ### Time limit (HARD REQUIREMENT)
41
53
 
42
54
  **Every bash command MUST complete within 60 seconds.** All commands must be invoked through the timeout wrapper:
43
55
 
44
56
  ```bash
45
- timeout-dead --sec 60 "<your command>"
57
+ time-d --sec 60 "<your command>"
46
58
  ```
47
59
 
48
60
  Long-running daemons must use `nohup ... >/dev/null 2>&1 &` so the wrapper returns immediately.
49
61
 
50
- ### Setup
62
+ ### Install dependencies
51
63
 
52
64
  ```bash
53
- timeout-dead --sec 60 "uv sync"
65
+ time-d --sec 60 "uv sync"
54
66
  ```
55
67
 
56
68
  ### Verify after changes
@@ -58,16 +70,16 @@ timeout-dead --sec 60 "uv sync"
58
70
  Run **all** checks in this order — treat errors as blockers:
59
71
 
60
72
  ```bash
61
- timeout-dead --sec 60 "ruff check ."
62
- timeout-dead --sec 60 "ruff format --check ."
63
- timeout-dead --sec 60 "pyright ."
64
- timeout-dead --sec 60 "python -m pytest tests/ -v"
73
+ time-d --sec 60 "ruff check ."
74
+ time-d --sec 60 "ruff format --check ."
75
+ time-d --sec 60 "pyright ."
76
+ time-d --sec 60 "python -m pytest tests/ -v"
65
77
  ```
66
78
 
67
79
  ### Fix formatting & imports
68
80
 
69
81
  ```bash
70
- timeout-dead --sec 60 "ruff check --fix . && ruff format ."
82
+ time-d --sec 60 "ruff check --fix . && ruff format ."
71
83
  ```
72
84
 
73
85
  **LSP is mandatory.** Configure `pyright-langserver` and `ruff server` in your editor. After every change, confirm lint, format, and type-check show **0 errors**. `ruff format` is the single source of truth for formatting — no `black`, no `isort`.
@@ -75,7 +87,7 @@ timeout-dead --sec 60 "ruff check --fix . && ruff format ."
75
87
  ### Run a single test
76
88
 
77
89
  ```bash
78
- timeout-dead --sec 60 "python -m pytest tests/test_file.py::test_name -v"
90
+ time-d --sec 60 "python -m pytest tests/test_file.py::test_name -v"
79
91
  ```
80
92
 
81
93
  ### Mandatory testing
@@ -170,19 +182,19 @@ class ConflictError(BaseError):
170
182
  ### Unit Tests
171
183
 
172
184
  - Tests live in `tests/` mirroring the source structure.
173
- - Run unit tests: `timeout-dead --sec 60 "pytest tests/ -v --ignore=tests/integration"`.
185
+ - Run unit tests: `time-d --sec 60 "pytest tests/ -v --ignore=tests/integration"`.
174
186
 
175
187
  ### Integration Tests
176
188
 
177
189
  - Place integration tests in `tests/integration/`. Use `@pytest.mark.integration` marker.
178
- - Run integration tests: `timeout-dead --sec 60 "pytest tests/integration/ -v -m \"integration\""`.
179
- - Run unit tests only: `timeout-dead --sec 60 "pytest tests/ -v -m \"not integration\""`.
190
+ - Run integration tests: `time-d --sec 60 "pytest tests/integration/ -v -m \"integration\""`.
191
+ - Run unit tests only: `time-d --sec 60 "pytest tests/ -v -m \"not integration\""`.
180
192
 
181
193
  ### Coverage
182
194
 
183
195
  - Aim for high coverage of core business logic. Use `pytest-cov` to measure:
184
196
  ```bash
185
- timeout-dead --sec 60 "pytest tests/ --cov=src/timeout_dead --cov-report=term-missing"
197
+ time-d --sec 60 "pytest tests/ --cov=src/timeout_dead --cov-report=term-missing"
186
198
  ```
187
199
 
188
200
  ## Environment & Configuration
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: timeout-dead
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Lightweight command timeout utility with zero runtime dependencies
5
5
  Author-email: Dmitry Krivoruchko <umbrella.leaf.for.work@gmail.com>
6
6
  License-Expression: Unlicense
@@ -67,6 +67,9 @@ time-d "echo hello"
67
67
  # Specify a custom timeout
68
68
  timeout-dead --sec 120 "npm run build"
69
69
 
70
+ # Sub-second timeouts work too
71
+ timeout-dead --sec 0.5 "potentially-hanging-tool"
72
+
70
73
  # Use SIGINT instead of default SIGTERM
71
74
  timeout-dead --signal INT --sec 30 "long-running-server"
72
75
 
@@ -77,7 +80,7 @@ timeout-dead --no-output "curl -s https://example.com"
77
80
  ## Usage
78
81
 
79
82
  ```
80
- usage: timeout-dead [-h] [--sec SECONDS] [--signal SIGNAL] [--no-output] COMMAND ...
83
+ usage: timeout-dead [-h] [-v] [--sec SECONDS] [--signal SIGNAL] [--no-output] COMMAND ...
81
84
 
82
85
  Lightweight command timeout utility.
83
86
 
@@ -86,7 +89,8 @@ positional arguments:
86
89
 
87
90
  options:
88
91
  -h, --help show this help message and exit
89
- --sec SECONDS timeout in seconds (default: 60)
92
+ -v, --version show version and exit
93
+ --sec SECONDS timeout in seconds (default: 60.0, accepts floats)
90
94
  --signal SIGNAL signal to send on timeout (TERM, KILL, HUP, INT)
91
95
  --no-output suppress normal output (stdout, stderr, header, footer)
92
96
  ```
@@ -103,12 +107,41 @@ options:
103
107
 
104
108
  ## Signal reference
105
109
 
106
- | Signal | Unix | Windows |
107
- |--------|------|---------|
110
+ | Signal | Unix | Windows |
111
+ | ------ | ------------------------------------- | ---------------------------------- |
108
112
  | `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 |
113
+ | `KILL` | `SIGKILL` (9) — force kill | Falls back to `TerminateProcess` |
114
+ | `HUP` | `SIGHUP` (1) — hangup | Falls back to `TerminateProcess` |
115
+ | `INT` | `SIGINT` (2) — interrupt (Ctrl+C) | `CTRL_C_EVENT` — console interrupt |
116
+
117
+ ## Why `subprocess` timeout is not enough
118
+
119
+ Python's built-in `subprocess` timeout only kills the direct child, not its entire process tree. If your command spawns subprocesses (`npm install`, `make`, `docker build`), children survive the parent kill.
120
+
121
+ `timeout-dead` uses **process groups** to terminate everything — every subprocess, pipeline, and child.
122
+
123
+ ## Real-world scenarios
124
+
125
+ | Scenario | Command | Why it hangs | `timeout-dead` |
126
+ | ---------------- | ------------------------- | ------------------------------------------------------- | -------------------------------------------------------- |
127
+ | Gradle build | `./gradlew build` | 20+ min fresh build, agent generates duplicate commands | `timeout-dead --sec 600 "./gradlew build"` |
128
+ | CMake build | `cmake --build .` | Locks waiting for dependency resolution | `timeout-dead --sec 180 "cmake --build ."` |
129
+ | Spring Boot | `./gradlew bootRun` | Server never exits, agent won't proceed | `timeout-dead --sec 30 --signal INT "./gradlew bootRun"` |
130
+ | Docker build | `docker build -t myapp .` | Network timeout, internal retries, no progress | `timeout-dead --sec 600 "docker build -t myapp ."` |
131
+ | npm install | `npm install` | Corrupted cache or registry auth hang | `timeout-dead --sec 300 "npm install"` |
132
+ | Interactive REPL | `python` / `node` / `irb` | Waits for input, agent doesn't know | `timeout-dead --sec 5 "python"` |
133
+
134
+ ## For AI agents
135
+
136
+ If you build agents that execute shell commands, `timeout-dead` is essential infrastructure. Agents frequently generate commands that hang — waiting for input, entering infinite loops, or starting interactive programs.
137
+
138
+ Instead of agents freezing indefinitely, wrap every command:
139
+
140
+ ```bash
141
+ timeout-dead --sec <timeout> --signal <signal> "<command>"
142
+ ```
143
+
144
+ No dependencies, no code changes. Agent always gets a response — exit code + output — and can implement retry, fallback, or user notification.
112
145
 
113
146
  ## Development
114
147
 
@@ -41,6 +41,9 @@ time-d "echo hello"
41
41
  # Specify a custom timeout
42
42
  timeout-dead --sec 120 "npm run build"
43
43
 
44
+ # Sub-second timeouts work too
45
+ timeout-dead --sec 0.5 "potentially-hanging-tool"
46
+
44
47
  # Use SIGINT instead of default SIGTERM
45
48
  timeout-dead --signal INT --sec 30 "long-running-server"
46
49
 
@@ -51,7 +54,7 @@ timeout-dead --no-output "curl -s https://example.com"
51
54
  ## Usage
52
55
 
53
56
  ```
54
- usage: timeout-dead [-h] [--sec SECONDS] [--signal SIGNAL] [--no-output] COMMAND ...
57
+ usage: timeout-dead [-h] [-v] [--sec SECONDS] [--signal SIGNAL] [--no-output] COMMAND ...
55
58
 
56
59
  Lightweight command timeout utility.
57
60
 
@@ -60,7 +63,8 @@ positional arguments:
60
63
 
61
64
  options:
62
65
  -h, --help show this help message and exit
63
- --sec SECONDS timeout in seconds (default: 60)
66
+ -v, --version show version and exit
67
+ --sec SECONDS timeout in seconds (default: 60.0, accepts floats)
64
68
  --signal SIGNAL signal to send on timeout (TERM, KILL, HUP, INT)
65
69
  --no-output suppress normal output (stdout, stderr, header, footer)
66
70
  ```
@@ -77,12 +81,41 @@ options:
77
81
 
78
82
  ## Signal reference
79
83
 
80
- | Signal | Unix | Windows |
81
- |--------|------|---------|
84
+ | Signal | Unix | Windows |
85
+ | ------ | ------------------------------------- | ---------------------------------- |
82
86
  | `TERM` | `SIGTERM` (15) — terminate gracefully | `CTRL_BREAK_EVENT` — console break |
83
- | `KILL` | `SIGKILL` (9) — force kill | Falls back to `TerminateProcess` |
84
- | `HUP` | `SIGHUP` (1) — hangup | Falls back to `TerminateProcess` |
85
- | `INT` | `SIGINT` (2) — interrupt (Ctrl+C) | `CTRL_C_EVENT` — console interrupt |
87
+ | `KILL` | `SIGKILL` (9) — force kill | Falls back to `TerminateProcess` |
88
+ | `HUP` | `SIGHUP` (1) — hangup | Falls back to `TerminateProcess` |
89
+ | `INT` | `SIGINT` (2) — interrupt (Ctrl+C) | `CTRL_C_EVENT` — console interrupt |
90
+
91
+ ## Why `subprocess` timeout is not enough
92
+
93
+ Python's built-in `subprocess` timeout only kills the direct child, not its entire process tree. If your command spawns subprocesses (`npm install`, `make`, `docker build`), children survive the parent kill.
94
+
95
+ `timeout-dead` uses **process groups** to terminate everything — every subprocess, pipeline, and child.
96
+
97
+ ## Real-world scenarios
98
+
99
+ | Scenario | Command | Why it hangs | `timeout-dead` |
100
+ | ---------------- | ------------------------- | ------------------------------------------------------- | -------------------------------------------------------- |
101
+ | Gradle build | `./gradlew build` | 20+ min fresh build, agent generates duplicate commands | `timeout-dead --sec 600 "./gradlew build"` |
102
+ | CMake build | `cmake --build .` | Locks waiting for dependency resolution | `timeout-dead --sec 180 "cmake --build ."` |
103
+ | Spring Boot | `./gradlew bootRun` | Server never exits, agent won't proceed | `timeout-dead --sec 30 --signal INT "./gradlew bootRun"` |
104
+ | Docker build | `docker build -t myapp .` | Network timeout, internal retries, no progress | `timeout-dead --sec 600 "docker build -t myapp ."` |
105
+ | npm install | `npm install` | Corrupted cache or registry auth hang | `timeout-dead --sec 300 "npm install"` |
106
+ | Interactive REPL | `python` / `node` / `irb` | Waits for input, agent doesn't know | `timeout-dead --sec 5 "python"` |
107
+
108
+ ## For AI agents
109
+
110
+ If you build agents that execute shell commands, `timeout-dead` is essential infrastructure. Agents frequently generate commands that hang — waiting for input, entering infinite loops, or starting interactive programs.
111
+
112
+ Instead of agents freezing indefinitely, wrap every command:
113
+
114
+ ```bash
115
+ timeout-dead --sec <timeout> --signal <signal> "<command>"
116
+ ```
117
+
118
+ No dependencies, no code changes. Agent always gets a response — exit code + output — and can implement retry, fallback, or user notification.
86
119
 
87
120
  ## Development
88
121
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.1'
22
- __version_tuple__ = version_tuple = (0, 1, 1)
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g86f511007'
24
+ __commit_id__ = commit_id = 'gd1dae1c5b'
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
- """Легковесная утилита для запуска команд с таймаутом."""
3
+ """Lightweight command timeout utility."""
4
4
 
5
5
  import argparse
6
6
  import os
@@ -10,6 +10,8 @@ import subprocess
10
10
  import sys
11
11
  import threading
12
12
  import time
13
+ from importlib.metadata import PackageNotFoundError
14
+ from importlib.metadata import version as _get_version
13
15
 
14
16
 
15
17
  # MARK: Constants
@@ -17,13 +19,13 @@ import time
17
19
 
18
20
 
19
21
  class _Const:
20
- DEFAULT_TIMEOUT_S: int = 60
22
+ DEFAULT_TIMEOUT_S: float = 60.0
21
23
  GRACE_PERIOD_S: float = 1.0
22
- HEADER_SEPARATOR: str = "-" * 60
24
+ HEADER_SEPARATOR: str = "-" * 50
23
25
 
24
26
  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_BASH_NOT_FOUND: str = "bash not found in PATH"
28
+ MSG_TIMEOUT: str = "Timeout exceeded {}s"
27
29
  MSG_EXEC_ERROR: str = "Execution error: {}"
28
30
 
29
31
  SIGNAL_NAMES: tuple[str, ...] = ("TERM", "KILL", "HUP", "INT")
@@ -35,6 +37,17 @@ class _Const:
35
37
  "INT": signal.SIGINT,
36
38
  }
37
39
 
40
+ @staticmethod
41
+ def _resolve_version() -> str:
42
+ try:
43
+ return _get_version("timeout-dead")
44
+
45
+ except PackageNotFoundError:
46
+ return "unknown"
47
+
48
+
49
+ _PROJECT_VERSION: str = _Const._resolve_version()
50
+
38
51
 
39
52
  # MARK: Private Helpers
40
53
  # ------------------------------------------------
@@ -49,13 +62,13 @@ def _is_windows() -> bool:
49
62
 
50
63
  def _find_bash() -> str:
51
64
  """
52
- Ищет bash в PATH.
65
+ Locate bash executable in PATH.
53
66
 
54
67
  Returns:
55
- str: путь к исполняемому файлу bash
68
+ str: path to the bash executable
56
69
 
57
70
  Raises:
58
- SystemExit: если bash не найден
71
+ SystemExit: if bash is not found
59
72
  """
60
73
 
61
74
  bash_path = shutil.which("bash")
@@ -77,7 +90,7 @@ def _terminate_process(
77
90
  force: bool = False,
78
91
  signal_num: int = signal.SIGTERM,
79
92
  ) -> None:
80
- """Завершает процессмягко (выбранным сигналом) или жёстко."""
93
+ """Terminate the process gracefully (chosen signal) or forcefully."""
81
94
 
82
95
  if process.poll() is not None:
83
96
  return
@@ -112,10 +125,10 @@ def _terminate_process(
112
125
 
113
126
  def _kill_with_timeout(
114
127
  process: subprocess.Popen[bytes] | subprocess.Popen[str],
115
- timeout: int,
128
+ timeout: float,
116
129
  signal_num: int = signal.SIGTERM,
117
130
  ) -> None:
118
- """Убивает процесс по таймауту с двухэтапной логикой."""
131
+ """Kill the process after timeout with two-stage logic."""
119
132
 
120
133
  if process.poll() is not None:
121
134
  return
@@ -135,21 +148,21 @@ def _kill_with_timeout(
135
148
 
136
149
  def run_command(
137
150
  command_string: str,
138
- timeout: int = _Const.DEFAULT_TIMEOUT_S,
151
+ timeout: float = _Const.DEFAULT_TIMEOUT_S,
139
152
  signal_name: str = "TERM",
140
153
  no_output: bool = False,
141
154
  ) -> int:
142
155
  """
143
- Запускает команду с таймаутом через bash.
156
+ Run a command with timeout via bash.
144
157
 
145
158
  Args:
146
- command_string (str): команда для выполнения
147
- timeout (int): таймаут в секундах
148
- signal_name (str): имя сигнала для мягкого завершения
149
- no_output (bool): подавлять ли обычный вывод
159
+ command_string (str): command to execute
160
+ timeout (float): timeout in seconds
161
+ signal_name (str): signal name for graceful termination
162
+ no_output (bool): suppress normal output
150
163
 
151
164
  Returns:
152
- int: код возврата процесса (-1 при ошибке запуска)
165
+ int: process return code (-1 on launch error)
153
166
  """
154
167
 
155
168
  signal_num = _Const.SIGNAL_MAP.get(signal_name, signal.SIGTERM)
@@ -157,7 +170,10 @@ def run_command(
157
170
  timer: threading.Timer | None = None
158
171
 
159
172
  try:
160
- creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if _is_windows() else 0
173
+ creationflags = (
174
+ getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if _is_windows() else 0
175
+ )
176
+
161
177
  start_new_session = not _is_windows()
162
178
 
163
179
  process = subprocess.Popen(
@@ -190,6 +206,7 @@ def run_command(
190
206
  except Exception as e:
191
207
  if timer:
192
208
  timer.cancel()
209
+
193
210
  if process and process.poll() is None:
194
211
  try:
195
212
  _terminate_process(process, force=True)
@@ -206,16 +223,23 @@ def run_command(
206
223
 
207
224
 
208
225
  def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
209
- """Разбирает аргументы командной строки."""
226
+ """Parse command-line arguments."""
210
227
 
211
228
  parser = argparse.ArgumentParser(
212
229
  description="Lightweight command timeout utility.",
213
230
  formatter_class=argparse.RawDescriptionHelpFormatter,
214
231
  )
215
232
 
233
+ parser.add_argument(
234
+ "-v",
235
+ "--version",
236
+ action="version",
237
+ version=f"timeout-dead {_PROJECT_VERSION}",
238
+ )
239
+
216
240
  parser.add_argument(
217
241
  "--sec",
218
- type=int,
242
+ type=float,
219
243
  default=_Const.DEFAULT_TIMEOUT_S,
220
244
  help=f"timeout in seconds (default: {_Const.DEFAULT_TIMEOUT_S})",
221
245
  metavar="SECONDS",
@@ -250,8 +274,8 @@ def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
250
274
  # ------------------------------------------------
251
275
 
252
276
 
253
- def print_header(command: str, timeout: int) -> None:
254
- """Выводит заголовок выполнения."""
277
+ def print_header(command: str, timeout: float) -> None:
278
+ """Print execution header."""
255
279
 
256
280
  print(f"Running: {command}")
257
281
  print(f"Timeout: {timeout} seconds")
@@ -262,7 +286,7 @@ def print_header(command: str, timeout: int) -> None:
262
286
 
263
287
 
264
288
  def print_footer(return_code: int) -> None:
265
- """Выводит футер выполнения."""
289
+ """Print execution footer."""
266
290
 
267
291
  print(_Const.HEADER_SEPARATOR)
268
292
  print(f"Exit code: {return_code}")
@@ -272,7 +296,7 @@ def print_footer(return_code: int) -> None:
272
296
 
273
297
 
274
298
  def main(argv: list[str] | None = None) -> None:
275
- """Главная точка входа."""
299
+ """Main entry point."""
276
300
 
277
301
  args = parse_arguments(argv)
278
302
 
@@ -13,6 +13,7 @@ uv.lock
13
13
  .github/workflows/pyright.yml
14
14
  .github/workflows/ruff.yml
15
15
  .github/workflows/tests.yml
16
+ .vscode/settings.json
16
17
  src/timeout_dead/__init__.py
17
18
  src/timeout_dead/_version.py
18
19
  src/timeout_dead/cli.py
@@ -9,7 +9,7 @@ import pytest
9
9
 
10
10
  @pytest.fixture
11
11
  def temp_dir() -> Iterator[Path]:
12
- """Временная директория для тестов."""
12
+ """Temporary directory for tests."""
13
13
 
14
14
  with tempfile.TemporaryDirectory() as tmp:
15
15
  yield Path(tmp)
@@ -1,4 +1,4 @@
1
- """Тесты CLI утилиты timeout-dead."""
1
+ """Tests for timeout-dead CLI utility."""
2
2
 
3
3
  import os
4
4
  import signal
@@ -30,7 +30,7 @@ def _run_cli(
30
30
  *args: str,
31
31
  timeout: int = 10,
32
32
  ) -> subprocess.CompletedProcess[str]:
33
- """Запускает timeout-dead как subprocess."""
33
+ """Run timeout-dead as a subprocess."""
34
34
 
35
35
  return subprocess.run(
36
36
  [sys.executable, "-m", "timeout_dead.cli", *args],
@@ -48,7 +48,7 @@ def _run_cli(
48
48
  class TestParseArguments:
49
49
  def test_defaults(self) -> None:
50
50
  args = parse_arguments(["echo", "hello"])
51
- assert args.sec == 60
51
+ assert args.sec == 60.0
52
52
  assert args.signal == "TERM"
53
53
  assert args.no_output is False
54
54
  assert args.command == ["echo", "hello"]
@@ -57,7 +57,25 @@ class TestParseArguments:
57
57
 
58
58
  def test_custom_timeout(self) -> None:
59
59
  args = parse_arguments(["--sec", "120", "echo", "hello"])
60
- assert args.sec == 120
60
+ assert args.sec == 120.0
61
+
62
+ # ------------------------------------------------
63
+
64
+ def test_float_timeout(self) -> None:
65
+ args = parse_arguments(["--sec", "2.5", "echo", "hello"])
66
+ assert args.sec == 2.5
67
+
68
+ # ------------------------------------------------
69
+
70
+ def test_subsecond_timeout(self) -> None:
71
+ args = parse_arguments(["--sec", "0.3", "sleep", "1"])
72
+ assert args.sec == 0.3
73
+
74
+ # ------------------------------------------------
75
+
76
+ def test_zero_timeout(self) -> None:
77
+ args = parse_arguments(["--sec", "0", "echo", "hello"])
78
+ assert args.sec == 0.0
61
79
 
62
80
  # ------------------------------------------------
63
81
 
@@ -217,6 +235,58 @@ class TestRunCommand:
217
235
  assert rc != 0
218
236
 
219
237
 
238
+ # MARK: Float timeout tests
239
+ # ------------------------------------------------
240
+
241
+
242
+ class TestFloatTimeout:
243
+ def test_float_parsed_correctly(self) -> None:
244
+ rc = run_command("echo ok", timeout=2.5)
245
+ assert rc == 0
246
+
247
+ # ------------------------------------------------
248
+
249
+ def test_subsecond_timeout_triggers(self) -> None:
250
+ rc = run_command("sleep 10", timeout=0.3)
251
+ assert rc != 0
252
+
253
+ # ------------------------------------------------
254
+
255
+ def test_millisecond_timeout(self) -> None:
256
+ start = time.monotonic()
257
+ rc = run_command("sleep 10", timeout=0.5)
258
+ elapsed = time.monotonic() - start
259
+ assert rc != 0
260
+ assert elapsed < 4.0
261
+
262
+ # ------------------------------------------------
263
+
264
+ def test_float_in_message(self, capsys: pytest.CaptureFixture[str]) -> None:
265
+ run_command("sleep 10", timeout=1.5)
266
+ captured = capsys.readouterr()
267
+ assert "Timeout exceeded" in captured.err
268
+
269
+ # ------------------------------------------------
270
+
271
+ def test_cli_float_arg(self) -> None:
272
+ result = _run_cli("--sec", "0.3", "sleep", "10")
273
+ assert result.returncode != 0
274
+ assert "Timeout exceeded" in result.stderr
275
+
276
+ # ------------------------------------------------
277
+
278
+ def test_cli_float_with_signal(self) -> None:
279
+ result = _run_cli("--sec", "0.5", "--signal", "KILL", "sleep", "10")
280
+ assert result.returncode != 0
281
+
282
+ # ------------------------------------------------
283
+
284
+ def test_cli_float_no_output(self) -> None:
285
+ result = _run_cli("--no-output", "--sec", "0.3", "sleep", "10")
286
+ assert "Timeout exceeded" in result.stderr
287
+ assert "Running:" not in result.stdout
288
+
289
+
220
290
  # MARK: Signal selection tests
221
291
  # ------------------------------------------------
222
292
 
@@ -245,7 +315,7 @@ class TestSignalSelection:
245
315
  reason="SIGINT file-based test requires Unix signal handling",
246
316
  )
247
317
  def test_signal_int_handling(self, tmp_path: Path) -> None:
248
- """Python-скрипт ловит SIGINT и записывает номер сигнала в файл."""
318
+ """Python script catches SIGINT and writes signal number to file."""
249
319
 
250
320
  marker = tmp_path / "signal.txt"
251
321
  script_path = tmp_path / "handler.py"
@@ -395,6 +465,21 @@ class TestIntegration:
395
465
  assert "--sec" in result.stdout
396
466
  assert "--signal" in result.stdout
397
467
  assert "--no-output" in result.stdout
468
+ assert "--version" in result.stdout
469
+
470
+ # ------------------------------------------------
471
+
472
+ def test_cli_version_long(self) -> None:
473
+ result = _run_cli("--version")
474
+ assert result.returncode == 0
475
+ assert "timeout-dead" in result.stdout
476
+
477
+ # ------------------------------------------------
478
+
479
+ def test_cli_version_short(self) -> None:
480
+ result = _run_cli("-v")
481
+ assert result.returncode == 0
482
+ assert "timeout-dead" in result.stdout
398
483
 
399
484
  # ------------------------------------------------
400
485
 
@@ -445,8 +530,8 @@ class TestIntegration:
445
530
 
446
531
  class TestConstants:
447
532
  def test_default_timeout(self) -> None:
448
- assert _Const.DEFAULT_TIMEOUT_S == 60
449
- assert isinstance(_Const.DEFAULT_TIMEOUT_S, int)
533
+ assert _Const.DEFAULT_TIMEOUT_S == 60.0
534
+ assert isinstance(_Const.DEFAULT_TIMEOUT_S, float)
450
535
 
451
536
  # ------------------------------------------------
452
537
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes