[#132] Add steps logger and refactor reporter usage

Signed-off-by: Andrey Berezin <a.berezin@yadro.com>
This commit is contained in:
Andrey Berezin 2023-11-28 12:28:44 +03:00 committed by Andrey Berezin
parent 47414eb866
commit 39a17f3634
9 changed files with 163 additions and 16 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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)

View 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

View file

@ -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

View file

@ -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

View 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)