forked from TrueCloudLab/frostfs-testlib
[#132] Add steps logger and refactor reporter usage
Signed-off-by: Andrey Berezin <a.berezin@yadro.com>
This commit is contained in:
parent
47414eb866
commit
39a17f3634
9 changed files with 163 additions and 16 deletions
|
@ -1,6 +1,7 @@
|
||||||
from frostfs_testlib.reporter.allure_handler import AllureHandler
|
from frostfs_testlib.reporter.allure_handler import AllureHandler
|
||||||
from frostfs_testlib.reporter.interfaces import ReporterHandler
|
from frostfs_testlib.reporter.interfaces import ReporterHandler
|
||||||
from frostfs_testlib.reporter.reporter import Reporter
|
from frostfs_testlib.reporter.reporter import Reporter
|
||||||
|
from frostfs_testlib.reporter.steps_logger import StepsLogger
|
||||||
|
|
||||||
__reporter = Reporter()
|
__reporter = Reporter()
|
||||||
|
|
||||||
|
@ -15,3 +16,7 @@ def get_reporter() -> Reporter:
|
||||||
Singleton reporter instance.
|
Singleton reporter instance.
|
||||||
"""
|
"""
|
||||||
return __reporter
|
return __reporter
|
||||||
|
|
||||||
|
|
||||||
|
def step(title: str):
|
||||||
|
return __reporter.step(title)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import os
|
import os
|
||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager, ContextDecorator
|
||||||
from textwrap import shorten
|
from textwrap import shorten
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ from frostfs_testlib.reporter.interfaces import ReporterHandler
|
||||||
class AllureHandler(ReporterHandler):
|
class AllureHandler(ReporterHandler):
|
||||||
"""Handler that stores test artifacts in Allure report."""
|
"""Handler that stores test artifacts in Allure report."""
|
||||||
|
|
||||||
def step(self, name: str) -> AbstractContextManager:
|
def step(self, name: str) -> AbstractContextManager | ContextDecorator:
|
||||||
name = shorten(name, width=140, placeholder="...")
|
name = shorten(name, width=140, placeholder="...")
|
||||||
return allure.step(name)
|
return allure.step(name)
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager, ContextDecorator
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ class ReporterHandler(ABC):
|
||||||
"""Interface of handler that stores test artifacts in some reporting tool."""
|
"""Interface of handler that stores test artifacts in some reporting tool."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def step(self, name: str) -> AbstractContextManager:
|
def step(self, name: str) -> AbstractContextManager | ContextDecorator:
|
||||||
"""Register a new step in test execution.
|
"""Register a new step in test execution.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
|
@ -5,6 +5,7 @@ from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from frostfs_testlib.plugins import load_plugin
|
from frostfs_testlib.plugins import load_plugin
|
||||||
from frostfs_testlib.reporter.interfaces import ReporterHandler
|
from frostfs_testlib.reporter.interfaces import ReporterHandler
|
||||||
|
from frostfs_testlib.utils.func_utils import format_by_args
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
@ -63,7 +64,8 @@ class Reporter:
|
||||||
def wrapper(*a, **kw):
|
def wrapper(*a, **kw):
|
||||||
resulting_func = func
|
resulting_func = func
|
||||||
for handler in self.handlers:
|
for handler in self.handlers:
|
||||||
decorator = handler.step_decorator(name)
|
parsed_name = format_by_args(func, name, *a, **kw)
|
||||||
|
decorator = handler.step_decorator(parsed_name)
|
||||||
resulting_func = decorator(resulting_func)
|
resulting_func = decorator(resulting_func)
|
||||||
|
|
||||||
return resulting_func(*a, **kw)
|
return resulting_func(*a, **kw)
|
||||||
|
@ -81,11 +83,11 @@ class Reporter:
|
||||||
Returns:
|
Returns:
|
||||||
Step context.
|
Step context.
|
||||||
"""
|
"""
|
||||||
if not self.handlers:
|
|
||||||
return _empty_step()
|
|
||||||
|
|
||||||
step_contexts = [handler.step(name) for handler in self.handlers]
|
step_contexts = [handler.step(name) for handler in self.handlers]
|
||||||
return AggregateContextManager(step_contexts)
|
if not step_contexts:
|
||||||
|
step_contexts = [_empty_step()]
|
||||||
|
decorated_wrapper = self.step_deco(name)
|
||||||
|
return AggregateContextManager(step_contexts, decorated_wrapper)
|
||||||
|
|
||||||
def attach(self, content: Any, file_name: str) -> None:
|
def attach(self, content: Any, file_name: str) -> None:
|
||||||
"""Attach specified content with given file name to the test report.
|
"""Attach specified content with given file name to the test report.
|
||||||
|
@ -104,9 +106,10 @@ class AggregateContextManager(AbstractContextManager):
|
||||||
|
|
||||||
contexts: list[AbstractContextManager]
|
contexts: list[AbstractContextManager]
|
||||||
|
|
||||||
def __init__(self, contexts: list[AbstractContextManager]) -> None:
|
def __init__(self, contexts: list[AbstractContextManager], decorated_wrapper: Callable) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.contexts = contexts
|
self.contexts = contexts
|
||||||
|
self.wrapper = decorated_wrapper
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
for context in self.contexts:
|
for context in self.contexts:
|
||||||
|
@ -127,3 +130,6 @@ class AggregateContextManager(AbstractContextManager):
|
||||||
# If all context agreed to suppress exception, then suppress it;
|
# If all context agreed to suppress exception, then suppress it;
|
||||||
# otherwise return None to reraise
|
# otherwise return None to reraise
|
||||||
return True if all(suppress_decisions) else None
|
return True if all(suppress_decisions) else None
|
||||||
|
|
||||||
|
def __call__(self, *args: Any, **kwds: Any) -> Any:
|
||||||
|
return self.wrapper(*args, **kwds)
|
||||||
|
|
56
src/frostfs_testlib/reporter/steps_logger.py
Normal file
56
src/frostfs_testlib/reporter/steps_logger.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from contextlib import AbstractContextManager, ContextDecorator
|
||||||
|
from functools import wraps
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from frostfs_testlib.reporter.interfaces import ReporterHandler
|
||||||
|
|
||||||
|
|
||||||
|
class StepsLogger(ReporterHandler):
|
||||||
|
"""Handler that prints steps to log."""
|
||||||
|
|
||||||
|
def step(self, name: str) -> AbstractContextManager | ContextDecorator:
|
||||||
|
return StepLoggerContext(name)
|
||||||
|
|
||||||
|
def step_decorator(self, name: str) -> Callable:
|
||||||
|
return StepLoggerContext(name)
|
||||||
|
|
||||||
|
def attach(self, body: Any, file_name: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StepLoggerContext(AbstractContextManager):
|
||||||
|
INDENT = {}
|
||||||
|
|
||||||
|
def __init__(self, title: str):
|
||||||
|
self.title = title
|
||||||
|
self.logger = logging.getLogger("NeoLogger")
|
||||||
|
self.thread = threading.get_ident()
|
||||||
|
if self.thread not in StepLoggerContext.INDENT:
|
||||||
|
StepLoggerContext.INDENT[self.thread] = 1
|
||||||
|
|
||||||
|
def __enter__(self) -> Any:
|
||||||
|
indent = ">" * StepLoggerContext.INDENT[self.thread]
|
||||||
|
self.logger.info(f"[{self.thread}] {indent} {self.title}")
|
||||||
|
StepLoggerContext.INDENT[self.thread] += 1
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
__exc_type: type[BaseException] | None,
|
||||||
|
__exc_value: BaseException | None,
|
||||||
|
__traceback: TracebackType | None,
|
||||||
|
) -> bool | None:
|
||||||
|
|
||||||
|
StepLoggerContext.INDENT[self.thread] -= 1
|
||||||
|
indent = "<" * StepLoggerContext.INDENT[self.thread]
|
||||||
|
self.logger.info(f"[{self.thread}] {indent} {self.title}")
|
||||||
|
|
||||||
|
def __call__(self, func):
|
||||||
|
@wraps(func)
|
||||||
|
def impl(*a, **kw):
|
||||||
|
with self:
|
||||||
|
return func(*a, **kw)
|
||||||
|
|
||||||
|
return impl
|
|
@ -42,7 +42,7 @@ def parallel(
|
||||||
exceptions = [future.exception() for future in futures if future.exception()]
|
exceptions = [future.exception() for future in futures if future.exception()]
|
||||||
if exceptions:
|
if exceptions:
|
||||||
message = "\n".join([str(e) for e in exceptions])
|
message = "\n".join([str(e) for e in exceptions])
|
||||||
raise RuntimeError(f"The following exceptions occured during parallel run:\n {message}")
|
raise RuntimeError(f"The following exceptions occured during parallel run:\n{message}")
|
||||||
return futures
|
return futures
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,9 @@ from typing import Any
|
||||||
from _pytest.outcomes import Failed
|
from _pytest.outcomes import Failed
|
||||||
from pytest import fail
|
from pytest import fail
|
||||||
|
|
||||||
|
from frostfs_testlib import reporter
|
||||||
|
from frostfs_testlib.utils.func_utils import format_by_args
|
||||||
|
|
||||||
logger = logging.getLogger("NeoLogger")
|
logger = logging.getLogger("NeoLogger")
|
||||||
|
|
||||||
# TODO: we may consider deprecating some methods here and use tenacity instead
|
# TODO: we may consider deprecating some methods here and use tenacity instead
|
||||||
|
@ -50,7 +53,7 @@ class expect_not_raises:
|
||||||
return impl
|
return impl
|
||||||
|
|
||||||
|
|
||||||
def retry(max_attempts: int, sleep_interval: int = 1, expected_result: Any = None):
|
def retry(max_attempts: int, sleep_interval: int = 1, expected_result: Any = None, title: str = None):
|
||||||
"""
|
"""
|
||||||
Decorator to wait for some conditions/functions to pass successfully.
|
Decorator to wait for some conditions/functions to pass successfully.
|
||||||
This is useful if you don't know exact time when something should pass successfully and do not
|
This is useful if you don't know exact time when something should pass successfully and do not
|
||||||
|
@ -62,8 +65,7 @@ def retry(max_attempts: int, sleep_interval: int = 1, expected_result: Any = Non
|
||||||
assert max_attempts >= 1, "Cannot apply retry decorator with max_attempts < 1"
|
assert max_attempts >= 1, "Cannot apply retry decorator with max_attempts < 1"
|
||||||
|
|
||||||
def wrapper(func):
|
def wrapper(func):
|
||||||
@wraps(func)
|
def call(func, *a, **kw):
|
||||||
def impl(*a, **kw):
|
|
||||||
last_exception = None
|
last_exception = None
|
||||||
for _ in range(max_attempts):
|
for _ in range(max_attempts):
|
||||||
try:
|
try:
|
||||||
|
@ -84,6 +86,14 @@ def retry(max_attempts: int, sleep_interval: int = 1, expected_result: Any = Non
|
||||||
if last_exception is not None:
|
if last_exception is not None:
|
||||||
raise last_exception
|
raise last_exception
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def impl(*a, **kw):
|
||||||
|
if title is not None:
|
||||||
|
with reporter.step(format_by_args(func, title, *a, **kw)):
|
||||||
|
return call(func, *a, **kw)
|
||||||
|
|
||||||
|
return call(func, *a, **kw)
|
||||||
|
|
||||||
return impl
|
return impl
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
@ -124,6 +134,7 @@ def wait_for_success(
|
||||||
expected_result: Any = None,
|
expected_result: Any = None,
|
||||||
fail_testcase: bool = False,
|
fail_testcase: bool = False,
|
||||||
fail_message: str = "",
|
fail_message: str = "",
|
||||||
|
title: str = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Decorator to wait for some conditions/functions to pass successfully.
|
Decorator to wait for some conditions/functions to pass successfully.
|
||||||
|
@ -134,8 +145,7 @@ def wait_for_success(
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def wrapper(func):
|
def wrapper(func):
|
||||||
@wraps(func)
|
def call(func, *a, **kw):
|
||||||
def impl(*a, **kw):
|
|
||||||
start = int(round(time()))
|
start = int(round(time()))
|
||||||
last_exception = None
|
last_exception = None
|
||||||
while start + max_wait_time >= int(round(time())):
|
while start + max_wait_time >= int(round(time())):
|
||||||
|
@ -160,6 +170,14 @@ def wait_for_success(
|
||||||
if last_exception is not None:
|
if last_exception is not None:
|
||||||
raise last_exception
|
raise last_exception
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def impl(*a, **kw):
|
||||||
|
if title is not None:
|
||||||
|
with reporter.step(format_by_args(func, title, *a, **kw)):
|
||||||
|
return call(func, *a, **kw)
|
||||||
|
|
||||||
|
return call(func, *a, **kw)
|
||||||
|
|
||||||
return impl
|
return impl
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
"""
|
||||||
|
Idea of utils is to have small utilitary functions which are not dependent of anything.
|
||||||
|
"""
|
||||||
|
|
||||||
import frostfs_testlib.utils.converting_utils
|
import frostfs_testlib.utils.converting_utils
|
||||||
import frostfs_testlib.utils.datetime_utils
|
import frostfs_testlib.utils.datetime_utils
|
||||||
import frostfs_testlib.utils.json_utils
|
import frostfs_testlib.utils.json_utils
|
||||||
|
|
58
src/frostfs_testlib/utils/func_utils.py
Normal file
58
src/frostfs_testlib/utils/func_utils.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import collections
|
||||||
|
import inspect
|
||||||
|
import sys
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
def format_by_args(__func: Callable, __title: str, *a, **kw) -> str:
|
||||||
|
params = _func_parameters(__func, *a, **kw)
|
||||||
|
args = list(map(lambda x: _represent(x), a))
|
||||||
|
|
||||||
|
return __title.format(*args, **params)
|
||||||
|
|
||||||
|
|
||||||
|
# These 2 functions are copied from allure_commons._allure
|
||||||
|
# Duplicate it here in order to be independent of allure and make some adjustments.
|
||||||
|
def _represent(item):
|
||||||
|
if isinstance(item, str):
|
||||||
|
return item
|
||||||
|
elif isinstance(item, (bytes, bytearray)):
|
||||||
|
return repr(type(item))
|
||||||
|
else:
|
||||||
|
return repr(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _func_parameters(func, *args, **kwargs):
|
||||||
|
parameters = {}
|
||||||
|
arg_spec = inspect.getfullargspec(func)
|
||||||
|
arg_order = list(arg_spec.args)
|
||||||
|
args_dict = dict(zip(arg_spec.args, args))
|
||||||
|
|
||||||
|
if arg_spec.defaults:
|
||||||
|
kwargs_defaults_dict = dict(zip(arg_spec.args[-len(arg_spec.defaults) :], arg_spec.defaults))
|
||||||
|
parameters.update(kwargs_defaults_dict)
|
||||||
|
|
||||||
|
if arg_spec.varargs:
|
||||||
|
arg_order.append(arg_spec.varargs)
|
||||||
|
varargs = args[len(arg_spec.args) :]
|
||||||
|
parameters.update({arg_spec.varargs: varargs} if varargs else {})
|
||||||
|
|
||||||
|
if arg_spec.args and arg_spec.args[0] in ["cls", "self"]:
|
||||||
|
args_dict.pop(arg_spec.args[0], None)
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
if sys.version_info < (3, 7):
|
||||||
|
# Sort alphabetically as old python versions does
|
||||||
|
# not preserve call order for kwargs.
|
||||||
|
arg_order.extend(sorted(list(kwargs.keys())))
|
||||||
|
else:
|
||||||
|
# Keep py3.7 behaviour to preserve kwargs order
|
||||||
|
arg_order.extend(list(kwargs.keys()))
|
||||||
|
parameters.update(kwargs)
|
||||||
|
|
||||||
|
parameters.update(args_dict)
|
||||||
|
|
||||||
|
items = parameters.items()
|
||||||
|
sorted_items = sorted(map(lambda kv: (kv[0], _represent(kv[1])), items), key=lambda x: arg_order.index(x[0]))
|
||||||
|
|
||||||
|
return collections.OrderedDict(sorted_items)
|
Loading…
Reference in a new issue