-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Создание новой функции execute с улучшенным выполнением команд и обра…
…боткой ошибок (#46) * Create mirror-to-gitflic.yml (#39) (#40) * Create mirror-to-gitflic.yml (#39) Co-authored-by: tihon49 <tihon49@gmail.com> * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml * Update mirror-to-gitflic.yml --------- Co-authored-by: tihon49 <tihon49@gmail.com> * tools: adds new execute method * adds docstring to execute2 * add parameter raise_on_error for execute2 * adds trying to gracefull terminate for execute2 * refacts execute2 return statements * refacts execute2 and move running command into other func * makes run_command as private func * creates shared model dir and creates exectution return model * renames dir for models * moves new executor to libs and renames it to execute * moves execution model to cli package * adds strip for stderr and stdout in execute * adds basic unit tests for execute * excludes S101 for tests in ruff.toml * fixes format * creates ExecuteParam incapsulation * renames execution_models to models * fiexes loggibg for executor * change path to execute params model * changes inherits for execution exeptions --------- Co-authored-by: tihon49 <tihon49@gmail.com> Co-authored-by: Aerodisk <167296471+Aerodisk@users.noreply.github.com>
- Loading branch information
1 parent
e7c0f6d
commit e35eb98
Showing
6 changed files
with
347 additions
and
1 deletion.
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
"""Defines custom exceptions for handling errors during command execution. | ||
Classes: | ||
ExecuteTimeoutExpiredError: Raised when a command execution exceeds the | ||
specified timeout. | ||
ExecuteError: General exception for command execution errors. | ||
Dependencies: | ||
openvair.abstracts.base_exception: Provides the BaseCustomException class | ||
for creating custom exceptions. | ||
""" | ||
|
||
from openvair.abstracts.base_exception import BaseCustomException | ||
|
||
|
||
class ExecuteError(BaseCustomException): | ||
"""General exception for command execution errors.""" | ||
|
||
... | ||
|
||
|
||
class ExecuteTimeoutExpiredError(ExecuteError): | ||
"""Raised when a command execution reaches its timeout.""" | ||
|
||
... | ||
|
||
|
||
class UnsuccessReturnCodeError(ExecuteError): | ||
"""Raised when getting unsuccesses return code""" | ||
|
||
... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
"""Executor module for running shell commands with advanced error handling. | ||
Functions: | ||
__terminate_process: Attempts to gracefully terminate a subprocess and | ||
forcefully kills it if necessary. | ||
__prepare_env: Prepares the environment variables for command execution. | ||
execute: Executes a shell command with optional root privileges, | ||
timeout, and error handling. | ||
""" | ||
|
||
import os | ||
from typing import Dict, List | ||
from subprocess import PIPE, Popen, TimeoutExpired | ||
|
||
from openvair.libs.log import get_logger | ||
from openvair.libs.cli.models import ExecuteParams, ExecutionResult | ||
from openvair.libs.cli.exceptions import ( | ||
UnsuccessReturnCodeError, | ||
ExecuteTimeoutExpiredError, | ||
) | ||
|
||
LOG = get_logger(__name__) | ||
|
||
|
||
def __terminate_process(proc: Popen, cmd_str: str) -> str: | ||
"""Terminates a subprocess gracefully, kills it if termination fails. | ||
Attempts to gracefully terminate a subprocess and forcefully kill it | ||
if termination fails. | ||
Args: | ||
proc (Popen): The subprocess to be terminated. | ||
cmd_str (str): The command string representing the subprocess for | ||
logging purposes. | ||
Returns: | ||
str: The standard error output (stderr) collected from the subprocess | ||
after termination. | ||
""" | ||
LOG.warning(f"Command '{cmd_str}' timed out. Terminating process.") | ||
proc.terminate() | ||
try: | ||
proc.wait(timeout=5) | ||
except TimeoutExpired: | ||
LOG.error(f"Command '{cmd_str}' did not terminate. Killing it.") | ||
proc.kill() | ||
_, stderr = proc.communicate() | ||
return str(stderr) | ||
|
||
|
||
def __prepare_env(env_params: Dict[str, str]) -> Dict[str, str]: | ||
"""Prepares the environment variables for the command execution. | ||
Args: | ||
env_params (Dict[str, str]): Key-value pairs representing environment | ||
variables to be added or overridden. | ||
Returns: | ||
Dict[str, str]: The merged environment variables, combining the current | ||
environment and the provided `env_params`. | ||
""" | ||
env_vars = os.environ.copy() # Copy current environment | ||
for var, val in env_params.items(): | ||
env_vars[var] = val | ||
return env_vars | ||
|
||
|
||
def execute( | ||
*args: str, | ||
params: ExecuteParams = ExecuteParams(), | ||
) -> ExecutionResult: | ||
"""Executes a shell command and returns its stdout, stderr, and exit code. | ||
Args: | ||
*args (str): The command and its arguments to execute. Each argument | ||
must be passed as a separate string. | ||
params (ExecuteParams): A Pydantic model containing command execution | ||
parameters such as `shell`, `timeout`, `env`, etc. | ||
Returns: | ||
ExecutionResult: A model containing: | ||
- `returncode` (int): The exit code of the command. | ||
- `stdout` (str): Standard output of the command. | ||
- `stderr` (str): Standard error output of the command. | ||
Raises: | ||
ExecuteTimeoutExpiredError: Raised if the command execution exceeds the | ||
specified timeout. | ||
UnsuccessReturnCodeError: Raised if the command exits with a non-zero | ||
return code and `raise_on_error` is True. | ||
OSError: Raised for system-level errors, such as command not found or | ||
permission issues. | ||
Example: | ||
>>> params = ExecuteParams(shell=True, timeout=10, raise_on_error=True) | ||
>>> result = execute('ls', '-la', params=params) | ||
>>> print(result.stdout) | ||
""" | ||
cmd: List[str] = list(args) | ||
if params.run_as_root and hasattr(os, 'geteuid') and os.geteuid() != 0: | ||
cmd = [params.root_helper, *cmd] | ||
|
||
cmd_str = ' '.join(cmd) | ||
LOG.info(f'Executing command: {cmd_str}') | ||
try: | ||
with Popen( # noqa: S603 | ||
cmd_str if params.shell else cmd, | ||
shell=params.shell, | ||
stdout=PIPE, | ||
stderr=PIPE, | ||
stdin=PIPE, | ||
text=True, | ||
env=__prepare_env(params.env) if params.env else None, | ||
) as proc: | ||
try: | ||
stdout, stderr = proc.communicate(timeout=params.timeout) | ||
returncode = proc.returncode | ||
LOG.info( | ||
f"Command '{cmd_str}' completed with return code: " | ||
f'{returncode}' | ||
) | ||
|
||
if params.raise_on_error and returncode != 0: | ||
message = ( | ||
f"Command '{cmd_str}' failed with return code " | ||
f'{returncode}' | ||
) | ||
LOG.error(message) | ||
raise UnsuccessReturnCodeError(message) | ||
|
||
return ExecutionResult( | ||
returncode=returncode, | ||
stdout=stdout.strip() or '', | ||
stderr=stderr.strip() or '', | ||
) | ||
except TimeoutExpired: | ||
stderr = __terminate_process(proc, cmd_str) | ||
message = ( | ||
f"Command '{cmd_str}' timed out and was killed.\n" | ||
f'Error: {stderr}' | ||
) | ||
raise ExecuteTimeoutExpiredError(message) | ||
except OSError as err: | ||
LOG.error(f"OS error for command '{cmd_str}': {err}") | ||
raise |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
"""This module defines the models for command execution parameters and results. | ||
Classes: | ||
ExecutionResult: Represents the outcome of a command execution. | ||
ExecuteParams: Encapsulates parameters for executing a shell command. | ||
""" | ||
|
||
from typing import Dict, Optional | ||
|
||
from pydantic import Field, BaseModel | ||
|
||
|
||
class ExecutionResult(BaseModel): | ||
"""Represents the result of a command execution. | ||
Attributes: | ||
returncode (int): The exit code of the executed command. | ||
stdout (str): The standard output produced by the command. | ||
stderr (str): The standard error output produced by the command. | ||
""" | ||
|
||
returncode: int | ||
stdout: str | ||
stderr: str | ||
|
||
|
||
class ExecuteParams(BaseModel): | ||
"""Encapsulates parameters for executing a shell command. | ||
Attributes: | ||
shell (bool): If True, the command will be executed through the shell. | ||
run_as_root (bool): If True, the command will be executed with root | ||
privileges. | ||
root_helper (str): Command used to elevate privileges, such as 'sudo'. | ||
timeout (Optional[float]): Maximum time in seconds to wait for the | ||
command to complete. | ||
env (Optional[Dict[str, str]]): Environment variables for the command. | ||
raise_on_error (bool): If True, raises an exception if the command | ||
fails. | ||
""" | ||
|
||
shell: bool = Field( | ||
default=False, | ||
description='If True, the command will be executed through the shell.', | ||
) | ||
run_as_root: bool = Field( | ||
default=False, | ||
description=( | ||
'If True, the command will be executed with root privileges.' | ||
), | ||
) | ||
root_helper: str = Field( | ||
default='sudo', | ||
description="Command used to elevate privileges, such as 'sudo'.", | ||
) | ||
timeout: Optional[float] = Field( | ||
default=None, | ||
description=( | ||
'Maximum time in seconds to wait for the command to complete.' | ||
), | ||
) | ||
env: Optional[Dict[str, str]] = Field( | ||
default=None, description='Environment variables for the command.' | ||
) | ||
raise_on_error: bool = Field( | ||
default=False, | ||
description='If True, raises an exception if the command fails.', | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
"""Unit tests for the `execute` function in the `openvair.libs.cli.executor`. | ||
This test suite verifies the behavior of the `execute` function under various | ||
conditions, including successful execution, errors, timeouts, and invalid | ||
commands. Mocking is used to isolate the function from actual subprocess calls, | ||
ensuring that tests are deterministic and safe. | ||
Usage: | ||
Run the tests using pytest: | ||
pytest openvair/libs/cli/test_executor.py | ||
""" | ||
|
||
from typing import TYPE_CHECKING | ||
from subprocess import TimeoutExpired | ||
from unittest.mock import MagicMock, patch | ||
|
||
import pytest | ||
|
||
from openvair.libs.cli.models import ExecuteParams | ||
from openvair.libs.cli.executor import execute | ||
from openvair.libs.cli.exceptions import ( | ||
ExecuteError, | ||
ExecuteTimeoutExpiredError, | ||
) | ||
|
||
if TYPE_CHECKING: | ||
from openvair.libs.cli.models import ExecutionResult | ||
|
||
|
||
def test_execute_success() -> None: | ||
"""Test the successful execution of a command. | ||
Simulates a command execution using `subprocess.Popen` and verifies that: | ||
- The command returns the correct `stdout`. | ||
- The `returncode` is 0, indicating successful execution. | ||
- The `stderr` is empty. | ||
""" | ||
with patch('subprocess.Popen') as mock_popen: | ||
process_mock: MagicMock = MagicMock() | ||
process_mock.communicate.return_value = ('output', '') | ||
process_mock.returncode = 0 | ||
mock_popen.return_value = process_mock | ||
|
||
result: ExecutionResult = execute('echo', 'hello') | ||
|
||
assert result.returncode == 0 | ||
assert result.stdout == 'hello' | ||
assert result.stderr == '' | ||
|
||
|
||
def test_execute_with_error() -> None: | ||
"""Test execution of a command that returns an error. | ||
Simulates a command execution where the return code is non-zero and verifies | ||
that: | ||
- The `ExecuteError` exception is raised when `raise_on_error=True`. | ||
- The error details are logged correctly. | ||
""" | ||
with patch('subprocess.Popen') as mock_popen: | ||
process_mock: MagicMock = MagicMock() | ||
process_mock.communicate.return_value = ('', 'error') | ||
process_mock.returncode = 1 | ||
mock_popen.return_value = process_mock | ||
|
||
with pytest.raises(ExecuteError): | ||
execute('false', params=ExecuteParams(raise_on_error=True)) | ||
|
||
|
||
def test_execute_timeout() -> None: | ||
"""Test execution of a command that exceeds the timeout. | ||
Simulates a command execution where the process exceeds the specified | ||
timeout and verifies that: | ||
- The `ExecuteTimeoutExpiredError` exception is raised. | ||
- The timeout behavior is logged and handled correctly. | ||
""" | ||
with patch('subprocess.Popen') as mock_popen: | ||
process_mock: MagicMock = MagicMock() | ||
mock_popen.return_value = process_mock | ||
process_mock.communicate.side_effect = TimeoutExpired( | ||
cmd='sleep 1', timeout=1 | ||
) | ||
|
||
with pytest.raises(ExecuteTimeoutExpiredError): | ||
execute('sleep', '5', params=ExecuteParams(timeout=1)) | ||
|
||
|
||
def test_execute_invalid_command() -> None: | ||
"""Test execution of an invalid command. | ||
Simulates a scenario where the command to be executed does not exist and | ||
verifies that: | ||
- An `OSError` exception is raised. | ||
- The error details are captured and logged correctly. | ||
""" | ||
with patch('subprocess.Popen', side_effect=OSError()), pytest.raises( | ||
OSError | ||
): | ||
execute('invalid_command') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters