"""Local command execution helpers. These mirror the remote SSH helpers but execute commands on the API host itself. They are used for the built-in local monitoring machine. """ from __future__ import annotations import json import logging import posixpath import shlex import subprocess from dataclasses import dataclass logger = logging.getLogger(__name__) @dataclass class CommandResult: """Plain result object returned by local command execution.""" command: str exit_status: int stdout: str stderr: str class LocalCommandClient: """Execute the same POSIX shell snippets used by the SSH client locally.""" def __init__(self, timeout: int = 20): self.timeout = timeout def run(self, command: str, timeout: int | None = None) -> CommandResult: shell_command = ["/bin/sh", "-c", command] logger.debug("Local run timeout=%s command=%s", timeout or self.timeout, command) proc = subprocess.run( shell_command, capture_output=True, text=True, timeout=timeout or self.timeout, ) result = CommandResult( command=command, exit_status=proc.returncode, stdout=proc.stdout, stderr=proc.stderr, ) if result.exit_status == 0: logger.debug("Local command ok exit_status=%s", result.exit_status) else: logger.warning( "Local command failed exit_status=%s stderr=%s", result.exit_status, result.stderr.strip() or result.stdout.strip(), ) return result def list_dir(self, path: str) -> CommandResult: quoted = shlex.quote(path) not_dir_message = shlex.quote(f"Not a directory: {path}") command = ( f"test -d {quoted} || " f"{{ echo {not_dir_message} >&2; exit 20; }}; " f"find {quoted} -maxdepth 1 -mindepth 1 -printf " "'%y\\t%s\\t%T@\\t%f\\0' | python3 -c " + shlex.quote( "import sys,json; data=sys.stdin.buffer.read().split(b'\\0'); " "rows=[]\n" "for row in data:\n" " if not row: continue\n" " t,s,m,n=row.decode('utf-8','replace').split('\\t',3)\n" " rows.append({'type':t,'size':int(s),'mtime':float(m),'name':n})\n" "print(json.dumps(rows))" ) ) return self.run(command) def stat_path(self, path: str) -> CommandResult: quoted = shlex.quote(path) return self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}") def ffprobe_json(self, path: str) -> dict[str, object]: quoted = shlex.quote(path) result = self.run( "ffprobe -v error -show_format -show_streams -print_format json " + quoted, timeout=60, ) if result.exit_status != 0: raise RuntimeError(result.stderr or result.stdout or "ffprobe failed") return json.loads(result.stdout) @staticmethod def join(parent: str, child: str) -> str: return posixpath.normpath(posixpath.join(parent, child))