Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-117225: Add color to doctest output #117583

Merged
merged 27 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2c1108b
Add colour to doctest output and create _colorize module
hugovk Apr 6, 2024
d27c0a8
Use _colorize in traceback module
hugovk Apr 6, 2024
bb591b6
Fix whitespace
hugovk Apr 6, 2024
42079be
Use f-strings
hugovk Apr 6, 2024
0088579
Remove underscores from members of an underscored module
hugovk Apr 6, 2024
d3034fa
Add blurb
hugovk Apr 6, 2024
39780cb
Remove underscores from members of an underscored module
hugovk Apr 6, 2024
c5aec15
Revert "Fix whitespace"
hugovk Apr 6, 2024
7e40133
Move _colorize to stdlib block, colour->color
hugovk Apr 6, 2024
e484465
Move imports together
hugovk Apr 6, 2024
1c7b025
Move imports together
hugovk Apr 6, 2024
ab2c94c
Move imports together
hugovk Apr 6, 2024
1aaeab8
Revert notests -> no_tests
hugovk Apr 6, 2024
cd02e4a
Revert "Use f-strings"
hugovk Apr 6, 2024
06543ff
Fix local tests
hugovk Apr 6, 2024
31c6647
Use red divider for failed test
hugovk Apr 7, 2024
9be3d81
Fix local tests
hugovk Apr 7, 2024
e4ff3e3
Less red
hugovk Apr 7, 2024
b62500a
Revert unnecessary changes
hugovk Apr 7, 2024
eb4f8dc
Move colour tests to test__colorize.py
hugovk Apr 7, 2024
976bfb4
Refactor asserts
hugovk Apr 7, 2024
ad7a946
Add missing captured_output to test.support's __all__ to fix IDE warning
hugovk Apr 7, 2024
796e9f2
Only move test_colorized_detection_checks_for_environment_variables f…
hugovk Apr 7, 2024
99d4d0c
Apply suggestions from code review
hugovk Apr 7, 2024
95b9831
Use unittest's enterContext
hugovk Apr 7, 2024
d5417b4
Merge remote-tracking branch 'upstream/main' into doctest-tidy-output…
hugovk Apr 16, 2024
ece3ce0
Keep colorize functionality in traceback module for now
hugovk Apr 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions Lib/_colorize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import io
import os
import sys

COLORIZE = True


class ANSIColors:
BOLD_GREEN = "\x1b[1;32m"
BOLD_MAGENTA = "\x1b[1;35m"
BOLD_RED = "\x1b[1;31m"
GREEN = "\x1b[32m"
GREY = "\x1b[90m"
MAGENTA = "\x1b[35m"
RED = "\x1b[31m"
RESET = "\x1b[0m"
YELLOW = "\x1b[33m"

Copy link
Contributor

@Privat33r-dev Privat33r-dev Apr 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ANSIColorsStub = type('ANSIColorsStub', (ANSIColors,), {x: "" for x in dir(ANSIColors) if not x.startswith('__')})
def get_ansi_colors() -> ANSIColors:
return ANSIColors if can_colorize() else ANSIColorsStub

Using get_ansi_colors() preserves IDE suggestions (ctrl+space) and centralizes, as well as abstracts color availability logic, improving code's clarity and flexibility.


def can_colorize():
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
if sys.platform == "win32":
try:
import nt

if not nt._supports_virtual_terminal():
return False
except (ImportError, AttributeError):
return False

if os.environ.get("PYTHON_COLORS") == "0":
return False
if os.environ.get("PYTHON_COLORS") == "1":
return True
if "NO_COLOR" in os.environ:
return False
if not COLORIZE:
return False
if "FORCE_COLOR" in os.environ:
return True
if os.environ.get("TERM") == "dumb":
return False
try:
return os.isatty(sys.stderr.fileno())
except io.UnsupportedOperation:
return sys.stderr.isatty()
74 changes: 50 additions & 24 deletions Lib/doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def _test():
import unittest
from io import StringIO, IncrementalNewlineDecoder
from collections import namedtuple
import _colorize # Used in doctests
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved


class TestResults(namedtuple('TestResults', 'failed attempted')):
Expand Down Expand Up @@ -1030,7 +1031,7 @@ def _find(self, tests, obj, name, module, source_lines, globs, seen):
# Look for tests in a module's contained objects.
if inspect.ismodule(obj) and self._recurse:
for valname, val in obj.__dict__.items():
valname = '%s.%s' % (name, valname)
valname = f'{name}.{valname}'

# Recurse to functions & classes.
if ((self._is_routine(val) or inspect.isclass(val)) and
Expand All @@ -1051,7 +1052,7 @@ def _find(self, tests, obj, name, module, source_lines, globs, seen):
"must be strings, functions, methods, "
"classes, or modules: %r" %
(type(val),))
valname = '%s.__test__.%s' % (name, valname)
valname = f'{name}.__test__.{valname}'
self._find(tests, val, valname, module, source_lines,
globs, seen)

