test_console_output: Implement the entire TextIO contract.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
Anders Kaseorg
2021-12-22 21:55:47 -08:00
committed by Tim Abbott
parent 702ce071f4
commit d40f3d54f1
2 changed files with 135 additions and 84 deletions

View File

@@ -68,8 +68,8 @@ from zerver.lib.streams import (
from zerver.lib.test_console_output import ( from zerver.lib.test_console_output import (
ExtraConsoleOutputFinder, ExtraConsoleOutputFinder,
ExtraConsoleOutputInTestException, ExtraConsoleOutputInTestException,
TeeStderrAndFindExtraConsoleOutput, tee_stderr_and_find_extra_console_output,
TeeStdoutAndFindExtraConsoleOutput, tee_stdout_and_find_extra_console_output,
) )
from zerver.lib.test_helpers import find_key_by_email, instrument_url from zerver.lib.test_helpers import find_key_by_email, instrument_url
from zerver.lib.users import get_api_key from zerver.lib.users import get_api_key
@@ -168,9 +168,9 @@ class ZulipTestCase(TestCase):
if not settings.BAN_CONSOLE_OUTPUT: if not settings.BAN_CONSOLE_OUTPUT:
return super().run(result) return super().run(result)
extra_output_finder = ExtraConsoleOutputFinder() extra_output_finder = ExtraConsoleOutputFinder()
with TeeStderrAndFindExtraConsoleOutput( with tee_stderr_and_find_extra_console_output(
extra_output_finder extra_output_finder
), TeeStdoutAndFindExtraConsoleOutput(extra_output_finder): ), tee_stdout_and_find_extra_console_output(extra_output_finder):
test_result = super().run(result) test_result = super().run(result)
if extra_output_finder.full_extra_output: if extra_output_finder.full_extra_output:
exception_message = f""" exception_message = f"""
@@ -189,7 +189,7 @@ You should be able to quickly reproduce this failure with:
test-backend --ban-console-output {self.id()} test-backend --ban-console-output {self.id()}
Output: Output:
{extra_output_finder.full_extra_output} {extra_output_finder.full_extra_output.decode(errors="replace")}
-------------------------------------------- --------------------------------------------
""" """
raise ExtraConsoleOutputInTestException(exception_message) raise ExtraConsoleOutputInTestException(exception_message)

View File

@@ -1,8 +1,10 @@
import logging import logging
import re import re
import sys import sys
from contextlib import contextmanager
from io import SEEK_SET, TextIOWrapper
from types import TracebackType from types import TracebackType
from typing import Optional, Sequence, Type, cast from typing import IO, Iterable, Iterator, List, Optional, Type
class ExtraConsoleOutputInTestException(Exception): class ExtraConsoleOutputInTestException(Exception):
@@ -13,106 +15,155 @@ class ExtraConsoleOutputFinder:
def __init__(self) -> None: def __init__(self) -> None:
valid_line_patterns = [ valid_line_patterns = [
# Example: Running zerver.tests.test_attachments.AttachmentsTests.test_delete_unauthenticated # Example: Running zerver.tests.test_attachments.AttachmentsTests.test_delete_unauthenticated
"^Running ", b"^Running ",
# Example: ** Test is TOO slow: analytics.tests.test_counts.TestRealmActiveHumans.test_end_to_end (0.581 s) # Example: ** Test is TOO slow: analytics.tests.test_counts.TestRealmActiveHumans.test_end_to_end (0.581 s)
"^\\*\\* Test is TOO slow: ", b"^\\*\\* Test is TOO slow: ",
"^----------------------------------------------------------------------", b"^----------------------------------------------------------------------",
# Example: INFO: URL coverage report is in var/url_coverage.txt # Example: INFO: URL coverage report is in var/url_coverage.txt
"^INFO: URL coverage report is in", b"^INFO: URL coverage report is in",
# Example: INFO: Try running: ./tools/create-test-api-docs # Example: INFO: Try running: ./tools/create-test-api-docs
"^INFO: Try running:", b"^INFO: Try running:",
# Example: -- Running tests in parallel mode with 4 processes # Example: -- Running tests in parallel mode with 4 processes
"^-- Running tests in", b"^-- Running tests in",
"^OK", b"^OK",
# Example: Ran 2139 tests in 115.659s # Example: Ran 2139 tests in 115.659s
"^Ran [0-9]+ tests in", b"^Ran [0-9]+ tests in",
# Destroying test database for alias 'default'... # Destroying test database for alias 'default'...
"^Destroying test database for alias ", b"^Destroying test database for alias ",
"^Using existing clone", b"^Using existing clone",
"^\\*\\* Skipping ", b"^\\*\\* Skipping ",
] ]
self.compiled_line_patterns = [] self.compiled_line_pattern = re.compile(b"|".join(valid_line_patterns))
for pattern in valid_line_patterns: self.partial_line = b""
self.compiled_line_patterns.append(re.compile(pattern)) self.full_extra_output = b""
self.full_extra_output = ""
def find_extra_output(self, data: str) -> None: def find_extra_output(self, data: bytes) -> None:
lines = data.split("\n") *lines, self.partial_line = (self.partial_line + data).split(b"\n")
for line in lines: for line in lines:
if not line: if not self.compiled_line_pattern.match(line):
continue self.full_extra_output += line + b"\n"
found_extra_output = True
for compiled_pattern in self.compiled_line_patterns:
if compiled_pattern.match(line):
found_extra_output = False
break
if found_extra_output:
self.full_extra_output += f"{line}\n"
class TeeStderrAndFindExtraConsoleOutput: class WrappedIO(IO[bytes]):
def __init__(self, extra_output_finder: ExtraConsoleOutputFinder) -> None: def __init__(self, stream: IO[bytes], extra_output_finder: ExtraConsoleOutputFinder) -> None:
self.stderr_stream = sys.stderr self.stream = stream
# get shared console handler instance from any logger that have it
self.console_log_handler = cast(
logging.StreamHandler, logging.getLogger("django.server").handlers[0]
)
assert isinstance(self.console_log_handler, logging.StreamHandler)
assert self.console_log_handler.stream == sys.stderr
self.extra_output_finder = extra_output_finder self.extra_output_finder = extra_output_finder
def __enter__(self) -> None: @property
sys.stderr = self # type: ignore[assignment] # Doing tee by swapping stderr stream with custom file like class def mode(self) -> str:
self.console_log_handler.stream = self return self.stream.mode
@property
def name(self) -> str:
return self.stream.name
def close(self) -> None:
pass
@property
def closed(self) -> bool:
return self.stream.closed
def fileno(self) -> int:
return self.stream.fileno()
def flush(self) -> None:
self.stream.flush()
def isatty(self) -> bool:
return self.stream.isatty()
def read(self, n: int = -1) -> bytes:
return self.stream.read(n)
def readable(self) -> bool:
return self.stream.readable()
def readline(self, limit: int = -1) -> bytes:
return self.stream.readline(limit)
def readlines(self, hint: int = -1) -> List[bytes]:
return self.stream.readlines(hint)
def seek(self, offset: int, whence: int = SEEK_SET) -> int:
return self.stream.seek(offset, whence)
def seekable(self) -> bool:
return self.stream.seekable()
def tell(self) -> int:
return self.stream.tell()
def truncate(self, size: Optional[int] = None) -> int:
return self.truncate(size)
def writable(self) -> bool:
return self.stream.writable()
def write(self, data: bytes) -> int:
num_chars = self.stream.write(data)
self.extra_output_finder.find_extra_output(data)
return num_chars
def writelines(self, data: Iterable[bytes]) -> None:
self.stream.writelines(data)
lines = b"".join(data)
self.extra_output_finder.find_extra_output(lines)
def __next__(self) -> bytes:
return next(self.stream)
def __iter__(self) -> Iterator[bytes]:
return self
def __enter__(self) -> IO[bytes]:
self.stream.__enter__()
return self
def __exit__( def __exit__(
self, self,
exc_type: Optional[Type[BaseException]], exc_type: Optional[Type[BaseException]],
exc_value: Optional[BaseException], exc_value: Optional[BaseException],
traceback: Optional[TracebackType], traceback: Optional[TracebackType],
) -> None: ) -> Optional[bool]:
sys.stderr = self.stderr_stream return self.stream.__exit__(exc_type, exc_value, traceback)
self.console_log_handler.stream = sys.stderr
def write(self, data: str) -> None:
self.stderr_stream.write(data)
self.extra_output_finder.find_extra_output(data)
def writelines(self, data: Sequence[str]) -> None:
self.stderr_stream.writelines(data)
lines = "".join(data)
self.extra_output_finder.find_extra_output(lines)
def flush(self) -> None:
self.stderr_stream.flush()
class TeeStdoutAndFindExtraConsoleOutput: @contextmanager
def __init__(self, extra_output_finder: ExtraConsoleOutputFinder) -> None: def tee_stderr_and_find_extra_console_output(
self.stdout_stream = sys.stdout extra_output_finder: ExtraConsoleOutputFinder,
self.extra_output_finder = extra_output_finder ) -> Iterator[None]:
stderr = sys.stderr
def __enter__(self) -> None: # get shared console handler instance from any logger that have it
sys.stdout = self # type: ignore[assignment] # Doing tee by swapping stderr stream with custom file like class console_log_handler = logging.getLogger("django.server").handlers[0]
assert isinstance(console_log_handler, logging.StreamHandler)
assert console_log_handler.stream == stderr
def __exit__( sys.stderr = console_log_handler.stream = TextIOWrapper(
self, WrappedIO(stderr.buffer, extra_output_finder), line_buffering=True
exc_type: Optional[Type[BaseException]], )
exc_value: Optional[BaseException], try:
traceback: Optional[TracebackType], yield
) -> None: finally:
sys.stdout = self.stdout_stream try:
sys.stderr.flush()
finally:
sys.stderr = console_log_handler.stream = stderr
def write(self, data: str) -> None:
self.stdout_stream.write(data)
self.extra_output_finder.find_extra_output(data)
def writelines(self, data: Sequence[str]) -> None: @contextmanager
self.stdout_stream.writelines(data) def tee_stdout_and_find_extra_console_output(
lines = "".join(data) extra_output_finder: ExtraConsoleOutputFinder,
self.extra_output_finder.find_extra_output(lines) ) -> Iterator[None]:
stdout = sys.stdout
def flush(self) -> None: sys.stdout = TextIOWrapper(
self.stdout_stream.flush() WrappedIO(sys.stdout.buffer, extra_output_finder), line_buffering=True
)
try:
yield
finally:
try:
sys.stdout.flush()
finally:
sys.stdout = stdout