From db9382300192ff0e1f695a050374a24149f9dc7e Mon Sep 17 00:00:00 2001 From: David Arnold Date: Wed, 9 Oct 2024 12:05:19 +0200 Subject: [PATCH] feat: better test & dumpster output (#28044) --- frappe/deprecation_dumpster.py | 26 ++++++------- frappe/testing/config.py | 1 - frappe/testing/discovery.py | 2 - frappe/testing/result.py | 67 ++++++++++++++++++++++++++++------ frappe/testing/runner.py | 7 +--- 5 files changed, 69 insertions(+), 34 deletions(-) diff --git a/frappe/deprecation_dumpster.py b/frappe/deprecation_dumpster.py index a3aa58f412..df0278289b 100644 --- a/frappe/deprecation_dumpster.py +++ b/frappe/deprecation_dumpster.py @@ -33,6 +33,10 @@ class Color: CYAN = 96 +class FrappeDeprecationWarning(Warning): + ... + + try: # since python 3.13, PEP 702 from warnings import deprecated as _deprecated @@ -44,7 +48,7 @@ except ImportError: T = TypeVar("T", bound=Callable) - def _deprecated(message: str, category=DeprecationWarning, stacklevel=1) -> Callable[[T], T]: + def _deprecated(message: str, category=FrappeDeprecationWarning, stacklevel=1) -> Callable[[T], T]: def decorator(func: T) -> T: @functools.wraps(func) def wrapper(*args, **kwargs): @@ -86,18 +90,14 @@ def deprecated(original: str, marked: str, graduation: str, msg: str, stacklevel + colorize(caller_filepath, Color.CYAN) ) - return functools.wraps(func)( - _deprecated( - colorize(f"`{original}`", Color.CYAN) - + colorize( - f" was moved (DATE: {marked}) to frappe/deprecation_dumpster.py" - f" for removal (from {graduation} onwards); note:\n ", - Color.RED, - ) - + colorize(f"{msg}\n", Color.YELLOW), - stacklevel=stacklevel, - ) - )(func) + func.__name__ = original + wrapper = _deprecated( + colorize(f"It was marked on {marked} for removal from {graduation} with note: ", Color.RED) + + colorize(f"{msg}", Color.YELLOW), + stacklevel=stacklevel, + ) + + return functools.update_wrapper(wrapper, func)(func) return decorator diff --git a/frappe/testing/config.py b/frappe/testing/config.py index ea5e4fc058..b44d165f12 100644 --- a/frappe/testing/config.py +++ b/frappe/testing/config.py @@ -1,5 +1,4 @@ from dataclasses import dataclass, field -from typing import Optional, Union @dataclass diff --git a/frappe/testing/discovery.py b/frappe/testing/discovery.py index 7eadbd1ed3..d29b1824c3 100644 --- a/frappe/testing/discovery.py +++ b/frappe/testing/discovery.py @@ -136,5 +136,3 @@ def _add_module_tests(runner, app: str, module: str): class TestRunnerError(Exception): """Custom exception for test runner errors""" - - pass diff --git a/frappe/testing/result.py b/frappe/testing/result.py index 15f23200e7..342efef301 100644 --- a/frappe/testing/result.py +++ b/frappe/testing/result.py @@ -17,7 +17,9 @@ This TestResult class is typically used by the TestRunner to collect and display test results during test execution in the Frappe framework. """ +import io import logging +import sys import time import unittest @@ -27,6 +29,20 @@ logger = logging.getLogger(__name__) class TestResult(unittest.TextTestResult): + def __init__(self, stream, descriptions, verbosity): + super().__init__(stream, descriptions, verbosity) + self._old_stdout = [] + self._old_stderr = [] + + def startTestRun(self): + if not sys.warnoptions: + import warnings + + from frappe.deprecation_dumpster import FrappeDeprecationWarning + + warnings.simplefilter("ignore") + warnings.filterwarnings("module", category=FrappeDeprecationWarning) + def startTest(self, test): self.tb_locals = True self._started_at = time.monotonic() @@ -34,15 +50,32 @@ class TestResult(unittest.TextTestResult): test_class = unittest.util.strclass(test.__class__) if getattr(self, "current_test_class", None) != test_class: self.current_test_class = test_class - click.echo(f"\n{unittest.util.strclass(test.__class__)}") - logger.info(f"{unittest.util.strclass(test.__class__)}") + self.stream.write(f"\n{test_class}\n") + logger.info(f"{test_class}") if new_doctypes := getattr(test.__class__, "_newly_created_test_records", None): records = [f"{name} ({qty})" for name, qty in reversed(new_doctypes)] - click.secho( - f" Test Records created: {', '.join(records)}", - fg="bright_black", - ) + hint = click.style(f" Test Records created: {', '.join(records)}", fg="bright_black") + self.stream.write(hint + "\n") logger.info(f"records created: {', '.join(records)}") + self.stream.flush() + + self._old_stderr.append(sys.stderr) + self._old_stdout.append(sys.stdout) + self._test_stdout_capture = io.StringIO() + self._test_stderr_capture = io.StringIO() + sys.stdout = self._test_stdout_capture + sys.stderr = self._test_stderr_capture + + def stopTest(self, test): + super().stopTest(test) + sys.stdout = self._old_stderr.pop() + sys.stderr = self._old_stdout.pop() + for line in self._test_stdout_capture.getvalue().splitlines(): + self.stream.write(f" ▹ {line}\n") + self.stream.flush() + for line in self._test_stderr_capture.getvalue().splitlines(): + self.stream.write(f" ▸ {line}\n") + self.stream.flush() def getTestMethodName(self, test): return test._testMethodName if hasattr(test, "_testMethodName") else str(test) @@ -52,32 +85,34 @@ class TestResult(unittest.TextTestResult): elapsed = time.monotonic() - self._started_at threshold_passed = elapsed >= SLOW_TEST_THRESHOLD long_elapsed = click.style(f" ({elapsed:.03}s)", fg="red") if threshold_passed else "" - click.echo(f" {click.style(' ✔ ', fg='green')} {self.getTestMethodName(test)}{long_elapsed}") + self._write_result(test, " ✔ ", "green", long_elapsed) logger.debug(f"{test!s:<200} {'[success]':>20} ⌛{elapsed}") def addError(self, test, err): super(unittest.TextTestResult, self).addError(test, err) - click.echo(f" {click.style(' ✖ ', fg='red')} {self.getTestMethodName(test)}") + self._write_result(test, " ✖ ", "red") logger.debug(f"{test!s:<200} {'[error]':>20}") def addFailure(self, test, err): super(unittest.TextTestResult, self).addFailure(test, err) - click.echo(f" {click.style(' ✖ ', fg='red')} {self.getTestMethodName(test)}") + self._write_result(test, " ✖ ", "red") logger.debug(f"{test!s:<200} {'[failure]':>20}") def addSkip(self, test, reason): super(unittest.TextTestResult, self).addSkip(test, reason) - click.echo(f" {click.style(' = ', fg='white')} {self.getTestMethodName(test)}") + self._write_result(test, " = ", "white") logger.debug(f"{test!s:<200} {'[skipped]':>20}") def addExpectedFailure(self, test, err): super(unittest.TextTestResult, self).addExpectedFailure(test, err) - click.echo(f" {click.style(' ✖ ', fg='red')} {self.getTestMethodName(test)}") + self.stream.write("x") + self._write_result(test, "✔ ", "green") logger.debug(f"{test!s:<200} {'[expected failure]':>20}") def addUnexpectedSuccess(self, test): super(unittest.TextTestResult, self).addUnexpectedSuccess(test) - click.echo(f" {click.style(' ✔ ', fg='green')} {self.getTestMethodName(test)}") + self.stream.write("u") + self._write_result(test, "✖ ", "red") logger.debug(f"{test!s:<200} {'[unexpected success]':>20}") def printErrors(self): @@ -95,5 +130,13 @@ class TestResult(unittest.TextTestResult): def __str__(self): return f"Tests: {self.testsRun}, Failing: {len(self.failures)}, Errors: {len(self.errors)}" + def _write_result(self, test, status, color, suffix=""): + test_method = self.getTestMethodName(test) + result = f" {click.style(status, fg=color)} {test_method}" + result += f" {suffix}" if suffix else "" + result += "\n" + self.stream.write(result) + self.stream.flush() + SLOW_TEST_THRESHOLD = 2 diff --git a/frappe/testing/runner.py b/frappe/testing/runner.py index 92f9acc066..0b839abfb6 100644 --- a/frappe/testing/runner.py +++ b/frappe/testing/runner.py @@ -21,17 +21,12 @@ It can be customized through the TestConfig object passed during initialization. import contextlib import cProfile import logging -import os import pstats import unittest from collections import defaultdict from collections.abc import Iterator from io import StringIO -from pathlib import Path -import click - -import frappe from frappe.tests.classes.context_managers import debug_on from .config import TestConfig @@ -59,7 +54,7 @@ class TestRunner(unittest.TextTestRunner): failfast=False, buffer=False, resultclass=None, - warnings=None, + warnings="module", *, tb_locals=False, cfg: TestConfig,