Expand All @@ -1066,7 +1067,7 @@ def _find(self, tests, obj, name, module, source_lines, globs, seen):
if ((inspect.isroutine(val) or inspect.isclass(val) or
isinstance(val, property)) and
self._from_module(module, val)):
valname = '%s.%s' % (name, valname)
valname = f'{name}.{valname}'
self._find(tests, val, valname, module, source_lines,
globs, seen)

Expand Down Expand Up @@ -1172,6 +1173,9 @@ class DocTestRunner:
The `run` method is used to process a single DocTest case. It
returns a TestResults instance.

>>> save_colorize = _colorize.COLORIZE
>>> _colorize.COLORIZE = False

>>> tests = DocTestFinder().find(_TestClass)
>>> runner = DocTestRunner(verbose=False)
>>> tests.sort(key = lambda test: test.name)
Expand Down Expand Up @@ -1222,6 +1226,8 @@ class DocTestRunner:
can be also customized by subclassing DocTestRunner, and
overriding the methods `report_start`, `report_success`,
`report_unexpected_exception`, and `report_failure`.

>>> _colorize.COLORIZE = save_colorize
"""
# This divider string is used to separate failure messages, and to
# separate sections of the summary.
Expand Down Expand Up @@ -1309,7 +1315,7 @@ def _failure_header(self, test, example):
out.append('File "%s", line %s, in %s' %
(test.filename, lineno, test.name))
else:
out.append('Line %s, in %s' % (example.lineno+1, test.name))
out.append(f'Line {example.lineno+1}, in {test.name}')
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
out.append('Failed example:')
source = example.source
out.append(_indent(source))
Expand Down Expand Up @@ -1566,10 +1572,12 @@ def summarize(self, verbose=None):
summary is. If the verbosity is not specified, then the
DocTestRunner's verbosity is used.
"""
from _colorize import ANSIColors, can_colorize
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved

if verbose is None:
verbose = self._verbose

notests, passed, failed = [], [], []
no_tests, passed, failed = [], [], []
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
total_tries = total_failures = total_skips = 0

for name, (failures, tries, skips) in self._stats.items():
Expand All @@ -1579,47 +1587,65 @@ def summarize(self, verbose=None):
total_skips += skips

if tries == 0:
notests.append(name)
no_tests.append(name)
elif failures == 0:
passed.append((name, tries))
else:
failed.append((name, (failures, tries, skips)))

if can_colorize():
bold_green = ANSIColors.BOLD_GREEN
bold_red = ANSIColors.BOLD_RED
green = ANSIColors.GREEN
red = ANSIColors.RED
reset = ANSIColors.RESET
yellow = ANSIColors.YELLOW
else:
bold_green = ""
bold_red = ""
green = ""
red = ""
reset = ""
yellow = ""

if verbose:
if notests:
print(f"{_n_items(notests)} had no tests:")
notests.sort()
for name in notests:
if no_tests:
print(f"{_n_items(no_tests)} had no tests:")
no_tests.sort()
for name in no_tests:
print(f" {name}")

if passed:
print(f"{_n_items(passed)} passed all tests:")
print(f"{green}{_n_items(passed)} passed all tests:{reset}")
for name, count in sorted(passed):
s = "" if count == 1 else "s"
print(f" {count:3d} test{s} in {name}")
print(f" {green}{count:3d} test{s} in {name}{reset}")

if failed:
print(self.DIVIDER)
print(f"{_n_items(failed)} had failures:")
print(f"{red}{_n_items(failed)} had failures:{reset}")
for name, (failures, tries, skips) in sorted(failed):
print(f" {failures:3d} of {tries:3d} in {name}")
print(f"{red} {failures:3d} of {tries:3d} in {name}{reset}")

if verbose:
s = "" if total_tries == 1 else "s"
print(f"{total_tries} test{s} in {_n_items(self._stats)}.")

and_f = f" and {total_failures} failed" if total_failures else ""
print(f"{total_tries - total_failures} passed{and_f}.")
and_f = (
f" and {red}{total_failures} failed{reset}"
if total_failures else ""
)
print(f"{green}{total_tries - total_failures} passed{reset}{and_f}.")

if total_failures:
s = "" if total_failures == 1 else "s"
msg = f"***Test Failed*** {total_failures} failure{s}"
msg = f"{bold_red}***Test Failed*** {total_failures} failure{s}{reset}"
if total_skips:
s = "" if total_skips == 1 else "s"
msg = f"{msg} and {total_skips} skipped test{s}"
msg = f"{msg} and {yellow}{total_skips} skipped test{s}{reset}"
print(f"{msg}.")
elif verbose:
print("Test passed.")
print(f"{bold_green}Test passed.{reset}")

