From 6933aeab83d36b7d6e5df1a6951a344561b89b93 Mon Sep 17 00:00:00 2001 From: Stephane Odul Date: Mon, 29 Jan 2024 14:02:28 -0800 Subject: [PATCH 1/4] Apply changes from black v24. This will avoid CI errors as it installs the latest version of Black. --- .gitignore | 1 + green/process.py | 1 - green/test/test_runner.py | 7 +++++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 593a56a..c6ec148 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ tags # virtual environments venv* env* +.python-version *.sublime-workspace diff --git a/green/process.py b/green/process.py index 529e8be..1767938 100644 --- a/green/process.py +++ b/green/process.py @@ -146,7 +146,6 @@ def _repopulate_pool_static( util.debug("added worker") - import multiprocessing.pool from multiprocessing import util # type: ignore from multiprocessing.pool import MaybeEncodingError # type: ignore diff --git a/green/test/test_runner.py b/green/test/test_runner.py index 470c80f..d527ba2 100644 --- a/green/test/test_runner.py +++ b/green/test/test_runner.py @@ -141,7 +141,8 @@ def test_verbose3(self): """ self.args.verbose = 3 sub_tmpdir = pathlib.Path(tempfile.mkdtemp(dir=self.tmpdir)) - content = dedent(""" + content = dedent( + """ import unittest class Verbose3(unittest.TestCase): def test01(self): @@ -400,7 +401,9 @@ class A(unittest.TestCase): def testPasses(self): pass""" ) - (sub_tmpdir / "test_detectNumProcesses.py").write_text(content, encoding="utf-8") + (sub_tmpdir / "test_detectNumProcesses.py").write_text( + content, encoding="utf-8" + ) # Load the tests os.chdir(self.tmpdir) try: From 025124e6bf44c74c6d63519100f6bcc751851243 Mon Sep 17 00:00:00 2001 From: Stephane Odul Date: Mon, 29 Jan 2024 14:06:47 -0800 Subject: [PATCH 2/4] Help address the start_time error on skipped tests with python 3.12.1. --- green/result.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/green/result.py b/green/result.py index c30ee07..0b4d23b 100644 --- a/green/result.py +++ b/green/result.py @@ -237,6 +237,9 @@ class ProtoTestResult(BaseTestResult): I'm the TestResult object for a single unit test run in a process. """ + start_time: float = 0 + errors: list + def __init__(self, start_callback=None, finalize_callback=None): super().__init__(None, None) self.start_callback = start_callback @@ -256,12 +259,13 @@ def __init__(self, start_callback=None, finalize_callback=None): "test_time", ] self.failfast = False # Because unittest inspects the attribute + self.errors = [] self.reinitialize() def reinitialize(self): self.shouldStop = False self.collectedDurations = [] - self.errors = [] + self.errors.clear() self.expectedFailures = [] self.failures = [] self.passing = [] @@ -312,7 +316,7 @@ def __setstate__(self, dict): def startTest(self, test): """ - Called before each test runs + Called before each test runs. """ test = proto_test(test) self.start_time = time.time() @@ -322,9 +326,12 @@ def startTest(self, test): def stopTest(self, test): """ - Called after each test runs + Called after each test runs. """ - self.test_time = str(time.time() - self.start_time) + if self.start_time: + self.test_time = str(time.time() - self.start_time) + else: + self.test_time = "0.0" def finalize(self): """ From 651ab467e371b42e6205b9c3d223f2e1f50e4b0a Mon Sep 17 00:00:00 2001 From: Stephane Odul Date: Mon, 29 Jan 2024 17:40:51 -0800 Subject: [PATCH 3/4] * Add type annotation. * fix/flag potential bugs highlighted by mypy. --- green/loader.py | 25 ++-- green/output.py | 101 +++++++------ green/process.py | 66 +++++--- green/result.py | 306 +++++++++++++++++++------------------- green/runner.py | 25 +++- green/test/test_result.py | 12 +- 6 files changed, 287 insertions(+), 248 deletions(-) diff --git a/green/loader.py b/green/loader.py index f32ad70..ca52827 100644 --- a/green/loader.py +++ b/green/loader.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections import OrderedDict from doctest import DocTestSuite from fnmatch import fnmatch @@ -12,7 +14,7 @@ import traceback from green.output import debug -from green.result import proto_test +from green import result from green.suite import GreenTestSuite python_file_pattern = re.compile(r"^[_a-z]\w*?\.py$", re.IGNORECASE) @@ -293,7 +295,9 @@ def testFailure(self): return None -def toProtoTestList(suite, test_list=None, doing_completions=False): +def toProtoTestList( + suite, test_list=None, doing_completions: bool = False +) -> list[result.ProtoTest]: """ Take a test suite and turn it into a list of ProtoTests. @@ -314,22 +318,22 @@ def toProtoTestList(suite, test_list=None, doing_completions=False): if isinstance(suite, unittest.TestCase): # Skip actual blank TestCase objects that twisted inserts if str(type(suite)) != "": - test_list.append(proto_test(suite)) + test_list.append(result.proto_test(suite)) else: for i in suite: toProtoTestList(i, test_list, doing_completions) return test_list -def toParallelTargets(suite, targets): +def toParallelTargets(suite, targets: list[str]) -> list[str]: """ Produce a list of targets which should be tested in parallel. - For the most part this will be a list of test modules. The exception is - when a dotted name representing something more granular than a module - was input (like an individal test case or test method) + For the most part, this will be a list of test modules. + The exception is when a dotted name representing something more granular + than a module was input (like an individual test case or test method). """ - targets = filter(lambda x: x != ".", targets) + targets = [x for x in targets if x != "."] # First, convert the suite to a proto test list - proto tests nicely # parse things like the fully dotted name of the test and the # finest-grained module it belongs to, which simplifies our job. @@ -338,11 +342,12 @@ def toParallelTargets(suite, targets): modules = {x.module for x in proto_test_list} # Get the list of user-specified targets that are NOT modules non_module_targets = [] + target: str for target in targets: - if not list(filter(None, [target in x for x in modules])): + if not list(filter(None, (target in x for x in modules))): non_module_targets.append(target) # Main loop -- iterating through all loaded test methods - parallel_targets = [] + parallel_targets: list[str] = [] for test in proto_test_list: found = False for target in non_module_targets: diff --git a/green/output.py b/green/output.py index f459045..d0fe137 100644 --- a/green/output.py +++ b/green/output.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Iterable, TYPE_CHECKING + from colorama import Fore, Style from colorama.ansi import Cursor from colorama.initialise import wrap_stream @@ -8,6 +12,10 @@ import sys from unidecode import unidecode +if TYPE_CHECKING: + from colorama.ansitowin32 import StreamWrapper + from colorama.initialise import _TextIOT + global debug_level debug_level = 0 @@ -15,7 +23,7 @@ unicode = None # so pyflakes stops complaining -def debug(message, level=1): +def debug(message: str, level: int = 1): """ So we can tune how much debug output we get when we turn it on. """ @@ -28,73 +36,72 @@ class Colors: A class to centralize wrapping strings in terminal colors. """ - def __init__(self, termcolor=None): - """ - termcolor - If None, attempt to autodetect whether we are in a terminal - and turn on terminal colors if we think we are. If True, force - terminal colors on. If False, force terminal colors off. + def __init__(self, termcolor: bool | None = None): + """Initialize the Colors object. + + Args: + termcolor: If None, attempt to autodetect whether we are in a + terminal and turn on terminal colors if we think we are. + If True, force terminal colors on. + If False, force terminal colors off. """ - if termcolor is None: - self.termcolor = sys.stdout.isatty() - else: - self.termcolor = termcolor + self.termcolor = sys.stdout.isatty() if termcolor is None else termcolor - def wrap(self, text, style): + def wrap(self, text: str, style: str) -> str: if self.termcolor: return f"{style}{text}{Style.RESET_ALL}" - else: - return text + return text # Movement - def start_of_line(self): + def start_of_line(self) -> str: return "\r" - def up(self, lines=1): + def up(self, lines: int = 1) -> str: return Cursor.UP(lines) # Real colors and styles - def bold(self, text): + def bold(self, text: str) -> str: return self.wrap(text, Style.BRIGHT) - def blue(self, text): + def blue(self, text: str) -> str: if platform.system() == "Windows": # pragma: no cover # Default blue in windows is unreadable (such awful defaults...) return self.wrap(text, Fore.CYAN) else: return self.wrap(text, Fore.BLUE) - def green(self, text): + def green(self, text: str) -> str: return self.wrap(text, Fore.GREEN) - def red(self, text): + def red(self, text: str) -> str: return self.wrap(text, Fore.RED) - def yellow(self, text): + def yellow(self, text: str) -> str: return self.wrap(text, Fore.YELLOW) # Abstracted colors and styles - def passing(self, text): + def passing(self, text: str) -> str: return self.green(text) - def failing(self, text): + def failing(self, text: str) -> str: return self.red(text) - def error(self, text): + def error(self, text: str) -> str: return self.red(text) - def skipped(self, text): + def skipped(self, text: str) -> str: return self.blue(text) - def unexpectedSuccess(self, text): + def unexpectedSuccess(self, text: str) -> str: return self.yellow(text) - def expectedFailure(self, text): + def expectedFailure(self, text: str) -> str: return self.yellow(text) - def moduleName(self, text): + def moduleName(self, text: str) -> str: return self.bold(text) - def className(self, text): + def className(self, text: str) -> str: return text @@ -111,19 +118,19 @@ class GreenStream: writelines(lines) """ - indent_spaces = 2 - _ascii_only_output = False # default to printing output in unicode + indent_spaces: int = 2 + _ascii_only_output: bool = False # default to printing output in unicode coverage_pattern = re.compile(r"TOTAL\s+\d+\s+\d+\s+(?P\d+)%") def __init__( self, - stream, - override_appveyor=False, - disable_windows=False, - disable_unidecode=False, - ): + stream: _TextIOT, + override_appveyor: bool = False, + disable_windows: bool = False, + disable_unidecode: bool = False, + ) -> None: self.disable_unidecode = disable_unidecode - self.stream = stream + self.stream: _TextIOT | StreamWrapper = stream # Ironically, Windows CI platforms such as GitHub Actions and AppVeyor don't support windows # win32 system calls for colors, but it WILL interpret posix ansi escape codes! (The # opposite of an actual windows command prompt) @@ -135,7 +142,7 @@ def __init__( if override_appveyor or ( (on_windows and not on_windows_ci) and not disable_windows ): # pragma: no cover - self.stream = wrap_stream(self.stream, None, None, None, True) + self.stream = wrap_stream(stream, None, None, False, True) # set output is ascii-only self._ascii_only_output = True self.closed = False @@ -147,23 +154,23 @@ def __init__( # Susceptible to false-positives if other matching lines are output, # so set this to None immediately before running a coverage report to # guarantee accuracy. - self.coverage_percent = None + self.coverage_percent: int | None = None - def flush(self): + def flush(self) -> None: self.stream.flush() - def writeln(self, text=""): + def writeln(self, text: str = "") -> None: self.write(text + "\n") - def write(self, text): - if type(text) == bytes: + def write(self, text: str) -> None: + if isinstance(text, bytes): text = text.decode("utf-8") # Compensate for windows' anti-social unicode behavior if self._ascii_only_output and not self.disable_unidecode: # Windows doesn't actually want unicode, so we get # the closest ASCII equivalent text = text_type(unidecode(text)) - # Since coverage doesn't like us switching out it's stream to run extra + # Since coverage doesn't like us switching out its stream to run extra # reports to look for percent covered. We should replace this with # grabbing the percentage directly from coverage if we can figure out # how. @@ -174,14 +181,14 @@ def write(self, text): self.coverage_percent = int(percent_str) self.stream.write(text) - def writelines(self, lines): + def writelines(self, lines: Iterable[str]) -> None: """ Just for better compatibility with real file objects """ for line in lines: self.write(line) - def formatText(self, text, indent=0, outcome_char=""): + def formatText(self, text: str, indent: int = 0, outcome_char: str = "") -> str: # We'll go through each line in the text, modify it, and store it in a # new list updated_lines = [] @@ -197,7 +204,7 @@ def formatText(self, text, indent=0, outcome_char=""): output = "\n".join(updated_lines) return output - def formatLine(self, line, indent=0, outcome_char=""): + def formatLine(self, line: str, indent: int = 0, outcome_char: str = "") -> str: """ Takes a single line, optionally adds an indent and/or outcome character to the beginning of the line. @@ -205,7 +212,7 @@ def formatLine(self, line, indent=0, outcome_char=""): actual_spaces = (indent * self.indent_spaces) - len(outcome_char) return outcome_char + " " * actual_spaces + line - def isatty(self): + def isatty(self) -> bool: """ Wrap internal self.stream.isatty. """ diff --git a/green/process.py b/green/process.py index 1767938..ee2270a 100644 --- a/green/process.py +++ b/green/process.py @@ -1,10 +1,14 @@ +from __future__ import annotations + import logging import multiprocessing from multiprocessing.pool import Pool +import os import random import sys import tempfile import traceback +from typing import Type, TYPE_CHECKING, Union, Tuple, Callable, Iterable import coverage @@ -13,20 +17,28 @@ from green.result import proto_test, ProtoTest, ProtoTestResult +if TYPE_CHECKING: + from types import TracebackType + from queue import Queue + + ExcInfoType = Union[ + Tuple[Type[BaseException], BaseException, TracebackType], + Tuple[None, None, None], + ] + + # Super-useful debug function for finding problems in the subprocesses, and it # even works on windows -def ddebug(msg, err=None): # pragma: no cover +def ddebug(msg: str, err: ExcInfoType | None = None): # pragma: no cover """ err can be an instance of sys.exc_info() -- which is the latest traceback info """ - import os - if err: - err = "".join(traceback.format_exception(*err)) + error_string = "".join(traceback.format_exception(*err)) else: - err = "" - sys.__stdout__.write(f"({os.getpid()}) {msg} {err}\n") + error_string = "" + sys.__stdout__.write(f"({os.getpid()}) {msg} {error_string}\n") sys.__stdout__.flush() @@ -36,7 +48,7 @@ class ProcessLogger: instead of having process crashes be silent. """ - def __init__(self, callable): + def __init__(self, callable: Callable): self.__callable = callable def __call__(self, *args, **kwargs): @@ -65,13 +77,16 @@ class LoggingDaemonlessPool(Pool): """ @staticmethod - def Process(ctx, *args, **kwds): - process = ctx.Process(daemon=False, *args, **kwds) + def Process(ctx, *args, **kwargs): + process = ctx.Process(daemon=False, *args, **kwargs) return process - def apply_async(self, func, args=(), kwds={}, callback=None, error_callback=None): + # FIXME: `kwargs={}` is dangerous as the empty dict is declared at import time + # and becomes a shared object between all instances of LoggingDaemonlessPool. + # In short, it is a global variable that is mutable. + def apply_async(self, func, args=(), kwargs={}, callback=None, error_callback=None): return Pool.apply_async( - self, ProcessLogger(func), args, kwds, callback, error_callback + self, ProcessLogger(func), args, kwargs, callback, error_callback ) _wrap_exception = True @@ -212,27 +227,27 @@ def worker( # Unmodified (see above) class RemoteTraceback(Exception): # pragma: no cover - def __init__(self, tb): + def __init__(self, tb: str): self.tb = tb - def __str__(self): + def __str__(self) -> str: return self.tb # Unmodified (see above) class ExceptionWithTraceback: # pragma: no cover - def __init__(self, exc, tb): - tb = traceback.format_exception(type(exc), exc, tb) - tb = "".join(tb) + def __init__(self, exc: BaseException, tb: TracebackType): + tb_lines = traceback.format_exception(type(exc), exc, tb) + tb_text = "".join(tb_lines) self.exc = exc - self.tb = '\n"""\n%s"""' % tb + self.tb = '\n"""\n%s"""' % tb_text - def __reduce__(self): + def __reduce__(self) -> Tuple[Callable, Tuple[BaseException, str]]: return rebuild_exc, (self.exc, self.tb) # Unmodified (see above) -def rebuild_exc(exc, tb): # pragma: no cover +def rebuild_exc(exc: BaseException, tb: str): # pragma: no cover exc.__cause__ = RemoteTraceback(tb) return exc @@ -242,8 +257,13 @@ def rebuild_exc(exc, tb): # pragma: no cover # ----------------------------------------------------------------------------- +# Fixme: `omit_patterns=[]` is a global mutable. def poolRunner( - target, queue, coverage_number=None, omit_patterns=[], cov_config_file=True + target: str, + queue: Queue, + coverage_number: int | None = None, + omit_patterns: str | Iterable[str] | None = [], + cov_config_file: bool = True, ): # pragma: no cover """ I am the function that pool worker processes run. I run one unit test. @@ -259,7 +279,7 @@ def poolRunner( saved_tempdir = tempfile.tempdir tempfile.tempdir = tempfile.mkdtemp() - def raise_internal_failure(msg): + def raise_internal_failure(msg: str): err = sys.exc_info() t = ProtoTest() t.module = "green.loader" @@ -361,7 +381,7 @@ def finalize_callback(test_result): target, str(test), type(test), dir(test) ) ) - err = (TypeError, TypeError(description), None) + no_run_error = (TypeError, TypeError(description), None) t = ProtoTest() target_list = target.split(".") t.module = ".".join(target_list[:-2]) if len(target_list) > 1 else target @@ -371,7 +391,7 @@ def finalize_callback(test_result): target.split(".")[-1] if len(target_list) > 1 else "unknown_method" ) result.startTest(t) - result.addError(t, err) + result.addError(t, no_run_error) result.stopTest(t) queue.put(result) diff --git a/green/result.py b/green/result.py index 0b4d23b..f437227 100644 --- a/green/result.py +++ b/green/result.py @@ -1,39 +1,41 @@ -from collections import OrderedDict -from doctest import DocTestCase +from __future__ import annotations + +from doctest import DocTest, DocTestCase from math import ceil from shutil import get_terminal_size import time import traceback +from typing import Any, Union, TYPE_CHECKING from unittest.result import failfast from unittest import TestCase from green.output import Colors, debug from green.version import pretty_version +if TYPE_CHECKING: + TestCaseT = Union["ProtoTest", TestCase, DocTestCase] terminal_width, _ignored = get_terminal_size() -def proto_test(test): +def proto_test(test: TestCaseT) -> ProtoTest: """ - If test is a ProtoTest, I just return it. Otherwise I create a ProtoTest - out of test and return it. + If test is a ProtoTest, I just return it. + Otherwise, I create a ProtoTest out of test and return it. """ if isinstance(test, ProtoTest): return test - else: - return ProtoTest(test) + return ProtoTest(test) def proto_error(err): """ - If err is a ProtoError, I just return it. Otherwise I create a ProtoError - out of err and return it. + If err is a ProtoError, I just return it. + Otherwise, I create a ProtoError out of err and return it. """ if isinstance(err, ProtoError): return err - else: - return ProtoError(err) + return ProtoError(err) class ProtoTest: @@ -42,29 +44,38 @@ class ProtoTest: and can pass between processes. """ - def __init__(self, test=None): - self.module = "" - self.class_name = "" - self.method_name = "" - self.docstr_part = "" - self.subtest_part = "" - self.test_time = "0.0" - # We need to know that this is a doctest, because doctests are very - # different than regular test cases in many ways, so they get special - # treatment inside and outside of this class. - self.is_doctest = False + module: str = "" + class_name: str = "" + method_name: str = "" + docstr_part: str = "" + subtest_part: str = "" + test_time: str = "0.0" + failureException = AssertionError + description: str = "" + + # Doctests specific attributes: + is_doctest: bool = False + filename: str | None = None + name: str = "" + + def __init__(self, test: TestCase | DocTestCase | None = None) -> None: # Teardown handling is a royal mess - self.is_class_or_module_teardown_error = False + self.is_class_or_module_teardown_error: bool = False - # Is this a subtest? - if getattr(test, "_subDescription", None): - self.subtest_part = " " + test._subDescription() - test = test.test_case + # Is this a subtest? The _SubTest class is private so we need to check the attributes. + sub_description = getattr(test, "_subDescription", None) + if sub_description is not None: + self.subtest_part = " " + sub_description() + test = test.test_case # type: ignore # Is this a DocTest? + # We need to know that this is a doctest, because doctests are very + # different than regular test cases in many ways, so they get special + # treatment inside and outside of this class. if isinstance(test, DocTestCase): self.is_doctest = True - self.name = test._dt_test.name + dt_test: DocTest = test._dt_test # type: ignore + self.name = dt_test.name # We had to inject this in green/loader.py -- this is the test # module that specified that we should load doctests from some # other module -- so that we'll group the doctests with the test @@ -74,8 +85,8 @@ def __init__(self, test=None): # I'm not sure this will be the correct way to get the method name # in all cases. self.method_name = self.name.split(".")[1] - self.filename = test._dt_test.filename - self.lineno = test._dt_test.lineno + self.filename = dt_test.filename + self.lineno = dt_test.lineno # Is this a TestCase? elif isinstance(test, TestCase): @@ -94,29 +105,22 @@ def __init__(self, test=None): doc_segments.append(line) self.docstr_part = " ".join(doc_segments) - def __eq__(self, other): + def __eq__(self, other) -> bool: return self.__hash__() == other.__hash__() - def __hash__(self): + def __hash__(self) -> int: return hash(self.dotted_name) - def __str__(self): + def __str__(self) -> str: return self.dotted_name @property - def dotted_name(self, ignored=None): + def dotted_name(self, ignored: Any = None) -> str: if self.is_doctest or self.is_class_or_module_teardown_error: return self.name - return ( - self.module - + "." - + self.class_name - + "." - + self.method_name - + self.subtest_part - ) + return f"{self.module}.{self.class_name}.{self.method_name}{self.subtest_part}" - def getDescription(self, verbose): + def getDescription(self, verbose: int) -> str: # Classes or module teardown errors if self.is_class_or_module_teardown_error: return self.name @@ -124,19 +128,18 @@ def getDescription(self, verbose): if self.is_doctest: if verbose == 2: return self.name - elif verbose > 2: - return self.name + " -> " + self.filename + ":" + str(self.lineno) + if verbose > 2: + return f"{self.name} -> {self.filename}:{self.lineno}" return "" # Regular tests if verbose == 2: - return self.method_name + self.subtest_part + return f"{self.method_name}{self.subtest_part}" elif verbose == 3: - return (self.docstr_part + self.subtest_part) or self.method_name + return f"{self.docstr_part}{self.subtest_part}" or self.method_name elif verbose > 3: if self.docstr_part + self.subtest_part: - return self.method_name + ": " + self.docstr_part + self.subtest_part - else: - return self.method_name + return f"{self.method_name}: {self.docstr_part}{self.subtest_part}" + return self.method_name return "" @@ -146,10 +149,10 @@ class ProtoError: and can pass between processes. """ - def __init__(self, err=None): + def __init__(self, err=None) -> None: self.traceback_lines = traceback.format_exception(*err) - def __str__(self): + def __str__(self) -> str: return "\n".join(self.traceback_lines) @@ -158,15 +161,15 @@ class BaseTestResult: I am inherited by ProtoTestResult and GreenTestResult. """ - def __init__(self, stream, colors): - self.stdout_output = OrderedDict() - self.stderr_errput = OrderedDict() + def __init__(self, stream, *, colors: Colors | None = None): + self.stdout_output: dict[ProtoTest, Any] = {} + self.stderr_errput: dict[ProtoTest, Any] = {} self.stream = stream - self.colors = colors + self.colors: Colors = colors or Colors() # The collectedDurations list is new in Python 3.12. - self.collectedDurations = [] + self.collectedDurations: list[tuple[str, float]] = [] - def recordStdout(self, test, output): + def recordStdout(self, test: TestCaseT, output): """ Called with stdout that the suite decided to capture so we can report the captured output somewhere. @@ -175,7 +178,7 @@ def recordStdout(self, test, output): test = proto_test(test) self.stdout_output[test] = output - def recordStderr(self, test, errput): + def recordStderr(self, test: TestCaseT, errput): """ Called with stderr that the suite decided to capture so we can report the captured "errput" somewhere. @@ -184,7 +187,7 @@ def recordStderr(self, test, errput): test = proto_test(test) self.stderr_errput[test] = errput - def displayStdout(self, test): + def displayStdout(self, test: TestCaseT): """ Displays AND REMOVES the output captured from a specific test. The removal is done so that this method can be called multiple times @@ -192,16 +195,17 @@ def displayStdout(self, test): """ test = proto_test(test) if test.dotted_name in self.stdout_output: + colors = self.colors self.stream.write( "\n{} for {}\n{}".format( - self.colors.yellow("Captured stdout"), - self.colors.bold(test.dotted_name), + colors.yellow("Captured stdout"), + colors.bold(test.dotted_name), self.stdout_output[test], ) ) del self.stdout_output[test] - def displayStderr(self, test): + def displayStderr(self, test: TestCaseT): """ Displays AND REMOVES the errput captured from a specific test. The removal is done so that this method can be called multiple times @@ -209,16 +213,17 @@ def displayStderr(self, test): """ test = proto_test(test) if test.dotted_name in self.stderr_errput: + colors = self.colors self.stream.write( "\n{} for {}\n{}".format( - self.colors.yellow("Captured stderr"), - self.colors.bold(test.dotted_name), + colors.yellow("Captured stderr"), + colors.bold(test.dotted_name), self.stderr_errput[test], ) ) del self.stderr_errput[test] - def addDuration(self, test, elapsed): + def addDuration(self, test: TestCaseT, elapsed: float): """ Called when a test finished running, regardless of its outcome. @@ -237,11 +242,11 @@ class ProtoTestResult(BaseTestResult): I'm the TestResult object for a single unit test run in a process. """ - start_time: float = 0 - errors: list + start_time: float = 0.0 + shouldStop: bool = False def __init__(self, start_callback=None, finalize_callback=None): - super().__init__(None, None) + super().__init__(None, colors=None) self.start_callback = start_callback self.finalize_callback = finalize_callback self.finalize_callback_called = False @@ -259,45 +264,39 @@ def __init__(self, start_callback=None, finalize_callback=None): "test_time", ] self.failfast = False # Because unittest inspects the attribute - self.errors = [] - self.reinitialize() - - def reinitialize(self): - self.shouldStop = False self.collectedDurations = [] - self.errors.clear() + self.errors = [] self.expectedFailures = [] self.failures = [] self.passing = [] self.skipped = [] self.unexpectedSuccesses = [] + self.reinitialize() + + def reinitialize(self): + self.shouldStop = False + self.collectedDurations.clear() + self.errors.clear() + self.expectedFailures.clear() + self.failures.clear() + self.passing.clear() + self.skipped.clear() + self.unexpectedSuccesses.clear() + self.start_time = 0.0 self.test_time = "" - def __repr__(self): # pragma: no cover + def __repr__(self) -> str: # pragma: no cover return ( - "errors" - + str(self.errors) - + ", " - + "expectedFailures" - + str(self.expectedFailures) - + ", " - + "failures" - + str(self.failures) - + ", " - + "passing" - + str(self.passing) - + ", " - + "skipped" - + str(self.skipped) - + ", " - + "unexpectedSuccesses" - + str(self.unexpectedSuccesses) - + ", " - + "test_time" - + str(self.test_time) + f"errors{self.errors}," + f" expectedFailures{self.expectedFailures}," + f" failures{self.failures}," + f" passing{self.passing}," + f" skipped{self.skipped}," + f" unexpectedSuccesses{self.unexpectedSuccesses}," + f" test_time{self.test_time}" ) - def __getstate__(self): + def __getstate__(self) -> dict[str, Any]: """ Prevent the callback functions from getting pickled """ @@ -306,15 +305,15 @@ def __getstate__(self): result_dict[pickle_attr] = self.__dict__[pickle_attr] return result_dict - def __setstate__(self, dict): + def __setstate__(self, state: dict[str, Any]) -> None: """ - Since the callback functions weren't pickled, we need to init them + Since the callback functions weren't pickled, we need to initialize them. """ - self.__dict__.update(dict) + self.__dict__.update(state) self.start_callback = None self.finalize_callback = None - def startTest(self, test): + def startTest(self, test: TestCaseT): """ Called before each test runs. """ @@ -324,7 +323,7 @@ def startTest(self, test): if self.start_callback: self.start_callback(test) - def stopTest(self, test): + def stopTest(self, test: TestCaseT): """ Called after each test runs. """ @@ -344,43 +343,43 @@ def finalize(self): self.finalize_callback(self) self.finalize_callback_called = True - def addSuccess(self, test): + def addSuccess(self, test: TestCaseT): """ - Called when a test passed + Called when a test passed. """ self.passing.append(proto_test(test)) - def addError(self, test, err): + def addError(self, test: TestCaseT, err): """ - Called when a test raises an exception + Called when a test raises an exception. """ self.errors.append((proto_test(test), proto_error(err))) - def addFailure(self, test, err): + def addFailure(self, test: TestCaseT, err): """ - Called when a test fails a unittest assertion + Called when a test fails a unittest assertion. """ self.failures.append((proto_test(test), proto_error(err))) - def addSkip(self, test, reason): + def addSkip(self, test: TestCaseT, reason): """ - Called when a test is skipped + Called when a test is skipped. """ self.skipped.append((proto_test(test), reason)) - def addExpectedFailure(self, test, err): + def addExpectedFailure(self, test: TestCaseT, err): """ - Called when a test fails, and we expected the failure + Called when a test fails, and we expected the failure. """ self.expectedFailures.append((proto_test(test), proto_error(err))) - def addUnexpectedSuccess(self, test): + def addUnexpectedSuccess(self, test: TestCaseT): """ Called when a test passed, but we expected a failure """ self.unexpectedSuccesses.append(proto_test(test)) - def addSubTest(self, test, subtest, err): + def addSubTest(self, test: TestCaseT, subtest, err): """ Called at the end of a subtest no matter its result. @@ -400,18 +399,19 @@ class GreenTestResult(BaseTestResult): Aggregates test results and outputs them to a stream. """ + last_class = "" + last_module = "" + first_text_output: str = "" + shouldStop: bool = False + def __init__(self, args, stream): - super().__init__(stream, Colors(args.termcolor)) + super().__init__(stream, colors=Colors(args.termcolor)) self.args = args - self.showAll = args.verbose > 1 - self.dots = args.verbose == 1 + self.showAll: bool = args.verbose > 1 + self.dots: bool = args.verbose == 1 self.verbose = args.verbose - self.last_module = "" - self.last_class = "" - self.first_text_output = "" self.failfast = args.failfast - self.shouldStop = False - self.testsRun = 0 + self.testsRun: int = 0 # Individual lists self.collectedDurations = [] self.errors = [] @@ -436,10 +436,12 @@ def __str__(self): # pragma: no cover f"unexpectedSuccesses{self.unexpectedSuccesses}" ) - def stop(self): + def stop(self) -> None: self.shouldStop = True - def tryRecordingStdoutStderr(self, test, proto_test_result, err=None): + def tryRecordingStdoutStderr( + self, test: ProtoTest, proto_test_result: ProtoTestResult, err=None + ): if proto_test_result.stdout_output.get(test, False): self.recordStdout(test, proto_test_result.stdout_output[test]) if proto_test_result.stderr_errput.get(test, False): @@ -457,7 +459,7 @@ def tryRecordingStdoutStderr(self, test, proto_test_result, err=None): self.recordStderr(test, proto_test_result.stderr_errput[t]) break - def addProtoTestResult(self, proto_test_result): + def addProtoTestResult(self, proto_test_result: ProtoTestResult) -> None: for test, err in proto_test_result.errors: self.addError(test, err, proto_test_result.test_time) self.tryRecordingStdoutStderr(test, proto_test_result, err) @@ -477,18 +479,18 @@ def addProtoTestResult(self, proto_test_result): self.addUnexpectedSuccess(test, proto_test_result.test_time) self.tryRecordingStdoutStderr(test, proto_test_result) - def startTestRun(self): + def startTestRun(self) -> None: """ - Called once before any tests run + Called once before any tests run. """ self.startTime = time.time() # Really verbose information if self.verbose > 2: - self.stream.writeln(self.colors.bold(pretty_version() + "\n")) + self.stream.writeln(self.colors.bold(f"{pretty_version()}\n")) - def stopTestRun(self): + def stopTestRun(self) -> None: """ - Called once after all tests have run + Called once after all tests have run. """ self.stopTime = time.time() self.timeTaken = self.stopTime - self.startTime @@ -561,7 +563,7 @@ def stopTestRun(self): def startTest(self, test): """ - Called before the start of each test + Called before the start of each test. """ # Get our bearings test = proto_test(test) @@ -598,8 +600,7 @@ def startTest(self, test): def stopTest(self, test): """ - Supposed to be called after each test, but as far as I can tell that's a - lie and this is simply never called. + Supposed to be called after each test. """ def _reportOutcome(self, test, outcome_char, color_func, err=None, reason=""): @@ -630,7 +631,7 @@ def _reportOutcome(self, test, outcome_char, color_func, err=None, reason=""): def addSuccess(self, test, test_time=None): """ - Called when a test passed + Called when a test passed. """ test = proto_test(test) if test_time: @@ -641,7 +642,7 @@ def addSuccess(self, test, test_time=None): @failfast def addError(self, test, err, test_time=None): """ - Called when a test raises an exception + Called when a test raises an exception. """ test = proto_test(test) if test_time: @@ -654,7 +655,7 @@ def addError(self, test, err, test_time=None): @failfast def addFailure(self, test, err, test_time=None): """ - Called when a test fails a unittest assertion + Called when a test fails a unittest assertion. """ # Special case: Catch Twisted's skips that come thtrough as failures # and treat them as skips instead @@ -674,7 +675,7 @@ def addFailure(self, test, err, test_time=None): def addSkip(self, test, reason, test_time=None): """ - Called when a test is skipped + Called when a test is skipped. """ test = proto_test(test) if test_time: @@ -684,7 +685,7 @@ def addSkip(self, test, reason, test_time=None): def addExpectedFailure(self, test, err, test_time=None): """ - Called when a test fails, and we expected the failure + Called when a test fails, and we expected the failure. """ test = proto_test(test) if test_time: @@ -695,7 +696,7 @@ def addExpectedFailure(self, test, err, test_time=None): def addUnexpectedSuccess(self, test, test_time=None): """ - Called when a test passed, but we expected a failure + Called when a test passed, but we expected a failure. """ test = proto_test(test) if test_time: @@ -761,9 +762,9 @@ def printErrors(self): def wasSuccessful(self): """ - Tells whether or not the overall run was successful + Tells whether or not the overall run was successful. """ - if self.args.minimum_coverage != None: + if self.args.minimum_coverage is not None: if self.coverage_percent < self.args.minimum_coverage: self.stream.writeln( self.colors.red( @@ -775,20 +776,15 @@ def wasSuccessful(self): return False # fail if no tests are run. - if ( - sum( - len(x) - for x in [ - self.errors, - self.expectedFailures, - self.failures, - self.passing, - self.skipped, - self.unexpectedSuccesses, - ] + if not any( + ( + self.errors, + self.expectedFailures, + self.failures, + self.passing, + self.skipped, + self.unexpectedSuccesses, ) - == 0 ): return False - else: - return len(self.all_errors) + len(self.unexpectedSuccesses) == 0 + return not self.all_errors and not self.unexpectedSuccesses diff --git a/green/runner.py b/green/runner.py index 863653b..c07ef1c 100644 --- a/green/runner.py +++ b/green/runner.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import multiprocessing from sys import modules +from typing import TYPE_CHECKING from unittest.signals import registerResult, installHandler, removeResult import warnings @@ -7,7 +10,11 @@ from green.loader import toParallelTargets from green.output import debug, GreenStream from green.process import LoggingDaemonlessPool, poolRunner -from green.result import GreenTestResult +from green.result import GreenTestResult, ProtoTestResult + +if TYPE_CHECKING: + from multiprocessing.managers import SyncManager + from queue import Queue class InitializerOrFinalizer: @@ -49,7 +56,7 @@ def __call__(self, *args): ) -def run(suite, stream, args, testing=False): +def run(suite, stream, args, testing: bool = False) -> GreenTestResult: """ Run the given test case or test suite with the specified arguments. @@ -66,7 +73,8 @@ def run(suite, stream, args, testing=False): # Note: Catching SIGINT isn't supported by Python on windows (python # "WONTFIX" issue 18040) installHandler() - registerResult(result) + # Ignore the type mismatch until we make GreenTestResult a subclass of unittest.TestResult. + registerResult(result) # type: ignore with warnings.catch_warnings(): if args.warnings: # pragma: no cover @@ -95,8 +103,10 @@ def run(suite, stream, args, testing=False): finalizer=InitializerOrFinalizer(args.finalizer), maxtasksperchild=args.maxtasksperchild, ) - manager = multiprocessing.Manager() - targets = [(target, manager.Queue()) for target in parallel_targets] + manager: SyncManager = multiprocessing.Manager() + targets: list[tuple[str, Queue]] = [ + (target, manager.Queue()) for target in parallel_targets + ] if targets: for index, (target, queue) in enumerate(targets): if args.run_coverage: @@ -131,7 +141,7 @@ def run(suite, stream, args, testing=False): # currently waiting on this test, so print out # the white 'processing...' version of the output result.startTest(msg) - proto_test_result = queue.get() + proto_test_result: ProtoTestResult = queue.get() debug( "runner.run(): received proto test result: {}".format( str(proto_test_result) @@ -153,6 +163,7 @@ def run(suite, stream, args, testing=False): result.stopTestRun() - removeResult(result) + # Ignore the type mismatch untile we make GreenTestResult a subclass of unittest.TestResult. + removeResult(result) # type: ignore return result diff --git a/green/test/test_result.py b/green/test/test_result.py index 2d8a4b1..bc21ba1 100644 --- a/green/test/test_result.py +++ b/green/test/test_result.py @@ -44,7 +44,7 @@ def test_stdoutOutput(self): """ recordStdout records output. """ - btr = BaseTestResult(None, None) + btr = BaseTestResult(None, colors=None) pt = ProtoTest() o = "some output" btr.recordStdout(pt, o) @@ -54,7 +54,7 @@ def test_stdoutNoOutput(self): """ recordStdout ignores empty output sent to it """ - btr = BaseTestResult(None, None) + btr = BaseTestResult(None, colors=None) pt = ProtoTest() btr.recordStdout(pt, "") self.assertEqual(btr.stdout_output, {}) @@ -65,7 +65,7 @@ def test_displayStdout(self): """ stream = StringIO() noise = "blah blah blah" - btr = BaseTestResult(stream, Colors(False)) + btr = BaseTestResult(stream, colors=Colors(False)) pt = ProtoTest() btr.stdout_output[pt] = noise btr.displayStdout(pt) @@ -75,7 +75,7 @@ def test_stderrErrput(self): """ recordStderr records errput. """ - btr = BaseTestResult(None, None) + btr = BaseTestResult(None, colors=None) pt = ProtoTest() o = "some errput" btr.recordStderr(pt, o) @@ -85,7 +85,7 @@ def test_stderrNoErrput(self): """ recordStderr ignores empty errput sent to it """ - btr = BaseTestResult(None, None) + btr = BaseTestResult(None, colors=None) pt = ProtoTest() btr.recordStderr(pt, "") self.assertEqual(btr.stderr_errput, {}) @@ -96,7 +96,7 @@ def test_displayStderr(self): """ stream = StringIO() noise = "blah blah blah" - btr = BaseTestResult(stream, Colors(False)) + btr = BaseTestResult(stream, colors=Colors(False)) pt = ProtoTest() btr.stderr_errput[pt] = noise btr.displayStderr(pt) From 3801e726a8d1f955e83d66cff907912e89ffd6ba Mon Sep 17 00:00:00 2001 From: Stephane Odul Date: Mon, 29 Jan 2024 17:44:57 -0800 Subject: [PATCH 4/4] update GH actions --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f068a4d..fffcc1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 @@ -35,11 +35,11 @@ jobs: - name: Format run: black --check --diff green example - if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' + if: matrix.python-version == '3.12.0' && matrix.os == 'ubuntu-latest' - name: Mypy - run: mypy . - if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' + run: mypy green example + if: matrix.python-version == '3.12.0' && matrix.os == 'ubuntu-latest' - name: Test run: | @@ -50,10 +50,10 @@ jobs: run: | pip install --upgrade coveralls green -tvvvvr green - if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' + if: matrix.python-version == '3.12.0' && matrix.os == 'ubuntu-latest' - name: Coveralls run: coveralls --service=github env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' + if: matrix.python-version == '3.12.0' && matrix.os == 'ubuntu-latest'