Files
Ansible/scripts/src/aim/external.py
T
2026-09-15 18:54:22 +02:00

107 lines
3.7 KiB
Python

from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
import os
import shutil
import signal
import subprocess
from pathlib import Path
from typing import Sequence
from aim.exceptions import ExternalCommandFailed, OperationCancelled
_presenter = ContextVar('aim_external_presenter', default=None)
@contextmanager
def external_presentation(presenter):
"""Install presentation per application context, not in business managers."""
token = _presenter.set(presenter)
try:
yield
finally:
_presenter.reset(token)
def stable_cwd(preferred: Path | None = None) -> Path:
candidates = [preferred, Path('/etc/ansible'), Path.home(), Path('/')]
for candidate in candidates:
if candidate is None:
continue
try:
if candidate.is_dir():
return candidate
except OSError:
pass
return Path('/')
def command_available(command: str) -> bool:
return shutil.which(command) is not None
def stop_process(process: subprocess.Popen, *, group: bool = False) -> None:
"""Reap a cancelled command; do not claim to undo work on remote systems."""
if process.poll() is not None:
return
try:
if group and os.name == 'posix':
os.killpg(process.pid, signal.SIGTERM)
else:
process.terminate()
process.wait(timeout=3)
except subprocess.TimeoutExpired:
if group and os.name == 'posix':
os.killpg(process.pid, signal.SIGKILL)
else:
process.kill()
process.wait()
except ProcessLookupError:
process.wait()
def execute(args, kwargs):
"""Default terminal behavior, including native password/editor interaction."""
process = subprocess.Popen(list(args), **kwargs)
try:
stdout, stderr = process.communicate()
return subprocess.CompletedProcess(list(args), process.returncode, stdout, stderr)
finally:
# Runs on KeyboardInterrupt, SIGTERM/SystemExit, or a regular exception.
stop_process(process)
def run_external(args: Sequence[str], *, cwd: Path | None = None,
capture_output: bool = False, suppress_stdout: bool = False,
capture_stdout: bool = False, capture_stderr: bool = False,
text: bool = True, env: dict[str, str] | None = None,
ui_mode: str = 'auto') -> subprocess.CompletedProcess:
if (capture_output or capture_stdout) and suppress_stdout:
raise ValueError('capture_output and suppress_stdout are mutually exclusive')
if not args:
raise ValueError('An external command is required')
kwargs = {'cwd': stable_cwd(cwd), 'text': text}
if env is not None:
kwargs['env'] = env
if capture_output:
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
elif capture_stdout:
kwargs['stdout'] = subprocess.PIPE
elif suppress_stdout:
kwargs['stdout'] = subprocess.DEVNULL
if capture_stderr:
kwargs['stderr'] = subprocess.PIPE
try:
presenter = _presenter.get()
if presenter:
return presenter(list(args), kwargs, capture_output=capture_output or capture_stdout or capture_stderr,
suppress_stdout=suppress_stdout, ui_mode=ui_mode)
return execute(args, kwargs)
except KeyboardInterrupt:
raise OperationCancelled('External command interrupted. Completed remote work is not rolled back.') from None
except FileNotFoundError as exc:
raise ExternalCommandFailed(f'Required command not found: {args[0]}') from exc
except OSError as exc:
raise ExternalCommandFailed(f'Could not execute {args[0]}: {exc}') from exc