forked from TrueCloudLab/frostfs-testlib
Implement basic version of local shell
Also added two simple reporters that can be used by the shell to report command execution details. Signed-off-by: Vladimir Domnich <v.domnich@yadro.com>
This commit is contained in:
parent
c0bbfd1705
commit
f6ee129354
11 changed files with 453 additions and 0 deletions
14
reporter/__init__.py
Normal file
14
reporter/__init__.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .allure_reporter import AllureReporter
|
||||||
|
from .interfaces import Reporter
|
||||||
|
from .dummy_reporter import DummyReporter
|
||||||
|
|
||||||
|
|
||||||
|
def get_reporter() -> Reporter:
|
||||||
|
# TODO: in scope of reporter implementation task here we will have extendable
|
||||||
|
# solution for configuring and providing reporter for the library
|
||||||
|
if os.getenv("TESTLIB_REPORTER_TYPE", "DUMMY") == "DUMMY":
|
||||||
|
return DummyReporter()
|
||||||
|
else:
|
||||||
|
return AllureReporter()
|
36
reporter/allure_reporter.py
Normal file
36
reporter/allure_reporter.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import os
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
|
from textwrap import shorten
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import allure
|
||||||
|
from allure import attachment_type
|
||||||
|
|
||||||
|
from .interfaces import Reporter
|
||||||
|
|
||||||
|
|
||||||
|
class AllureReporter(Reporter):
|
||||||
|
"""
|
||||||
|
Implements storing of test artifacts in Allure report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def step(self, name: str) -> AbstractContextManager:
|
||||||
|
name = shorten(name, width=70, placeholder="...")
|
||||||
|
return allure.step(name)
|
||||||
|
|
||||||
|
def attach(self, body: Any, file_name: str) -> None:
|
||||||
|
attachment_name, extension = os.path.splitext(file_name)
|
||||||
|
attachment_type = self._resolve_attachment_type(extension)
|
||||||
|
|
||||||
|
allure.attach(body, attachment_name, attachment_type)
|
||||||
|
|
||||||
|
def _resolve_attachment_type(self, extension: str) -> attachment_type:
|
||||||
|
"""
|
||||||
|
Try to find matching Allure attachment type by extension. If no match was found,
|
||||||
|
default to TXT format.
|
||||||
|
"""
|
||||||
|
extension = extension.lower()
|
||||||
|
return next(
|
||||||
|
(allure_type for allure_type in attachment_type if allure_type.extension == extension),
|
||||||
|
attachment_type.TXT,
|
||||||
|
)
|
21
reporter/dummy_reporter.py
Normal file
21
reporter/dummy_reporter.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
from contextlib import AbstractContextManager, contextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .interfaces import Reporter
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _dummy_step():
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
class DummyReporter(Reporter):
|
||||||
|
"""
|
||||||
|
Dummy implementation of reporter, does not store artifacts anywhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def step(self, name: str) -> AbstractContextManager:
|
||||||
|
return _dummy_step()
|
||||||
|
|
||||||
|
def attach(self, content: Any, file_name: str) -> None:
|
||||||
|
pass
|
29
reporter/interfaces.py
Normal file
29
reporter/interfaces.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Reporter(ABC):
|
||||||
|
"""
|
||||||
|
Interface that supports storage of test artifacts in some reporting tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def step(self, name: str) -> AbstractContextManager:
|
||||||
|
"""
|
||||||
|
Register a new step in test execution.
|
||||||
|
|
||||||
|
:param str name: Name of the step
|
||||||
|
:return: step context
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def attach(self, content: Any, file_name: str) -> None:
|
||||||
|
"""
|
||||||
|
Attach specified content with given file name to the test report.
|
||||||
|
|
||||||
|
:param any name: content to attach. If not a string, it will be converted to a string.
|
||||||
|
:param str file_name: file name of attachment.
|
||||||
|
"""
|
||||||
|
pass
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
allure-python-commons==2.9.45
|
||||||
|
pexpect==4.8.0
|
0
shell/__init__.py
Normal file
0
shell/__init__.py
Normal file
59
shell/interfaces.py
Normal file
59
shell/interfaces.py
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InteractiveInput:
|
||||||
|
"""
|
||||||
|
Interactive input for a shell command.
|
||||||
|
|
||||||
|
:attr str prompt_pattern: regular expression that defines expected prompt from the command.
|
||||||
|
:attr str input: user input that should be supplied to the command in response to the prompt.
|
||||||
|
"""
|
||||||
|
prompt_pattern: str
|
||||||
|
input: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandOptions:
|
||||||
|
"""
|
||||||
|
Options that control command execution.
|
||||||
|
|
||||||
|
:attr list interactive_inputs: user inputs that should be interactively supplied to
|
||||||
|
the command during its' execution.
|
||||||
|
:attr int timeout: timeout for command execution (in seconds).
|
||||||
|
:attr bool check: controls whether to check return code of the command. Set to False to
|
||||||
|
ignore non-zero return codes.
|
||||||
|
"""
|
||||||
|
interactive_inputs: Optional[list[InteractiveInput]] = None
|
||||||
|
timeout: int = 30
|
||||||
|
check: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandResult:
|
||||||
|
"""
|
||||||
|
Represents a result of a command executed via shell.
|
||||||
|
"""
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
return_code: int
|
||||||
|
|
||||||
|
|
||||||
|
class Shell(ABC):
|
||||||
|
"""
|
||||||
|
Interface of a command shell on some system (local or remote).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def exec(self, command: str, options: Optional[CommandOptions] = None) -> CommandResult:
|
||||||
|
"""
|
||||||
|
Executes specified command on this shell. To execute interactive command, user inputs
|
||||||
|
should be specified in *options*.
|
||||||
|
|
||||||
|
:param str command: command to execute on the shell.
|
||||||
|
:param CommandOptions options: options that control command execution.
|
||||||
|
:return command result.
|
||||||
|
"""
|
||||||
|
pass
|
159
shell/local_shell.py
Normal file
159
shell/local_shell.py
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import IO, Optional
|
||||||
|
|
||||||
|
import pexpect
|
||||||
|
|
||||||
|
from reporter import get_reporter
|
||||||
|
from shell.interfaces import CommandOptions, CommandResult, Shell
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("neofs.testlib.shell")
|
||||||
|
reporter = get_reporter()
|
||||||
|
|
||||||
|
|
||||||
|
class LocalShell(Shell):
|
||||||
|
def exec(self, command: str, options: Optional[CommandOptions] = None) -> CommandResult:
|
||||||
|
# If no options were provided, use default options
|
||||||
|
options = options or CommandOptions()
|
||||||
|
|
||||||
|
logger.info(f"Executing command: {command}")
|
||||||
|
if options.interactive_inputs:
|
||||||
|
return self._exec_interactive(command, options)
|
||||||
|
return self._exec_non_interactive(command, options)
|
||||||
|
|
||||||
|
def _exec_interactive(self, command: str, options: CommandOptions) -> CommandResult:
|
||||||
|
start_time = datetime.utcnow()
|
||||||
|
log_file = tempfile.TemporaryFile() # File is reliable cross-platform way to capture output
|
||||||
|
result = None
|
||||||
|
command_process = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
command_process = pexpect.spawn(command, timeout=options.timeout)
|
||||||
|
command_process.delaybeforesend = 1
|
||||||
|
command_process.logfile_read = log_file
|
||||||
|
|
||||||
|
for interactive_input in options.interactive_inputs:
|
||||||
|
command_process.expect(interactive_input.prompt_pattern)
|
||||||
|
command_process.sendline(interactive_input.input)
|
||||||
|
|
||||||
|
result = self._get_pexpect_process_result(command_process, command)
|
||||||
|
if options.check and result.return_code != 0:
|
||||||
|
raise RuntimeError(f"Command: {command}\nreturn code: {result.return_code}\nOutput: {result.stdout}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
except pexpect.ExceptionPexpect as exc:
|
||||||
|
result = self._get_pexpect_process_result(command_process, command)
|
||||||
|
message = f"Command: {command}\nreturn code: {result.return_code}\nOutput: {result.stdout}"
|
||||||
|
if options.check:
|
||||||
|
raise RuntimeError(message) from exc
|
||||||
|
else:
|
||||||
|
logger.exception(message)
|
||||||
|
return result
|
||||||
|
except OSError as exc:
|
||||||
|
result = self._get_pexpect_process_result(command_process, command)
|
||||||
|
message = f"Command: {command}\nreturn code: {result.return_code}\nOutput: {exc.strerror}"
|
||||||
|
if options.check:
|
||||||
|
raise RuntimeError(message) from exc
|
||||||
|
else:
|
||||||
|
logger.exception(message)
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
result = self._get_pexpect_process_result(command_process, command)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
log_file.close()
|
||||||
|
end_time = datetime.utcnow()
|
||||||
|
self._report_command_result(command, start_time, end_time, result)
|
||||||
|
|
||||||
|
def _exec_non_interactive(self, command: str, options: CommandOptions) -> CommandResult:
|
||||||
|
start_time = datetime.utcnow()
|
||||||
|
result = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
command_process = subprocess.run(
|
||||||
|
command,
|
||||||
|
check=options.check,
|
||||||
|
universal_newlines=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
timeout=options.timeout,
|
||||||
|
shell=True
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CommandResult(
|
||||||
|
stdout=command_process.stdout or "",
|
||||||
|
stderr=command_process.stderr or "",
|
||||||
|
return_code=command_process.returncode,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
# TODO: always set check flag to false and capture command result normally
|
||||||
|
result = self._get_failing_command_result(command)
|
||||||
|
raise RuntimeError(f"Command: {command}\nError:\n"
|
||||||
|
f"return code: {exc.returncode}\n"
|
||||||
|
f"output: {exc.output}") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"Command: {command}\nOutput: {exc.strerror}") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
result = self._get_failing_command_result(command)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
end_time = datetime.utcnow()
|
||||||
|
self._report_command_result(command, start_time, end_time, result)
|
||||||
|
|
||||||
|
def _get_failing_command_result(self, command: str) -> CommandResult:
|
||||||
|
return_code, cmd_output = subprocess.getstatusoutput(command)
|
||||||
|
return CommandResult(
|
||||||
|
stdout=cmd_output,
|
||||||
|
stderr="",
|
||||||
|
return_code=return_code
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_pexpect_process_result(self, command_process: Optional[pexpect.spawn],
|
||||||
|
command: str) -> CommandResult:
|
||||||
|
"""
|
||||||
|
If command process is not None, captures output of this process.
|
||||||
|
If command process is None, then command fails when we attempt to start it, in this case
|
||||||
|
we use regular non-interactive process to get it's output.
|
||||||
|
"""
|
||||||
|
if command_process is None:
|
||||||
|
return self._get_failing_command_result(command)
|
||||||
|
|
||||||
|
# Wait for child process to end it's work
|
||||||
|
if command_process.isalive():
|
||||||
|
command_process.expect(pexpect.EOF)
|
||||||
|
|
||||||
|
# Close the process to obtain the exit code
|
||||||
|
command_process.close()
|
||||||
|
return_code = command_process.exitstatus
|
||||||
|
|
||||||
|
# Capture output from the log file
|
||||||
|
log_file: IO[bytes] = command_process.logfile_read
|
||||||
|
log_file.seek(0)
|
||||||
|
output = log_file.read().decode()
|
||||||
|
|
||||||
|
return CommandResult(stdout=output, stderr="", return_code=return_code)
|
||||||
|
|
||||||
|
def _report_command_result(self, command: str, start_time: datetime, end_time: datetime,
|
||||||
|
result: Optional[CommandResult]) -> None:
|
||||||
|
# TODO: increase logging level if return code is non 0, should be warning at least
|
||||||
|
logger.info(
|
||||||
|
f"Command: {command}\n"
|
||||||
|
f"{'Success:' if result and result.return_code == 0 else 'Error:'}\n"
|
||||||
|
f"return code: {result.return_code if result else ''} "
|
||||||
|
f"\nOutput: {result.stdout if result else ''}")
|
||||||
|
|
||||||
|
if result:
|
||||||
|
elapsed_time = end_time - start_time
|
||||||
|
command_attachment = (
|
||||||
|
f"COMMAND: {command}\n"
|
||||||
|
f"RETCODE: {result.return_code}\n\n"
|
||||||
|
f"STDOUT:\n{result.stdout}\n"
|
||||||
|
f"STDERR:\n{result.stderr}\n"
|
||||||
|
f"Start / End / Elapsed\t {start_time.time()} / {end_time.time()} / {elapsed_time}"
|
||||||
|
)
|
||||||
|
with reporter.step(f"COMMAND: {command}"):
|
||||||
|
reporter.attach(command_attachment, "Command execution.txt")
|
9
tests/helpers.py
Normal file
9
tests/helpers.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
def format_error_details(error: Exception) -> str:
|
||||||
|
return "".join(traceback.format_exception(
|
||||||
|
etype=type(error),
|
||||||
|
value=error,
|
||||||
|
tb=error.__traceback__)
|
||||||
|
)
|
78
tests/test_local_shell_interactive.py
Normal file
78
tests/test_local_shell_interactive.py
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from shell.interfaces import CommandOptions, InteractiveInput
|
||||||
|
from shell.local_shell import LocalShell
|
||||||
|
from tests.helpers import format_error_details
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocalShellInteractive(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.shell = LocalShell()
|
||||||
|
|
||||||
|
def test_command_with_one_prompt(self):
|
||||||
|
script = "password = input('Password: '); print(password)"
|
||||||
|
|
||||||
|
inputs = [InteractiveInput(prompt_pattern="Password", input="test")]
|
||||||
|
result = self.shell.exec(
|
||||||
|
f"python -c \"{script}\"",
|
||||||
|
CommandOptions(interactive_inputs=inputs)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.return_code)
|
||||||
|
self.assertOutputLines(["Password: test", "test"], result.stdout)
|
||||||
|
self.assertEqual("", result.stderr)
|
||||||
|
|
||||||
|
def test_command_with_several_prompts(self):
|
||||||
|
script = (
|
||||||
|
"input1 = input('Input1: '); print(input1); "
|
||||||
|
"input2 = input('Input2: '); print(input2)"
|
||||||
|
)
|
||||||
|
inputs = [
|
||||||
|
InteractiveInput(prompt_pattern="Input1", input="test1"),
|
||||||
|
InteractiveInput(prompt_pattern="Input2", input="test2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = self.shell.exec(
|
||||||
|
f"python -c \"{script}\"",
|
||||||
|
CommandOptions(interactive_inputs=inputs)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.return_code)
|
||||||
|
self.assertOutputLines(["Input1: test1", "test1", "Input2: test2", "test2"], result.stdout)
|
||||||
|
self.assertEqual("", result.stderr)
|
||||||
|
|
||||||
|
def test_failed_command_with_check(self):
|
||||||
|
script = "invalid script"
|
||||||
|
inputs = [InteractiveInput(prompt_pattern=".*", input="test")]
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError) as exc:
|
||||||
|
self.shell.exec(f"python -c \"{script}\"", CommandOptions(interactive_inputs=inputs))
|
||||||
|
|
||||||
|
error = format_error_details(exc.exception)
|
||||||
|
self.assertIn("Error", error)
|
||||||
|
# TODO: it would be nice to have return code as well
|
||||||
|
# self.assertIn("return code: 1", error)
|
||||||
|
|
||||||
|
def test_failed_command_without_check(self):
|
||||||
|
script = "invalid script"
|
||||||
|
inputs = [InteractiveInput(prompt_pattern=".*", input="test")]
|
||||||
|
|
||||||
|
result = self.shell.exec(
|
||||||
|
f"python -c \"{script}\"",
|
||||||
|
CommandOptions(interactive_inputs=inputs, check=False),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, result.return_code)
|
||||||
|
|
||||||
|
def test_non_existing_binary(self):
|
||||||
|
inputs = [InteractiveInput(prompt_pattern=".*", input="test")]
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError) as exc:
|
||||||
|
self.shell.exec("not-a-command", CommandOptions(interactive_inputs=inputs))
|
||||||
|
|
||||||
|
error = format_error_details(exc.exception)
|
||||||
|
self.assertIn("command was not found or was not executable", error)
|
||||||
|
|
||||||
|
def assertOutputLines(self, expected_lines: list[str], output: str) -> None:
|
||||||
|
output_lines = [line.strip() for line in output.split("\n") if line.strip()]
|
||||||
|
self.assertEqual(expected_lines, output_lines)
|
46
tests/test_local_shell_non_interactive.py
Normal file
46
tests/test_local_shell_non_interactive.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from shell.interfaces import CommandOptions
|
||||||
|
from shell.local_shell import LocalShell
|
||||||
|
from tests.helpers import format_error_details
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocalShellNonInteractive(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.shell = LocalShell()
|
||||||
|
|
||||||
|
def test_successful_command(self):
|
||||||
|
script = "print('test')"
|
||||||
|
|
||||||
|
result = self.shell.exec(f"python -c \"{script}\"")
|
||||||
|
|
||||||
|
self.assertEqual(0, result.return_code)
|
||||||
|
self.assertEqual("test", result.stdout.strip())
|
||||||
|
self.assertEqual("", result.stderr)
|
||||||
|
|
||||||
|
def test_failed_command_with_check(self):
|
||||||
|
script = "invalid script"
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError) as exc:
|
||||||
|
self.shell.exec(f"python -c \"{script}\"")
|
||||||
|
|
||||||
|
error = format_error_details(exc.exception)
|
||||||
|
self.assertIn("Error", error)
|
||||||
|
self.assertIn("return code: 1", error)
|
||||||
|
|
||||||
|
def test_failed_command_without_check(self):
|
||||||
|
script = "invalid script"
|
||||||
|
|
||||||
|
result = self.shell.exec(f"python -c \"{script}\"", CommandOptions(check=False))
|
||||||
|
|
||||||
|
self.assertEqual(1, result.return_code)
|
||||||
|
self.assertIn("Error", result.stdout)
|
||||||
|
|
||||||
|
def test_non_existing_binary(self):
|
||||||
|
with self.assertRaises(RuntimeError) as exc:
|
||||||
|
self.shell.exec(f"not-a-command")
|
||||||
|
|
||||||
|
error = format_error_details(exc.exception)
|
||||||
|
self.assertIn("Error", error)
|
||||||
|
self.assertIn("return code: 127", error)
|
Loading…
Reference in a new issue