From 8f73da5031d21c534fabb2ad8f179b5461927886 Mon Sep 17 00:00:00 2001 From: Pyifan Date: Tue, 31 Dec 2024 11:59:38 +0800 Subject: [PATCH] refine import chain --- pylintrc | 2 +- testplan/base.py | 11 +- testplan/common/entity/base.py | 10 +- testplan/common/exporters/__init__.py | 39 ++++++ testplan/common/exporters/pdf.py | 117 +++++++++++------- testplan/common/utils/logger.py | 3 + testplan/common/utils/strings.py | 79 +----------- testplan/exporters/testing/__init__.py | 60 +++++++-- .../pdf/renderers/entries/assertions.py | 5 +- .../testing/pdf/renderers/entries/base.py | 3 +- .../testing/pdf/renderers/reports.py | 3 +- testplan/importers/junit.py | 1 - testplan/runnable/base.py | 33 +++-- testplan/runnable/interactive/__init__.py | 1 - testplan/runnable/interactive/base.py | 4 - testplan/testing/base.py | 3 +- testplan/testing/multitest/entries/base.py | 4 +- .../multitest/entries/stdout/assertions.py | 2 +- 18 files changed, 222 insertions(+), 158 deletions(-) diff --git a/pylintrc b/pylintrc index 0c94ab766..3c3bb63aa 100644 --- a/pylintrc +++ b/pylintrc @@ -305,7 +305,7 @@ ignored-classes=optparse.Values,thread._local,_thread._local # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. -ignored-modules= +ignored-modules=testplan.exporters.testing # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. diff --git a/testplan/base.py b/testplan/base.py index 58f69b914..9ef2b9c7a 100644 --- a/testplan/base.py +++ b/testplan/base.py @@ -8,7 +8,7 @@ import traceback import threading -from typing import Optional, Union, Type, List, Callable +from typing import Optional, Union, Type, List, Callable, TYPE_CHECKING from types import ModuleType from schema import And @@ -22,7 +22,7 @@ from testplan.environment import Environments from testplan.parser import TestplanParser from testplan.runnable import TestRunner, TestRunnerConfig, TestRunnerResult -from testplan.runnable.interactive import TestRunnerIHandler + from testplan.runners.local import LocalRunner from testplan.runners.base import Executor from testplan.report.testing.styles import Style @@ -32,6 +32,9 @@ from testplan.testing.multitest.test_metadata import TestPlanMetadata from testplan.testing.ordering import BaseSorter +if TYPE_CHECKING: + from testplan.runnable.interactive.base import TestRunnerIHandler + def pdb_drop_handler(sig, frame): """ @@ -192,7 +195,7 @@ def __init__( verbose: bool = False, debug: bool = False, timeout: int = defaults.TESTPLAN_TIMEOUT, - interactive_handler: Type[TestRunnerIHandler] = TestRunnerIHandler, + interactive_handler: Type["TestRunnerIHandler"] = None, extra_deps: Optional[List[Union[str, ModuleType]]] = None, label: Optional[str] = None, driver_info: bool = False, @@ -401,7 +404,7 @@ def main_wrapper( verbose=False, debug=False, timeout=defaults.TESTPLAN_TIMEOUT, - interactive_handler=TestRunnerIHandler, + interactive_handler=None, extra_deps=None, label=None, driver_info=False, diff --git a/testplan/common/entity/base.py b/testplan/common/entity/base.py index 16e37a1d3..c19880346 100644 --- a/testplan/common/entity/base.py +++ b/testplan/common/entity/base.py @@ -1225,13 +1225,21 @@ def run(self): """ try: if self.cfg.interactive_port is not None: + + from testplan.runnable.interactive.base import ( + TestRunnerIHandler, + ) + if self._ihandler is not None: raise RuntimeError( f"{self} already has an active {self._ihandler}" ) self.logger.user_info("Starting %s in interactive mode", self) - self._ihandler = self.cfg.interactive_handler( + handler_class = ( + self.cfg.interactive_handler or TestRunnerIHandler + ) + self._ihandler = handler_class( target=self, http_port=self.cfg.interactive_port, pre_start_environments=self.cfg.pre_start_environments, diff --git a/testplan/common/exporters/__init__.py b/testplan/common/exporters/__init__.py index 11acc2e4d..381b5df2c 100644 --- a/testplan/common/exporters/__init__.py +++ b/testplan/common/exporters/__init__.py @@ -7,6 +7,7 @@ from testplan.common.config import Config, Configurable from testplan.common.utils import strings +from testplan.common.utils.comparison import is_regex from testplan.common.utils.timing import utcnow from testplan.report import TestReport @@ -172,3 +173,41 @@ def run_exporter( exp_result.result = result export_context.results.append(exp_result) return exp_result + + +def format_cell_data(data, limit): + """ + Change the str representation of values in data if they represent regex or + lambda functions. Also limit the length of these strings. + + :param data: List of values to be formatted. + :type data: ``list`` + :param limit: The number of characters allowed in each string. + :type limit: ``int`` + :return: List of formatted and limited strings. + :rtype: ``list`` + """ + for i, value in enumerate(data): + if is_regex(value): + data[i] = "REGEX('{}')".format(value.pattern) + elif "lambda" in str(value): + data[i] = "" + + return _limit_cell_length(data, limit) + + +def _limit_cell_length(iterable, limit): + """ + Limit the length of each string in the iterable. + + :param iterable: iterable object containing string values. + :type iterable: ``list`` or ``tuple`` etc. + :param limit: The number of characters allowed in each string + :type limit: ``int`` + :return: The list of limited strings. + :rtype: ``list`` of ``str`` + """ + return [ + val if len(str(val)) < limit else "{}...".format(str(val)[: limit - 3]) + for val in iterable + ] diff --git a/testplan/common/exporters/pdf.py b/testplan/common/exporters/pdf.py index 8f38d18be..ce69e0be5 100644 --- a/testplan/common/exporters/pdf.py +++ b/testplan/common/exporters/pdf.py @@ -1,15 +1,15 @@ """ Utilities for generating pdf files via Reportlab. """ - +import re +import os import itertools from reportlab.platypus import Table from reportlab.lib import colors +from reportlab.pdfbase.pdfmetrics import stringWidth -from testplan.common.utils.comparison import is_regex -from testplan.common.exporters import constants - +from testplan.common.exporters import constants, _limit_cell_length # If you increase this too much Reportlab starts having # performance issues and takes exponentially longer to render the PDF @@ -112,44 +112,6 @@ def create_base_tables(data, style, col_widths, max_rows=MAX_TABLE_ROWS): ] -def format_cell_data(data, limit): - """ - Change the str representation of values in data if they represent regex or - lambda functions. Also limit the length of these strings. - - :param data: List of values to be formatted. - :type data: ``list`` - :param limit: The number of characters allowed in each string. - :type limit: ``int`` - :return: List of formatted and limited strings. - :rtype: ``list`` - """ - for i, value in enumerate(data): - if is_regex(value): - data[i] = "REGEX('{}')".format(value.pattern) - elif "lambda" in str(value): - data[i] = "" - - return _limit_cell_length(data, limit) - - -def _limit_cell_length(iterable, limit): - """ - Limit the length of each string in the iterable. - - :param iterable: iterable object containing string values. - :type iterable: ``list`` or ``tuple`` etc. - :param limit: The number of characters allowed in each string - :type limit: ``int`` - :return: The list of limited strings. - :rtype: ``list`` of ``str`` - """ - return [ - val if len(str(val)) < limit else "{}...".format(str(val)[: limit - 3]) - for val in iterable - ] - - def _add_row_index(columns, rows, indices): """ Add row indices as the first column to the columns and rows data. @@ -771,3 +733,74 @@ def append(self, content, style=None): # This changes `self.end`, so needs to happen last self.content.extend(content) + + +def split_line(line, max_width, get_width_func=None): + """ + Split `line` into multi-lines if width exceeds `max_width`. + + :param line: Line to be split. + :param max_width: Maximum length of each line (unit: px). + :param get_width_func: A function which computes width of string + according to font and font size. + :return: list of lines + """ + result = [] + total_width = 0 + tmp_str = "" + get_text_width = ( + get_width_func + if get_width_func + else lambda text: stringWidth(text, "Helvetica", 9) + ) + + for ch in line: + char_width = get_text_width(ch) + if total_width + char_width <= max_width or not tmp_str: + tmp_str += ch + total_width += char_width + else: + result.append(tmp_str) + tmp_str = ch + total_width = char_width + + if tmp_str: + result.append(tmp_str) + + return result + + +def split_text( + text, font_name, font_size, max_width, keep_leading_whitespace=False +): + """ + Wraps `text` within given `max_width` limit (measured in px), keeping + initial indentation of each line (and generated lines) if + `keep_leading_whitespace` is True. + + :param text: Text to be split. + :param font_name: Font name. + :param font_size: Font size. + :param max_width: Maximum length of each line (unit: px). + :param keep_leading_whitespace: each split line keeps the leading + whitespace. + :return: list of lines + """ + + def get_text_width(text, name=font_name, size=font_size): + return stringWidth(text, name, size) + + result = [] + lines = [line for line in re.split(r"[\r\n]+", text) if line] + cutoff_regex = re.compile(r"^(\s|\t)+") + for line in lines: + line_list = split_line(line, max_width, get_text_width) + if keep_leading_whitespace and len(line_list) > 1: + first, rest = line_list[0], line_list[1:] + indent_match = cutoff_regex.match(first) + if indent_match: + prefix = first[indent_match.start() : indent_match.end()] + line_list = [first] + ["{}{}".format(prefix, s) for s in rest] + result.extend(line_list) + + return os.linesep.join(result) diff --git a/testplan/common/utils/logger.py b/testplan/common/utils/logger.py index 060cadfb9..0d6a43673 100644 --- a/testplan/common/utils/logger.py +++ b/testplan/common/utils/logger.py @@ -10,6 +10,7 @@ - Test progress information (e.g. Pass / Fail status) - Exporter statuses """ + import logging import os import sys @@ -113,6 +114,7 @@ def _initial_setup(): :return: root logger object and stdout logging handler :type: ``tuple`` """ + logging.setLoggerClass(TestplanLogger) root_logger = logging.getLogger(LOGGER_NAME) @@ -139,6 +141,7 @@ def _initial_setup(): # self.logger. However, for classes that don't inherit from Loggable or for # code outside of a class we provide the root testplan.common.utils.logger # object here as TESTPLAN_LOGGER. + TESTPLAN_LOGGER, STDOUT_HANDLER = _initial_setup() diff --git a/testplan/common/utils/strings.py b/testplan/common/utils/strings.py index 11e9fc724..a3b46e52d 100644 --- a/testplan/common/utils/strings.py +++ b/testplan/common/utils/strings.py @@ -12,11 +12,6 @@ colorama.init() from termcolor import colored -from reportlab.pdfbase.pdfmetrics import stringWidth - - -_DESCRIPTION_CUTOFF_REGEX = re.compile(r"^(\s|\t)+") - def map_to_str(value): """ @@ -91,8 +86,9 @@ def format_description(description): ..Foo bar 1 2 3 4 """ + cutoff_regex = re.compile(r"^(\s|\t)+") lines = [line for line in description.split(os.linesep) if line.strip()] - matches = [_DESCRIPTION_CUTOFF_REGEX.match(line) for line in lines] + matches = [cutoff_regex.match(line) for line in lines] if matches: min_offset = min( match.end() if match is not None else 0 for match in matches @@ -187,77 +183,6 @@ def wrap(text, width=150): return os.linesep.join(result) -def split_line(line, max_width, get_width_func=None): - """ - Split `line` into multi-lines if width exceeds `max_width`. - - :param line: Line to be split. - :param max_width: Maximum length of each line (unit: px). - :param get_width_func: A function which computes width of string - according to font and font size. - :return: list of lines - """ - result = [] - total_width = 0 - tmp_str = "" - get_text_width = ( - get_width_func - if get_width_func - else lambda text: stringWidth(text, "Helvetica", 9) - ) - - for ch in line: - char_width = get_text_width(ch) - if total_width + char_width <= max_width or not tmp_str: - tmp_str += ch - total_width += char_width - else: - result.append(tmp_str) - tmp_str = ch - total_width = char_width - - if tmp_str: - result.append(tmp_str) - - return result - - -def split_text( - text, font_name, font_size, max_width, keep_leading_whitespace=False -): - """ - Wraps `text` within given `max_width` limit (measured in px), keeping - initial indentation of each line (and generated lines) if - `keep_leading_whitespace` is True. - - :param text: Text to be split. - :param font_name: Font name. - :param font_size: Font size. - :param max_width: Maximum length of each line (unit: px). - :param keep_leading_whitespace: each split line keeps the leading - whitespace. - :return: list of lines - """ - - def get_text_width(text, name=font_name, size=font_size): - return stringWidth(text, name, size) - - result = [] - lines = [line for line in re.split(r"[\r\n]+", text) if line] - - for line in lines: - line_list = split_line(line, max_width, get_text_width) - if keep_leading_whitespace and len(line_list) > 1: - first, rest = line_list[0], line_list[1:] - indent_match = _DESCRIPTION_CUTOFF_REGEX.match(first) - if indent_match: - prefix = first[indent_match.start() : indent_match.end()] - line_list = [first] + ["{}{}".format(prefix, s) for s in rest] - result.extend(line_list) - - return os.linesep.join(result) - - def indent(lines_str, indent_size=2): """ Indent a multi-line string with a common indent. diff --git a/testplan/exporters/testing/__init__.py b/testplan/exporters/testing/__init__.py index cfc482148..46b7ae43a 100644 --- a/testplan/exporters/testing/__init__.py +++ b/testplan/exporters/testing/__init__.py @@ -1,8 +1,52 @@ -from .base import Exporter -from .tagfiltered import TagFilteredExporter -from .coverage import CoveredTestsExporter -from .http import HTTPExporter -from .json import JSONExporter -from .pdf import PDFExporter, TagFilteredPDFExporter -from .webserver import WebServerExporter -from .xml import XMLExporter +# pylint: disable=undefined-all-variable +__all__ = [ + "Exporter", + "TagFilteredExporter", + "CoveredTestsExporter", + "HTTPExporter", + "JSONExporter", + "PDFExporter", + "TagFilteredPDFExporter", + "WebServerExporter", + "XMLExporter", +] +# pylint: enable=undefined-all-variable + + +def __getattr__(name): + if name == "Exporter": + from .base import Exporter + + return Exporter + elif name == "TagFilteredExporter": + from .tagfiltered import TagFilteredExporter + + return TagFilteredExporter + elif name == "CoveredTestsExporter": + from .coverage import CoveredTestsExporter + + return CoveredTestsExporter + elif name == "HTTPExporter": + from .http import HTTPExporter + + return HTTPExporter + elif name == "JSONExporter": + from .json import JSONExporter + + return JSONExporter + elif name == "PDFExporter": + from .pdf import PDFExporter + + return PDFExporter + elif name == "TagFilteredPDFExporter": + from .pdf import TagFilteredPDFExporter + + return TagFilteredPDFExporter + elif name == "WebServerExporter": + from .webserver import WebServerExporter + + return WebServerExporter + elif name == "XMLExporter": + from .xml import XMLExporter + + return XMLExporter diff --git a/testplan/exporters/testing/pdf/renderers/entries/assertions.py b/testplan/exporters/testing/pdf/renderers/entries/assertions.py index bfd7a9903..f05a4c206 100644 --- a/testplan/exporters/testing/pdf/renderers/entries/assertions.py +++ b/testplan/exporters/testing/pdf/renderers/entries/assertions.py @@ -13,8 +13,9 @@ from reportlab.pdfbase.pdfmetrics import stringWidth from testplan.common.exporters.pdf import RowStyle, create_table -from testplan.common.exporters.pdf import format_table_style, format_cell_data -from testplan.common.utils.strings import split_line, split_text +from testplan.common.exporters.pdf import format_table_style +from testplan.common.exporters import format_cell_data +from testplan.common.exporters.pdf import split_line, split_text from testplan.common.utils.comparison import is_regex from testplan.exporters.testing.pdf.renderers.base import SlicedParagraph from testplan.report import Status diff --git a/testplan/exporters/testing/pdf/renderers/entries/base.py b/testplan/exporters/testing/pdf/renderers/entries/base.py index 3c9be2b6b..7d92d53de 100644 --- a/testplan/exporters/testing/pdf/renderers/entries/base.py +++ b/testplan/exporters/testing/pdf/renderers/entries/base.py @@ -8,8 +8,9 @@ from testplan.common.exporters.pdf import RowStyle, create_table from testplan.common.exporters.pdf import format_table_style +from testplan.common.exporters.pdf import split_text from testplan.common.utils.registry import Registry -from testplan.common.utils.strings import split_text + from testplan.exporters.testing.pdf.renderers.base import SlicedParagraph from testplan.testing.multitest.entries import base from .baseUtils import get_matlib_plot, export_plot_to_image, format_image diff --git a/testplan/exporters/testing/pdf/renderers/reports.py b/testplan/exporters/testing/pdf/renderers/reports.py index 98a7129aa..d5e8aca49 100644 --- a/testplan/exporters/testing/pdf/renderers/reports.py +++ b/testplan/exporters/testing/pdf/renderers/reports.py @@ -9,8 +9,9 @@ from reportlab.platypus import Paragraph from testplan.common.exporters.pdf import RowStyle +from testplan.common.exporters.pdf import split_text from testplan.common.utils.registry import Registry -from testplan.common.utils.strings import format_description, split_text, wrap +from testplan.common.utils.strings import format_description, wrap from testplan.report import ( ReportCategories, Status, diff --git a/testplan/importers/junit.py b/testplan/importers/junit.py index 6996ac5ea..ec3146152 100644 --- a/testplan/importers/junit.py +++ b/testplan/importers/junit.py @@ -16,7 +16,6 @@ ) from testplan.testing.multitest.entries.assertions import RawAssertion from testplan.testing.multitest.entries.schemas.base import registry -from ..common.utils.strings import uuid4 class JUnitImportedResult(SuitesResult): diff --git a/testplan/runnable/base.py b/testplan/runnable/base.py index b418c74ef..519476729 100644 --- a/testplan/runnable/base.py +++ b/testplan/runnable/base.py @@ -22,6 +22,7 @@ Pattern, Tuple, Union, + TYPE_CHECKING, ) from schema import And, Or, Use @@ -36,12 +37,15 @@ RunnableStatus, ) from testplan.common.exporters import BaseExporter, ExportContext, run_exporter -from testplan.common.remote.remote_service import RemoteService + +if TYPE_CHECKING: + from testplan.common.remote.remote_service import RemoteService + from testplan.monitor.resource import ( + ResourceMonitorServer, + ResourceMonitorClient, + ) from testplan.common.report import MergeError -from testplan.monitor.resource import ( - ResourceMonitorServer, - ResourceMonitorClient, -) + from testplan.common.utils import logger, strings from testplan.common.utils.package import import_tmp_module from testplan.common.utils.path import default_runpath, makedirs, makeemptydirs @@ -59,7 +63,6 @@ ) from testplan.report.filter import ReportingFilter from testplan.report.testing.styles import Style -from testplan.runnable.interactive import TestRunnerIHandler from testplan.runners.base import Executor from testplan.runners.pools.base import Pool from testplan.runners.pools.tasks import Task, TaskResult @@ -239,7 +242,8 @@ def get_options(cls): # active_loop_sleep impacts cpu usage in interactive mode ConfigOption("active_loop_sleep", default=0.05): float, ConfigOption( - "interactive_handler", default=TestRunnerIHandler + "interactive_handler", + default=None, ): object, ConfigOption("extra_deps", default=[]): [ Or(str, lambda x: inspect.ismodule(x)) @@ -385,7 +389,7 @@ class TestRunner(Runnable): :type abort_wait_timeout: ``int`` :param interactive_handler: Handler for interactive mode execution. :type interactive_handler: Subclass of :py:class: - `TestRunnerIHandler ` + `TestRunnerIHandler ` :param extra_deps: Extra module dependencies for interactive reload, or paths of these modules. :type extra_deps: ``list`` of ``module`` or ``str`` @@ -429,14 +433,14 @@ def __init__(self, **options): # when executing unit/functional tests or running in interactive mode. self._reset_report_uid = not self._is_interactive_run() self.scheduled_modules = [] # For interactive reload - self.remote_services: Dict[str, RemoteService] = {} + self.remote_services: Dict[str, "RemoteService"] = {} self.runid_filename = uuid.uuid4().hex self.define_runpath() self._runnable_uids = set() self._verified_targets = {} # target object id -> runnable uid - self.resource_monitor_server: Optional[ResourceMonitorServer] = None + self.resource_monitor_server: Optional["ResourceMonitorServer"] = None self.resource_monitor_server_file_path: Optional[str] = None - self.resource_monitor_client: Optional[ResourceMonitorClient] = None + self.resource_monitor_client: Optional["ResourceMonitorClient"] = None def __str__(self): return f"Testplan[{self.uid()}]" @@ -554,7 +558,7 @@ def add_exporters(self, exporters: List[Exporter]): """ self.cfg.exporters.extend(get_exporters(exporters)) - def add_remote_service(self, remote_service: RemoteService): + def add_remote_service(self, remote_service: "RemoteService"): """ Adds a remote service :py:class:`~testplan.common.remote.remote_service.RemoteService` @@ -1081,6 +1085,11 @@ def make_runpath_dirs(self): def _start_resource_monitor(self): """Start resource monitor server and client""" + from testplan.monitor.resource import ( + ResourceMonitorServer, + ResourceMonitorClient, + ) + if self.cfg.resource_monitor: self.resource_monitor_server = ResourceMonitorServer( self.resource_monitor_server_file_path, diff --git a/testplan/runnable/interactive/__init__.py b/testplan/runnable/interactive/__init__.py index 0dbb5d1b8..e69de29bb 100644 --- a/testplan/runnable/interactive/__init__.py +++ b/testplan/runnable/interactive/__init__.py @@ -1 +0,0 @@ -from .base import TestRunnerIHandler diff --git a/testplan/runnable/interactive/base.py b/testplan/runnable/interactive/base.py index 5c5a950b9..e41594bcf 100644 --- a/testplan/runnable/interactive/base.py +++ b/testplan/runnable/interactive/base.py @@ -2,8 +2,6 @@ Interactive handler for TestRunner runnable class. """ import numbers -import re -import socket import threading import warnings from concurrent import futures @@ -16,8 +14,6 @@ ReportCategories, RuntimeStatus, Status, - TestGroupReport, - TestCaseReport, TestReport, ) from testplan.runnable.interactive import http, reloader, resource_loader diff --git a/testplan/testing/base.py b/testplan/testing/base.py index 3eec93e5c..f248854be 100644 --- a/testplan/testing/base.py +++ b/testplan/testing/base.py @@ -18,7 +18,6 @@ Type, Tuple, ) -import plotly.express as px from testplan import defaults from testplan.common.config import ConfigOption, validate_func @@ -813,6 +812,8 @@ def _xfail(self, pattern: str, report) -> None: def _record_driver_timing( self, setup_or_teardown: str, case_report: TestCaseReport ) -> None: + import plotly.express as px + case_result = self.cfg.result( stdout_style=self.stdout_style, _scratch=self.scratch ) diff --git a/testplan/testing/multitest/entries/base.py b/testplan/testing/multitest/entries/base.py index 3ab892628..c9ea37581 100644 --- a/testplan/testing/multitest/entries/base.py +++ b/testplan/testing/multitest/entries/base.py @@ -9,7 +9,7 @@ import shutil import hashlib import pathlib -import plotly.io + from testplan.common.utils.convert import nested_groups from testplan.common.utils.timing import utcnow @@ -387,6 +387,8 @@ def __init__( class Plotly(Attachment): def __init__(self, fig, data_file_path, style=None, description=None): + import plotly.io + fig_json = plotly.io.to_json(fig) pathlib.Path(data_file_path).resolve().parent.mkdir( parents=True, exist_ok=True diff --git a/testplan/testing/multitest/entries/stdout/assertions.py b/testplan/testing/multitest/entries/stdout/assertions.py index 7b67efe93..ff1ab6f6d 100644 --- a/testplan/testing/multitest/entries/stdout/assertions.py +++ b/testplan/testing/multitest/entries/stdout/assertions.py @@ -7,7 +7,7 @@ from terminaltables import AsciiTable import testplan.common.exporters.constants as constants -from testplan.common.exporters.pdf import format_cell_data +from testplan.common.exporters import format_cell_data from testplan.common.utils.strings import Color, map_to_str from .. import assertions