#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # Copyright (C) 1998-2026 Stephane Galland # # This program is free library; you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as # published by the Free Software Foundation; either version 3 of the # License, or any later version. # # This library is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; see the file COPYING. If not, # write to the Free Software Foundation, Inc., 59 Temple Place - Suite # 330, Boston, MA 02111-1307, USA. """ Abstract implementation of a command and script runner. """ import sys import io import os import subprocess from abc import ABC from typing import override from dataclasses import dataclass from autolatex2.utils.i18n import T @dataclass class ScriptOutput: """ Represents the information that is output by an interpreter. """ standard_output : str error_output : str exception : BaseException | None return_code : int class CommandExecutionError(Exception): def __init__(self, return_code : int, msg : str = None): """ Construct the exception with the given return code. :param return_code: The return code of the executed command. :type return_code: int :param msg: Error message. :type msg: str """ self.__errno = return_code if msg: self.__strerror = T('Error during the execution of the command: %s') % msg else: self.__strerror = T('Error during the execution of the command; return code is %d') % return_code @property def errno(self) -> int: """ Replies the number of the error, usually, the return code of executed command. :return: The number of the error. :rtype: int """ return self.__errno @property def strerror(self) -> str: """ Replies the error message. :return: The error message. :rtype: str """ return self.__strerror @override def __str__(self) -> str: return self.strerror class Runner(ABC): """ Definition of an abstract implementation of a command and script runner. """ @staticmethod def check_runner_status(runner_output : ScriptOutput): """ Helper function that generate the correct running behavior regarding the status of a command. :param runner_output: The definition of the output provided by the runner. """ if runner_output.exception: raise runner_output.exception elif runner_output.return_code != 0: if runner_output.error_output: raise Exception(runner_output.error_output) else: raise Exception(T("Error when running the command. Return code is %d") % runner_output.return_code) @staticmethod def check_runner_exit_code(code : int): """ Helper function that generate the correct running behavior regarding the exit code of a command. :param code: exit code of a command. :type code: int """ if code != 0: raise Exception(T("Erroneous command with exit code %d") % code) @staticmethod def run_python(script : str, intercept_error : bool = False, local_variables : dict = None, show_script_on_error : bool = True) -> ScriptOutput: """ Run a Python script in the current process. :param script: The Python script to run. :type script: str :param intercept_error: Indicates if all the exception are intercepted and put inside the returned value. If False, the exceptions are not intercepted, and they are raised by this function. Default value is: False. :type intercept_error: bool :param local_variables: Dictionary of the predefined elements (imports or local variables) :type local_variables: dict :param show_script_on_error: Indicates if the script must be output on the standard error output in case of an error. Default is True. :type show_script_on_error: bool :return: An output containing the standard output, the error output, the error and the exit code. :rtype: ScriptOutput """ script = script + "\n" code_out = io.StringIO() code_err = io.StringIO() saved_stdout = sys.stdout saved_stderr = sys.stderr sys.stdout = code_out sys.stderr = code_err exception = None try: if intercept_error: try: exec(script, None, local_variables) except BaseException as e: exception = e else: try: exec(script, None, local_variables) except BaseException as err: if show_script_on_error: saved_stderr.write(str(err)) saved_stderr.write(Runner.__format_script(script)) raise err finally: sys.stdout = saved_stdout sys.stderr = saved_stderr sout = code_out.getvalue() serr = code_err.getvalue() code_out.close() code_err.close() return ScriptOutput(standard_output=sout, error_output=serr, exception=exception, return_code=0 if exception is None else 255) @staticmethod def __format_script(script : str) -> str: lines = script.split("\n") nlines = len(lines) pattern = "%d" % nlines s = len(pattern) pattern = "%" + str(s) + "d %s" for i in range(nlines): nl = str(pattern) % ((i+1), lines[i]) lines[i] = nl return "\n" + ("\n".join(lines)) @staticmethod def run_command(*cmd : str) -> ScriptOutput: """ Run an external command in a subprocess. :param cmd: The command line to run. :type cmd: list[str] :return: A quadruplet containing the standard output, the error output, and the exception, the return code. :rtype: ScriptOutput """ return Runner.run_command_to(None, *cmd) @staticmethod def run_command_to(redirect_stdout_to : str | None, *cmd : str) -> ScriptOutput: """ Run an external command in a subprocess. :param redirect_stdout_to: Specify the path of the file that must receive the standard output. :type redirect_stdout_to: str :param cmd: The command line to run. :type cmd: list :return: A quadruplet containing the standard output, the error output, and the exception, the return code. :rtype: ScriptOutput """ if redirect_stdout_to is not None: with open(redirect_stdout_to, "w") as stdout_file: out = subprocess.Popen(cmd, shell=False, stdout=stdout_file, stderr=subprocess.PIPE) sout, serr = out.communicate() return_code = out.returncode else: out = subprocess.Popen(cmd, shell=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE) sout, serr = out.communicate() return_code = out.returncode if return_code != 0: sex = CommandExecutionError(return_code, str(serr)) else: sex = None if sout is not None and isinstance(sout, bytes): sout = sout.decode() if serr is not None and isinstance(serr, bytes): serr = serr.decode() return ScriptOutput(standard_output=sout or '', error_output=serr or '', exception=sex, return_code=return_code) @staticmethod def run_command_without_redirect(*cmd : str) -> int: """ Run an external command in a subprocess without redirecting the input and outputs, and wait for the termination of the subprocess. :param cmd: The command line to run. :type cmd: list :return: exit code :rtype: int """ completed_process = subprocess.run(cmd) if completed_process: return completed_process.returncode return 255 @staticmethod def start_command_without_redirect(*cmd : str) -> bool: """ Run an external command in a subprocess without redirecting the input and outputs, and do not wait for the termination of the subprocess. :param cmd: The command line to run. :type cmd: list :return: True if the command was launched, False otherwise :rtype: bool """ proc = subprocess.Popen(cmd, shell=False) return proc is not None @staticmethod def normalize_command(*cmd : str) -> list[str]: """ Ensure that the command (the first element of the list) is a command with an absolute path. :param cmd: The command line to run. :type cmd: str :rtype: list[str] """ cmdl = list(cmd) c = cmdl[0] if not os.path.isabs(c): env_path = os.getenv("PATH") if env_path: for p in env_path.split(os.pathsep): fn = os.path.join(p, c) if os.path.exists(fn): cmdl = cmdl[1:] cmdl.insert(0, fn) return cmdl return cmdl @staticmethod def run_script(script : str, *interpreter : str) -> ScriptOutput: """ Run a script with the given interpreter. The script is passed to the interpreter on the standard input. The command line of the interpreter must be specified in order to run the interpreter and read the script from the standard input. :param script: The script to run. :type script: str :param interpreter: The command line of the interpreter to use. :type interpreter: str :return: An output containing the standard output, the error output, the exception, the return code. :rtype: ScriptOutput """ out = subprocess.Popen(interpreter, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) sin = script.encode("ascii") sout, serr = out.communicate(input = sin) if out.returncode != 0: sex = CommandExecutionError(out.returncode, str(serr)) else: sex = None if sout is not None and isinstance(sout, bytes): sout = sout.decode() if serr is not None and isinstance(serr, bytes): serr = serr.decode() return ScriptOutput(standard_output=sout or '', error_output=serr or '', exception=sex, return_code=out.returncode)