Skip to content

Commit

Permalink
Создание новой функции execute с улучшенным выполнением команд и обра…
Browse files Browse the repository at this point in the history
…боткой ошибок (#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
3 people authored Dec 16, 2024
1 parent e7c0f6d commit e35eb98
Show file tree
Hide file tree
Showing 6 changed files with 347 additions and 1 deletion.
Empty file added openvair/libs/cli/__init__.py
Empty file.
31 changes: 31 additions & 0 deletions openvair/libs/cli/exceptions.py
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"""

...
145 changes: 145 additions & 0 deletions openvair/libs/cli/executor.py
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
68 changes: 68 additions & 0 deletions openvair/libs/cli/models.py
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.',
)
99 changes: 99 additions & 0 deletions openvair/libs/cli/test_executor.py
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')
5 changes: 4 additions & 1 deletion ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ ignore = [
"__init__.py" = ["D104"]
"config.py" = ["D100"]

# Ignore Use of assert detected for tests
"test*.py" = ["S101"]

# Ignore import rules for main.py
"main.py" = ["E402"]

Expand All @@ -165,7 +168,7 @@ allow-multiline = false

# Do not lint calling this functions in args of another function.
[lint.flake8-bugbear]
extend-immutable-calls = ["fastapi.Depends", "fastapi.Query", "fastapi.File"]
extend-immutable-calls = ["fastapi.Depends", "fastapi.Query", "fastapi.File", "openvair.libs.cli.models.ExecuteParams"]

# isort configuration
[lint.isort]
Expand Down

0 comments on commit e35eb98

Please sign in to comment.