return TestResults(total_failures, total_tries, skipped=total_skips)

Expand All @@ -1637,7 +1663,7 @@ def merge(self, other):
d[name] = (failures, tries, skips)


def _n_items(items: list) -> str:
def _n_items(items: list | dict) -> str:
"""
Helper to pluralise the number of items in a list.
"""
Expand All @@ -1648,7 +1674,7 @@ def _n_items(items: list) -> str:

class OutputChecker:
"""
A class used to check the whether the actual output from a doctest
A class used to check whether the actual output from a doctest
example matches the expected output. `OutputChecker` defines two
methods: `check_output`, which compares a given pair of outputs,
and returns true if they match; and `output_difference`, which
Expand Down Expand Up @@ -1784,7 +1810,7 @@ def output_difference(self, example, got, optionflags):
# If we're not using diff, then simply list the expected
# output followed by the actual output.
if want and got:
return 'Expected:\n%sGot:\n%s' % (_indent(want), _indent(got))
return f'Expected:\n{_indent(want)}Got:\n{_indent(got)}'
elif want:
return 'Expected:\n%sGot nothing\n' % _indent(want)
elif got:
Expand Down Expand Up @@ -2019,7 +2045,7 @@ class doctest.Tester, then merges the results into (or creates)

# Check that we were actually given a module.
if not inspect.ismodule(m):
raise TypeError("testmod: module required; %r" % (m,))
raise TypeError(f"testmod: module required; {m!r}")

# If no name was given, then use the module's name.
if name is None:
Expand Down Expand Up @@ -2393,7 +2419,7 @@ def __hash__(self):

def __repr__(self):
name = self._dt_test.name.split('.')
return "%s (%s)" % (name[-1], '.'.join(name[:-1]))
return f"{name[-1]} ({'.'.join(name[:-1])})"

__str__ = object.__str__

Expand Down
8 changes: 6 additions & 2 deletions Lib/test/test_doctest/test_doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import tempfile
import types
import contextlib
import _colorize # used in doctests


if not support.has_subprocess_support:
Expand Down Expand Up @@ -466,7 +467,7 @@ def basics(): r"""
>>> tests = finder.find(sample_func)

>>> print(tests) # doctest: +ELLIPSIS
[<DocTest sample_func from test_doctest.py:33 (1 example)>]
[<DocTest sample_func from test_doctest.py:35 (1 example)>]

The exact name depends on how test_doctest was invoked, so allow for
leading path components.
Expand Down Expand Up @@ -2634,8 +2635,10 @@ def test_testfile(): r"""
called with the name of a file, which is taken to be relative to the
calling module. The return value is (#failures, #tests).

We don't want `-v` in sys.argv for these tests.
We don't want color or `-v` in sys.argv for these tests.

>>> save_colorize = _colorize.COLORIZE
>>> _colorize.COLORIZE = False
>>> save_argv = sys.argv
>>> if '-v' in sys.argv:
... sys.argv = [arg for arg in save_argv if arg != '-v']
Expand Down Expand Up @@ -2802,6 +2805,7 @@ def test_testfile(): r"""
TestResults(failed=0, attempted=2)
>>> doctest.master = None # Reset master.
>>> sys.argv = save_argv
>>> _colorize.COLORIZE = save_colorize
"""

class TestImporter(importlib.abc.MetaPathFinder, importlib.abc.ResourceLoader):
Expand Down
18 changes: 15 additions & 3 deletions Lib/test/test_doctest/test_doctest2.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import sys
import unittest
import _colorize

if sys.flags.optimize >= 2:
raise unittest.SkipTest("Cannot test docstrings with -O2")

Expand Down Expand Up @@ -108,14 +110,24 @@ def clsm(cls, val):


class Test(unittest.TestCase):
def setUp(self):
super().setUp()
self.colorize = _colorize.COLORIZE
_colorize.COLORIZE = False

def tearDown(self):
super().tearDown()
_colorize.COLORIZE = self.colorize

hugovk marked this conversation as resolved.
Show resolved Hide resolved
def test_testmod(self):
import doctest, sys
import doctest
import sys
EXPECTED = 19
f, t = doctest.testmod(sys.modules[__name__])
if f:
self.fail("%d of %d doctests failed" % (f, t))
self.fail(f"{f} of {t} doctests failed")
if t != EXPECTED:
self.fail("expected %d tests to run, not %d" % (EXPECTED, t))
self.fail(f"expected {EXPECTED} tests to run, not {t}")


# Pollute the namespace with a bunch of imported functions and classes,
Expand Down
Loading
Loading