mcp-coder-utils 0.1.2__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 @@
1
+ """Shared low-level Python helpers for the mcp-coder family of repos."""
File without changes
@@ -0,0 +1,754 @@
1
+ """Subprocess execution utilities with process isolation support.
2
+
3
+ This module provides functions for executing command-line tools with proper
4
+ timeout handling and STDIO isolation for Python commands.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import shlex
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ # Re-export for external use (allows catching without direct subprocess import)
20
+ from subprocess import CalledProcessError, SubprocessError, TimeoutExpired
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ MAX_STDERR_IN_ERROR: int = 500
25
+
26
+ __all__ = [
27
+ "CommandResult",
28
+ "CommandOptions",
29
+ "MAX_STDERR_IN_ERROR",
30
+ "check_tool_missing_error",
31
+ "execute_command",
32
+ "execute_subprocess",
33
+ "launch_process",
34
+ "format_command",
35
+ "prepare_env",
36
+ "truncate_stderr",
37
+ # Re-exported exceptions
38
+ "CalledProcessError",
39
+ "SubprocessError",
40
+ "TimeoutExpired",
41
+ ]
42
+
43
+
44
+ def check_tool_missing_error(
45
+ stderr: str, tool_name: str, python_path: str
46
+ ) -> str | None:
47
+ """Check if stderr indicates the tool is not installed.
48
+
49
+ Returns:
50
+ Error message string if the tool is missing, or None if no issue detected.
51
+ """
52
+ if f"No module named {tool_name}" in stderr:
53
+ return (
54
+ f"{tool_name} is not installed in the Python environment "
55
+ f"at {python_path}."
56
+ )
57
+ return None
58
+
59
+
60
+ def truncate_stderr(stderr: str, max_len: int = MAX_STDERR_IN_ERROR) -> str:
61
+ """Truncate stderr to a maximum length.
62
+
63
+ Returns:
64
+ The original stderr if within max_len, otherwise truncated with ellipsis.
65
+ """
66
+ if len(stderr) > max_len:
67
+ return stderr[:max_len] + "..."
68
+ return stderr
69
+
70
+
71
+ def format_command(command: list[str]) -> str:
72
+ """Format a command list as a platform-aware shell string.
73
+
74
+ Uses shlex.join() on Unix, subprocess.list2cmdline() on Windows.
75
+ Truncates at 200 characters with '...' suffix.
76
+
77
+ Returns:
78
+ The formatted command string, truncated if longer than 200 characters.
79
+ """
80
+ if os.name == "nt":
81
+ full = subprocess.list2cmdline(command)
82
+ else:
83
+ full = shlex.join(command)
84
+ if len(full) > 200:
85
+ return full[:200] + "..."
86
+ return full
87
+
88
+
89
+ @dataclass
90
+ class CommandResult:
91
+ """Represents the result of a command execution."""
92
+
93
+ return_code: int
94
+ stdout: str
95
+ stderr: str
96
+ timed_out: bool
97
+ execution_error: str | None = None
98
+ command: list[str] | None = field(default=None)
99
+ runner_type: str | None = field(default=None)
100
+ execution_time_ms: int | None = field(default=None)
101
+
102
+
103
+ @dataclass
104
+ class CommandOptions:
105
+ """Configuration options for command execution.
106
+
107
+ Attributes:
108
+ cwd: Working directory for the subprocess
109
+ timeout_seconds: Maximum time to wait for process completion
110
+ env: Environment variables for the subprocess. May contain internal
111
+ testing flags prefixed with underscore (e.g., _DISABLE_STDIO_ISOLATION)
112
+ that should NEVER be used in production code.
113
+ env_remove: List of environment variable names to remove after merging
114
+ capture_output: Whether to capture stdout and stderr
115
+ text: Whether to decode output as text
116
+ check: Whether to raise exception on non-zero exit code
117
+ shell: Whether to execute through shell
118
+ input_data: Data to send to subprocess stdin
119
+
120
+ Warning:
121
+ Environment variables starting with underscore (_) are internal testing
122
+ flags that bypass safety mechanisms. They must not be used in production.
123
+ """
124
+
125
+ cwd: str | None = None
126
+ timeout_seconds: int = 120
127
+ env: dict[str, str] | None = None
128
+ capture_output: bool = True
129
+ text: bool = True
130
+ check: bool = False
131
+ shell: bool = False
132
+ input_data: str | None = None
133
+ env_remove: list[str] | None = None
134
+
135
+
136
+ def is_python_command(command: list[str]) -> bool:
137
+ """Check if a command is a Python execution command.
138
+
139
+ Returns:
140
+ True if the command runs a Python interpreter.
141
+ """
142
+ if not command:
143
+ return False
144
+
145
+ executable = Path(command[0]).name.lower()
146
+ return (
147
+ executable in ["python", "python3", "python.exe", "python3.exe"]
148
+ or command[0] == sys.executable
149
+ )
150
+
151
+
152
+ def get_python_isolation_env() -> dict[str, str]:
153
+ """Get environment variables for Python subprocess isolation.
154
+
155
+ Returns:
156
+ Dict of environment variables with MCP isolation settings.
157
+ """
158
+ env = os.environ.copy()
159
+
160
+ # Python-specific settings to prevent MCP STDIO conflicts
161
+ env.update(
162
+ {
163
+ "PYTHONUNBUFFERED": "1",
164
+ "PYTHONDONTWRITEBYTECODE": "1",
165
+ "PYTHONIOENCODING": "utf-8",
166
+ "PYTHONNOUSERSITE": "1",
167
+ "PYTHONHASHSEED": "0",
168
+ "PYTHONSTARTUP": "",
169
+ }
170
+ )
171
+
172
+ # Remove MCP-specific variables
173
+ for var in ["MCP_STDIO_TRANSPORT", "MCP_SERVER_NAME", "MCP_CLIENT_PARAMS"]:
174
+ env.pop(var, None)
175
+
176
+ return env
177
+
178
+
179
+ def get_utf8_env() -> dict[str, str]:
180
+ """Get environment variables for UTF-8 encoding support on all subprocess types.
181
+
182
+ Returns:
183
+ Dict of environment variables with UTF-8 encoding settings.
184
+ """
185
+ env = os.environ.copy()
186
+
187
+ # Set UTF-8 encoding for all subprocess types
188
+ env.update(
189
+ {
190
+ "PYTHONIOENCODING": "utf-8",
191
+ "PYTHONUTF8": "1",
192
+ }
193
+ )
194
+
195
+ # On Windows, also set legacy encoding variables
196
+ if os.name == "nt":
197
+ env.update(
198
+ {
199
+ "PYTHONLEGACYWINDOWSFSENCODING": "utf-8",
200
+ }
201
+ )
202
+ else:
203
+ # On Unix systems, set locale for UTF-8
204
+ env.update(
205
+ {
206
+ "LC_ALL": "C.UTF-8",
207
+ }
208
+ )
209
+
210
+ return env
211
+
212
+
213
+ def prepare_env(
214
+ command: list[str] | str,
215
+ env: dict[str, str] | None,
216
+ env_remove: list[str] | None,
217
+ ) -> dict[str, str]:
218
+ """Build a complete environment dict for subprocess execution.
219
+
220
+ Starts from os.environ, applies Python isolation or UTF-8 settings
221
+ based on command type, merges caller-provided env on top, then
222
+ removes any keys listed in env_remove.
223
+
224
+ Args:
225
+ command: Command as list or string. String commands are always
226
+ treated as non-Python (shell=True implies unknown executable).
227
+ env: Optional caller-provided environment variables to merge.
228
+ env_remove: Optional list of environment variable names to remove.
229
+
230
+ Returns:
231
+ Complete environment dict ready for subprocess.Popen.
232
+ """
233
+ if isinstance(command, list) and is_python_command(command):
234
+ result = get_python_isolation_env()
235
+ else:
236
+ result = get_utf8_env()
237
+
238
+ if env:
239
+ result.update(env)
240
+
241
+ for key in env_remove or []:
242
+ result.pop(key, None)
243
+
244
+ return result
245
+
246
+
247
+ def _kill_process(
248
+ process: subprocess.Popen[str], logger_instance: logging.Logger
249
+ ) -> None:
250
+ """Kill a subprocess and its children, platform-aware.
251
+
252
+ On Unix, sends SIGTERM to the process group, waits briefly, then
253
+ SIGKILL if the process is still alive. On Windows, uses
254
+ ``taskkill /F /T``. Falls back to ``process.kill()`` on failure.
255
+
256
+ Args:
257
+ process: The subprocess to kill.
258
+ logger_instance: Logger used for debug messages on fallback paths.
259
+ """
260
+ if os.name == "nt":
261
+ try:
262
+ subprocess.run(
263
+ ["taskkill", "/F", "/T", "/PID", str(process.pid)],
264
+ capture_output=True,
265
+ timeout=5,
266
+ check=False,
267
+ )
268
+ except (subprocess.SubprocessError, OSError) as exc:
269
+ logger_instance.debug(
270
+ "Taskkill failed, using fallback",
271
+ extra={"error": str(exc), "pid": process.pid},
272
+ )
273
+ process.kill()
274
+ else:
275
+ try:
276
+ if (
277
+ hasattr(os, "killpg")
278
+ and hasattr(os, "getpgid")
279
+ and hasattr(signal, "SIGTERM")
280
+ and hasattr(signal, "SIGKILL")
281
+ ):
282
+ os.killpg( # type: ignore[attr-defined,unused-ignore]
283
+ os.getpgid(process.pid), # type: ignore[attr-defined,unused-ignore]
284
+ signal.SIGTERM, # type: ignore[attr-defined,unused-ignore]
285
+ )
286
+ time.sleep(0.5)
287
+ if process.poll() is None:
288
+ os.killpg( # type: ignore[attr-defined,unused-ignore]
289
+ os.getpgid(process.pid), # type: ignore[attr-defined,unused-ignore]
290
+ signal.SIGKILL, # type: ignore[attr-defined,unused-ignore]
291
+ )
292
+ else:
293
+ process.kill()
294
+ except (OSError, ProcessLookupError, AttributeError) as exc:
295
+ logger_instance.debug(
296
+ "Process group kill failed, using fallback",
297
+ extra={"error": str(exc), "pid": process.pid},
298
+ )
299
+ process.kill()
300
+
301
+ try:
302
+ process.wait(timeout=2)
303
+ except subprocess.TimeoutExpired:
304
+ pass
305
+
306
+
307
+ def _run_subprocess( # pylint: disable=too-many-statements
308
+ command: list[str], options: CommandOptions, use_stdio_isolation: bool = False
309
+ ) -> subprocess.CompletedProcess[str]:
310
+ """Run subprocess with or without STDIO isolation.
311
+
312
+ Args:
313
+ command: Command to execute
314
+ options: Execution options
315
+ use_stdio_isolation: Whether to use file-based STDIO isolation
316
+
317
+ Returns:
318
+ CompletedProcess with execution results
319
+
320
+ Raises:
321
+ TimeoutExpired: If the subprocess exceeds the configured timeout.
322
+ """
323
+ # Track start time for timeout logging
324
+ subprocess_start_time = time.time()
325
+ env = prepare_env(command, options.env, options.env_remove)
326
+
327
+ # Handle input data and stdin
328
+ stdin_value = subprocess.DEVNULL if options.input_data is None else None
329
+
330
+ # Use start_new_session for process isolation (thread-safe alternative to preexec_fn)
331
+ start_new_session = os.name != "nt" # True on Unix, False on Windows
332
+
333
+ # Use file-based STDIO for Python commands if needed
334
+ if use_stdio_isolation and options.capture_output:
335
+ with tempfile.TemporaryDirectory() as temp_dir:
336
+ stdout_file = Path(temp_dir) / "stdout.txt"
337
+ stderr_file = Path(temp_dir) / "stderr.txt"
338
+
339
+ process = None
340
+ stdout_f = None
341
+ stderr_f = None
342
+
343
+ try:
344
+ # Open files
345
+ stdout_f = open(stdout_file, "w", encoding="utf-8")
346
+ stderr_f = open(stderr_file, "w", encoding="utf-8")
347
+
348
+ # Use Popen for better process control
349
+ popen_proc = None
350
+ try:
351
+ popen_proc = subprocess.Popen(
352
+ command,
353
+ stdout=stdout_f,
354
+ stderr=stderr_f,
355
+ stdin=(
356
+ stdin_value
357
+ if options.input_data is None
358
+ else subprocess.PIPE
359
+ ),
360
+ cwd=options.cwd,
361
+ text=options.text,
362
+ encoding="utf-8" if options.text else None,
363
+ errors="replace", # Replace invalid characters instead of crashing
364
+ env=env,
365
+ shell=options.shell,
366
+ start_new_session=start_new_session,
367
+ )
368
+
369
+ # Communicate with timeout
370
+ try:
371
+ _, _ = popen_proc.communicate(
372
+ input=options.input_data, timeout=options.timeout_seconds
373
+ )
374
+ process = subprocess.CompletedProcess(
375
+ args=command,
376
+ returncode=popen_proc.returncode,
377
+ stdout="", # Will be read from file
378
+ stderr="", # Will be read from file
379
+ )
380
+ except subprocess.TimeoutExpired:
381
+ # Kill the process and all children
382
+ if popen_proc:
383
+ elapsed_time = time.time() - subprocess_start_time
384
+ logger.warning(
385
+ "Killing timed out process",
386
+ extra={
387
+ "mode": "stdio_isolation",
388
+ "pid": popen_proc.pid,
389
+ "command": format_command(command),
390
+ "timeout_seconds": options.timeout_seconds,
391
+ "elapsed_seconds": round(elapsed_time, 1),
392
+ "cwd": options.cwd or "current",
393
+ },
394
+ )
395
+ _kill_process(popen_proc, logger)
396
+
397
+ # Re-raise the timeout exception
398
+ raise
399
+
400
+ except subprocess.TimeoutExpired:
401
+ # Close files before re-raising to prevent Windows file locking
402
+ # This cleanup is necessary before re-raising the timeout exception
403
+ if stdout_f:
404
+ stdout_f.flush()
405
+ stdout_f.close()
406
+ if stderr_f:
407
+ stderr_f.flush()
408
+ stderr_f.close()
409
+
410
+ # On Windows, add a small delay to help with file handle cleanup
411
+ if os.name == "nt":
412
+ time.sleep(0.1)
413
+
414
+ # Re-raise to be handled by the caller
415
+ raise # pylint: disable=try-except-raise
416
+ finally:
417
+ # Ensure files are closed
418
+ if stdout_f and not stdout_f.closed:
419
+ stdout_f.close()
420
+ if stderr_f and not stderr_f.closed:
421
+ stderr_f.close()
422
+ except subprocess.TimeoutExpired: # pylint: disable=try-except-raise
423
+ raise
424
+ except Exception: # pylint: disable=try-except-raise
425
+ # Let any other exceptions propagate after cleanup in finally block
426
+ raise
427
+
428
+ # Read output files after process completes
429
+ # Use a small delay on Windows to avoid file locking issues
430
+ if os.name == "nt":
431
+ time.sleep(0.2)
432
+
433
+ # Read output files, handling potential errors
434
+ stdout = ""
435
+ stderr = ""
436
+
437
+ try:
438
+ if stdout_file.exists():
439
+ stdout = stdout_file.read_text(encoding="utf-8")
440
+ except (OSError, PermissionError) as exc:
441
+ logger.debug("Could not read stdout file", extra={"error": str(exc)})
442
+
443
+ try:
444
+ if stderr_file.exists():
445
+ stderr = stderr_file.read_text(encoding="utf-8")
446
+ except (OSError, PermissionError) as exc:
447
+ logger.debug("Could not read stderr file", extra={"error": str(exc)})
448
+
449
+ # Update the process with the actual output read from files
450
+ return subprocess.CompletedProcess(
451
+ args=command,
452
+ returncode=process.returncode if process else 1,
453
+ stdout=stdout,
454
+ stderr=stderr,
455
+ )
456
+ else:
457
+ # Regular execution with better process cleanup
458
+ popen_proc = None
459
+ try:
460
+ if options.capture_output:
461
+ popen_proc = subprocess.Popen(
462
+ command,
463
+ stdout=subprocess.PIPE,
464
+ stderr=subprocess.PIPE,
465
+ stdin=(
466
+ stdin_value if options.input_data is None else subprocess.PIPE
467
+ ),
468
+ cwd=options.cwd,
469
+ text=options.text,
470
+ encoding="utf-8" if options.text else None,
471
+ errors="replace", # Replace invalid characters instead of crashing
472
+ env=env,
473
+ shell=options.shell,
474
+ start_new_session=start_new_session,
475
+ )
476
+
477
+ try:
478
+ stdout, stderr = popen_proc.communicate(
479
+ input=options.input_data, timeout=options.timeout_seconds
480
+ )
481
+ return subprocess.CompletedProcess(
482
+ args=command,
483
+ returncode=popen_proc.returncode,
484
+ stdout=stdout or "",
485
+ stderr=stderr or "",
486
+ )
487
+ except subprocess.TimeoutExpired:
488
+ # Kill the process tree on timeout
489
+ if popen_proc:
490
+ elapsed_time = time.time() - subprocess_start_time
491
+ logger.warning(
492
+ "Killing timed out process",
493
+ extra={
494
+ "mode": "regular_execution",
495
+ "pid": popen_proc.pid,
496
+ "command": format_command(command),
497
+ "timeout_seconds": options.timeout_seconds,
498
+ "elapsed_seconds": round(elapsed_time, 1),
499
+ "cwd": options.cwd or "current",
500
+ },
501
+ )
502
+ _kill_process(popen_proc, logger)
503
+ raise
504
+ else:
505
+ # No output capture needed
506
+ return subprocess.run(
507
+ command,
508
+ capture_output=False,
509
+ cwd=options.cwd,
510
+ text=options.text,
511
+ encoding="utf-8" if options.text else None,
512
+ errors="replace", # Replace invalid characters instead of crashing
513
+ timeout=options.timeout_seconds,
514
+ env=env,
515
+ shell=options.shell,
516
+ stdin=stdin_value,
517
+ input=options.input_data,
518
+ start_new_session=start_new_session,
519
+ check=False,
520
+ )
521
+ except subprocess.TimeoutExpired: # pylint: disable=try-except-raise
522
+ raise
523
+ except Exception: # pylint: disable=try-except-raise
524
+ # Re-raise any other exceptions (this catch-all is needed for cleanup)
525
+ raise
526
+
527
+
528
+ def _run_heartbeat(
529
+ stop_event: threading.Event,
530
+ interval: int,
531
+ message: str,
532
+ start_time: float,
533
+ ) -> None:
534
+ """Heartbeat loop — runs in a daemon thread, logs at regular intervals."""
535
+ while not stop_event.wait(interval):
536
+ elapsed = time.time() - start_time
537
+ minutes, seconds = divmod(int(elapsed), 60)
538
+ logger.info("%s (elapsed: %dm %ds)", message, minutes, seconds)
539
+
540
+
541
+ def execute_subprocess(
542
+ command: list[str],
543
+ options: CommandOptions | None = None,
544
+ heartbeat_interval_seconds: int | None = None,
545
+ heartbeat_message: str = "",
546
+ ) -> CommandResult:
547
+ """Execute a command with automatic STDIO isolation for Python commands.
548
+
549
+ Args:
550
+ command: Command and arguments as a list
551
+ options: Execution options
552
+ heartbeat_interval_seconds: If set and > 0, log a heartbeat message
553
+ at this interval (in seconds) while the subprocess is running.
554
+ heartbeat_message: Message to include in heartbeat log entries.
555
+
556
+ Returns:
557
+ CommandResult with execution details
558
+
559
+ Raises:
560
+ TypeError: If command is None.
561
+ ValueError: If command is empty.
562
+ CalledProcessError: If check is True and the process returns non-zero exit code.
563
+ """
564
+ if command is None:
565
+ raise TypeError("Command cannot be None")
566
+
567
+ if not command:
568
+ raise ValueError("Command cannot be empty")
569
+
570
+ if options is None:
571
+ options = CommandOptions()
572
+
573
+ start_time = time.time()
574
+
575
+ # Determine if we need STDIO isolation
576
+ disable_isolation = (
577
+ options.env and options.env.get("_DISABLE_STDIO_ISOLATION") == "1"
578
+ )
579
+ use_isolation = is_python_command(command) and not disable_isolation
580
+
581
+ logger.debug(
582
+ "Starting subprocess execution",
583
+ extra={
584
+ "command": command[:3] if command else None,
585
+ "cwd": options.cwd,
586
+ "timeout_seconds": options.timeout_seconds,
587
+ "use_isolation": use_isolation,
588
+ },
589
+ )
590
+
591
+ stop_event = None
592
+ heartbeat_thread = None
593
+
594
+ if heartbeat_interval_seconds and heartbeat_interval_seconds > 0:
595
+ stop_event = threading.Event()
596
+ heartbeat_thread = threading.Thread(
597
+ target=_run_heartbeat,
598
+ args=(
599
+ stop_event,
600
+ heartbeat_interval_seconds,
601
+ heartbeat_message,
602
+ start_time,
603
+ ),
604
+ daemon=True,
605
+ )
606
+ heartbeat_thread.start()
607
+
608
+ try:
609
+ process = _run_subprocess(command, options, use_isolation)
610
+
611
+ # Handle check parameter
612
+ if options.check and process.returncode != 0:
613
+ raise subprocess.CalledProcessError(
614
+ process.returncode, command, process.stdout, process.stderr
615
+ )
616
+
617
+ execution_time_ms = int((time.time() - start_time) * 1000)
618
+
619
+ return CommandResult(
620
+ return_code=process.returncode,
621
+ stdout=process.stdout or "",
622
+ stderr=process.stderr or "",
623
+ timed_out=False,
624
+ command=command,
625
+ runner_type="subprocess",
626
+ execution_time_ms=execution_time_ms,
627
+ )
628
+
629
+ except subprocess.TimeoutExpired:
630
+ execution_time_ms = int((time.time() - start_time) * 1000)
631
+ return CommandResult(
632
+ return_code=1,
633
+ stdout="",
634
+ stderr="",
635
+ timed_out=True,
636
+ execution_error=f"Process timed out after {options.timeout_seconds} seconds",
637
+ command=command,
638
+ runner_type="subprocess",
639
+ execution_time_ms=execution_time_ms,
640
+ )
641
+
642
+ except subprocess.CalledProcessError as e:
643
+ if options.check:
644
+ raise
645
+ execution_time_ms = int((time.time() - start_time) * 1000)
646
+ return CommandResult(
647
+ return_code=e.returncode,
648
+ stdout=getattr(e, "stdout", "") or "",
649
+ stderr=getattr(e, "stderr", "") or "",
650
+ timed_out=False,
651
+ command=command,
652
+ runner_type="subprocess",
653
+ execution_time_ms=execution_time_ms,
654
+ )
655
+
656
+ except (FileNotFoundError, PermissionError, OSError) as e:
657
+ # Handle file system and permission errors
658
+ execution_time_ms = int((time.time() - start_time) * 1000)
659
+ logger.error(
660
+ "Subprocess execution failed",
661
+ extra={
662
+ "error": str(e),
663
+ "error_type": type(e).__name__,
664
+ "command_preview": command[:3] if command else None,
665
+ },
666
+ )
667
+ return CommandResult(
668
+ return_code=1,
669
+ stdout="",
670
+ stderr="",
671
+ timed_out=False,
672
+ execution_error=f"{type(e).__name__}: {e}",
673
+ command=command,
674
+ runner_type="subprocess",
675
+ execution_time_ms=execution_time_ms,
676
+ )
677
+
678
+ finally:
679
+ if stop_event is not None:
680
+ stop_event.set()
681
+ if heartbeat_thread is not None:
682
+ heartbeat_thread.join(timeout=2)
683
+
684
+
685
+ def execute_command(
686
+ command: list[str],
687
+ cwd: str | None = None,
688
+ timeout_seconds: int = 120,
689
+ env: dict[str, str] | None = None,
690
+ ) -> CommandResult:
691
+ """Execute a command with automatic STDIO isolation for Python commands.
692
+
693
+ Args:
694
+ command: Complete command as list (e.g., ["python", "-m", "pylint", "src"])
695
+ cwd: Working directory for subprocess
696
+ timeout_seconds: Timeout in seconds
697
+ env: Optional environment variables
698
+
699
+ Returns:
700
+ CommandResult with execution details and output
701
+ """
702
+ options = CommandOptions(
703
+ cwd=cwd,
704
+ timeout_seconds=timeout_seconds,
705
+ env=env,
706
+ )
707
+ return execute_subprocess(command, options)
708
+
709
+
710
+ def launch_process(
711
+ command: list[str] | str,
712
+ cwd: str | Path | None = None,
713
+ shell: bool = False,
714
+ env: dict[str, str] | None = None,
715
+ env_remove: list[str] | None = None,
716
+ ) -> int:
717
+ """Launch a process without waiting for it to complete.
718
+
719
+ This is for fire-and-forget process launching where you need the PID
720
+ but don't need to wait for completion or capture output.
721
+
722
+ Environment variables are always inherited from the parent process
723
+ via prepare_env(). The ``env`` parameter merges on top of the parent
724
+ environment rather than replacing it entirely.
725
+
726
+ Args:
727
+ command: Command as list or string (string requires shell=True)
728
+ cwd: Working directory for the process
729
+ shell: Whether to execute through shell (required for string commands)
730
+ env: Extra environment variables merged on top of the parent env
731
+ env_remove: Environment variable names to remove after merging
732
+
733
+ Returns:
734
+ Process ID (PID) of the launched process
735
+
736
+ Example:
737
+ # Launch VSCode — parent env is inherited automatically
738
+ pid = launch_process(["code", "myfile.txt"])
739
+
740
+ # Launch with extra env vars (no need for os.environ.copy())
741
+ pid = launch_process(["code", "myfile.txt"], env={"CUSTOM_VAR": "value"})
742
+ """
743
+ cwd_str = str(cwd) if cwd else None
744
+ prepared_env = prepare_env(command, env, env_remove)
745
+
746
+ process = subprocess.Popen(
747
+ command,
748
+ cwd=cwd_str,
749
+ shell=shell,
750
+ env=prepared_env,
751
+ stdout=subprocess.DEVNULL,
752
+ stderr=subprocess.DEVNULL,
753
+ )
754
+ return process.pid
@@ -0,0 +1,185 @@
1
+ """Subprocess streaming utilities with inactivity watchdog support.
2
+
3
+ Provides real-time line-by-line stdout streaming from subprocesses,
4
+ with an optional inactivity watchdog that kills hung processes.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ from collections.abc import Generator
13
+
14
+ from mcp_coder_utils.subprocess_runner import (
15
+ CommandOptions,
16
+ CommandResult,
17
+ _kill_process,
18
+ prepare_env,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ __all__ = [
24
+ "StreamResult",
25
+ "stream_subprocess",
26
+ ]
27
+
28
+
29
+ class StreamResult:
30
+ """Iterator wrapper that yields stdout lines and holds the final CommandResult.
31
+
32
+ Consume all lines before accessing ``.result`` — the result is only
33
+ available after the generator is exhausted or the process terminates.
34
+ """
35
+
36
+ def __init__(self, lines: Generator[str, None, None]) -> None:
37
+ self._lines = lines
38
+ self._result: CommandResult | None = None
39
+
40
+ def __iter__(self) -> "StreamResult":
41
+ """Return the iterator object itself."""
42
+ return self
43
+
44
+ def __next__(self) -> str:
45
+ """Return the next stdout line from the stream."""
46
+ return next(self._lines)
47
+
48
+ @property
49
+ def result(self) -> CommandResult:
50
+ """Return the final CommandResult after the stream is consumed.
51
+
52
+ Raises:
53
+ RuntimeError: If the iterator has not been fully consumed yet.
54
+ """
55
+ if self._result is None:
56
+ raise RuntimeError("Result not available yet — consume all lines first.")
57
+ return self._result
58
+
59
+
60
+ def stream_subprocess(
61
+ command: list[str],
62
+ options: CommandOptions | None = None,
63
+ inactivity_timeout_seconds: float | None = None,
64
+ ) -> StreamResult:
65
+ """Stream stdout lines from a subprocess with optional inactivity watchdog.
66
+
67
+ Yields one line at a time (with trailing newline stripped). When the
68
+ iterator is exhausted the caller can access the final
69
+ :class:`CommandResult` via ``stream_result.result``.
70
+
71
+ Args:
72
+ command: Command and arguments as a list.
73
+ options: Execution options (timeout_seconds in options is *not*
74
+ used — use *inactivity_timeout_seconds* for watchdog control).
75
+ inactivity_timeout_seconds: Kill the process if no stdout line is
76
+ received for this many seconds. ``None`` disables the watchdog.
77
+
78
+ Returns:
79
+ A :class:`StreamResult` that yields ``str`` lines and exposes
80
+ ``.result`` after iteration.
81
+
82
+ Raises:
83
+ TypeError: If command is None.
84
+ ValueError: If command is empty.
85
+ """
86
+ if command is None:
87
+ raise TypeError("Command cannot be None")
88
+ if not command:
89
+ raise ValueError("Command cannot be empty")
90
+
91
+ if options is None:
92
+ options = CommandOptions()
93
+
94
+ def _generate() -> Generator[str, None, None]:
95
+ env = prepare_env(command, options.env, options.env_remove)
96
+ start_new_session = os.name != "nt"
97
+ start_time = time.time()
98
+
99
+ process = subprocess.Popen(
100
+ command,
101
+ stdout=subprocess.PIPE,
102
+ stderr=subprocess.PIPE,
103
+ cwd=options.cwd,
104
+ text=True,
105
+ encoding="utf-8",
106
+ errors="replace",
107
+ env=env,
108
+ shell=options.shell,
109
+ start_new_session=start_new_session,
110
+ )
111
+
112
+ last_activity = time.time()
113
+ watchdog_triggered = False
114
+ stop_watchdog = threading.Event()
115
+
116
+ def _watchdog() -> None:
117
+ nonlocal watchdog_triggered
118
+ while not stop_watchdog.is_set():
119
+ if (time.time() - last_activity) > (inactivity_timeout_seconds or 0):
120
+ watchdog_triggered = True
121
+ logger.warning(
122
+ "Inactivity watchdog triggered",
123
+ extra={
124
+ "pid": process.pid,
125
+ "inactivity_timeout_seconds": inactivity_timeout_seconds,
126
+ },
127
+ )
128
+ _kill_process(process, logger)
129
+ return
130
+ stop_watchdog.wait(0.5)
131
+
132
+ # Collect stderr in a background thread to avoid pipe-buffer deadlock
133
+ stderr_chunks: list[str] = []
134
+
135
+ def _drain_stderr() -> None:
136
+ assert process.stderr is not None
137
+ for line in process.stderr:
138
+ stderr_chunks.append(line)
139
+
140
+ stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
141
+ stderr_thread.start()
142
+
143
+ watchdog_thread: threading.Thread | None = None
144
+ if inactivity_timeout_seconds is not None:
145
+ watchdog_thread = threading.Thread(target=_watchdog, daemon=True)
146
+ watchdog_thread.start()
147
+
148
+ try:
149
+ assert process.stdout is not None # guaranteed by PIPE
150
+ for raw_line in process.stdout:
151
+ last_activity = time.time()
152
+ yield raw_line.rstrip("\n").rstrip("\r")
153
+
154
+ process.wait()
155
+
156
+ finally:
157
+ stop_watchdog.set()
158
+ if watchdog_thread is not None:
159
+ watchdog_thread.join(timeout=2)
160
+ stderr_thread.join(timeout=5.0)
161
+
162
+ stderr = "".join(stderr_chunks)
163
+
164
+ execution_time_ms = int((time.time() - start_time) * 1000)
165
+
166
+ stream_result._result = CommandResult( # noqa: W0212
167
+ return_code=(
168
+ process.returncode if process.returncode is not None else 1
169
+ ),
170
+ stdout="", # stdout was streamed line-by-line
171
+ stderr=stderr,
172
+ timed_out=watchdog_triggered,
173
+ execution_error=(
174
+ f"Process killed due to inactivity "
175
+ f"(no output for {inactivity_timeout_seconds}s)"
176
+ if watchdog_triggered
177
+ else None
178
+ ),
179
+ command=command,
180
+ runner_type="streaming",
181
+ execution_time_ms=execution_time_ms,
182
+ )
183
+
184
+ stream_result = StreamResult(lines=_generate())
185
+ return stream_result
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-coder-utils
3
+ Version: 0.1.2
4
+ Summary: Shared low-level Python helpers (subprocess, logging, fs) for the mcp-coder family of repos
5
+ Author-email: Marcus Jellinghaus <Marcus@Jellinghaus.ch>
6
+ Project-URL: Homepage, https://github.com/MarcusJellinghaus/mcp-coder-utils
7
+ Project-URL: Repository, https://github.com/MarcusJellinghaus/mcp-coder-utils
8
+ Project-URL: Bug Tracker, https://github.com/MarcusJellinghaus/mcp-coder-utils/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: structlog>=23.2.0
17
+ Requires-Dist: python-json-logger>=3.3.0
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest; extra == "test"
20
+ Requires-Dist: pytest-xdist; extra == "test"
21
+ Provides-Extra: dev
22
+ Requires-Dist: mcp-coder-utils[test]; extra == "dev"
23
+ Requires-Dist: black>=23.0.0; extra == "dev"
24
+ Requires-Dist: isort>=5.12.0; extra == "dev"
25
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
26
+ Requires-Dist: pylint>=3.0.0; extra == "dev"
27
+ Requires-Dist: ruff>=0.9.0; extra == "dev"
28
+ Requires-Dist: vulture>=2.14; extra == "dev"
29
+ Requires-Dist: import-linter>=2.0; extra == "dev"
30
+ Requires-Dist: mcp-coder; extra == "dev"
31
+ Requires-Dist: mcp-tools-py; extra == "dev"
32
+ Requires-Dist: mcp-workspace; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # mcp-coder-utils
36
+
37
+ Shared low-level Python helpers (subprocess, logging, fs) for the mcp-coder family of repos.
38
+
39
+ Leaf library: no internal dependencies, language-agnostic, safe to import from any MCP server or client in the mcp-coder ecosystem.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install mcp-coder-utils
45
+ ```
46
+
47
+ ## Development
48
+
49
+ ```bash
50
+ tools\reinstall_local.bat
51
+ ```
52
+
53
+ This creates a local `.venv`, installs the package in editable mode with dev dependencies, and overrides the sibling repos (`mcp-coder`, `mcp-tools-py`, `mcp-workspace`) with their latest GitHub versions.
54
+
55
+ ## Related repos
56
+
57
+ - [mcp_coder](https://github.com/MarcusJellinghaus/mcp_coder) — CLI client and workflows
58
+ - [mcp-tools-py](https://github.com/MarcusJellinghaus/mcp-tools-py) — MCP server: Python code checks
59
+ - [mcp-workspace](https://github.com/MarcusJellinghaus/mcp-workspace) — MCP server: file operations
60
+ - [mcp-config](https://github.com/MarcusJellinghaus/mcp-config) — MCP client config CLI
@@ -0,0 +1,9 @@
1
+ mcp_coder_utils/__init__.py,sha256=zIYS61fxzcuUU2plZeWwo6MSmn84BHwQHOvYmj42nOk,73
2
+ mcp_coder_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ mcp_coder_utils/subprocess_runner.py,sha256=SJoZRJ78sFQipLj6Oo2NN7TyU8aEEmOFAczPzxI2ktA,25949
4
+ mcp_coder_utils/subprocess_streaming.py,sha256=pIgXSVSLI7wvYmostD3cVI7jwcjAfFU_4pIF2lU21fQ,6107
5
+ mcp_coder_utils-0.1.2.dist-info/licenses/LICENSE,sha256=hRZqBWHdMOHKnst3xDIqKmPhe-pzCwlBEI9nQHABRkU,1075
6
+ mcp_coder_utils-0.1.2.dist-info/METADATA,sha256=rF-vcqgwkcsftNdZJ5d-Wc9wtgmBQlXtpiVsn8Z8ofw,2418
7
+ mcp_coder_utils-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ mcp_coder_utils-0.1.2.dist-info/top_level.txt,sha256=mQ8w7ACfLD2rnarBYUVjYwW1yWqVXFHhzsA46O3PWIg,16
9
+ mcp_coder_utils-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcus Jellinghaus
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
+ mcp_coder_utils