bpo-42797: Add `-m` option for `doctest`

Allow `doctest` to filter on specific tests and examples with a glob
pattern and a list of indexes, eg:

`python -m doctest my_module.py -m open_*_file -m save_file:2,4-7`
This commit is contained in:
Costas Basdekis 2020-12-31 19:19:15 +00:00
parent f4936ad1c4
commit b88c0b39b1
5 changed files with 590 additions and 7 deletions

View File

@ -130,6 +130,29 @@ And so on, eventually ending with:
Test passed.
$
You can also limit the number of tests by passing a glob-like expression, and
optionally the indexes of the test cases:
The following will only run tests for methods that end in 'my_method':
.. code-block:: shell-session
$ python example.py -m my_method
The following will only run tests for methods in my module that end in
'my_method':
.. code-block:: shell-session
$ python example.py -m my_module.*.my_method
The following will only run tests for methods in my module that end in
'my_method', and also only test cases 0, 1, 4, 5, 6, 10, and all the rest:
.. code-block:: shell-session
$ python example.py -m my_module.*.my_method:-1,4-6,10-
That's all you need to know to start making productive use of :mod:`doctest`!
Jump in. The following sections provide full details. Note that there are many
examples of doctests in the standard Python test suite and libraries.
@ -288,6 +311,22 @@ strings are treated as if they were docstrings. In output, a key ``K`` in
Any classes found are recursively searched similarly, to test docstrings in
their contained methods and nested classes.
You can also filter the tests and test cases via ``-m``/``--match``:
.. code-block:: shell-session
$ python example.py -m my_method:4 -m ...
The first part is required and it is the suffix of the full test name (usually
something like ``__main__.method_name`` or ``__main__.ClassName.method_name``),
where you can also add ``*`` to match any substring, eg ``my_method``,
``my_*_method``, ``my_module.*.my_method``. Since it's a suffix a ``*`` is
implicitly added to the beginning of the pattern.
The second part is optional (along with ``:``) and it can be a list of 0-based
indexes or range of indexes, eg ``1``, ``4-8``, ``-8``, ``4-``, or a mix of them
``-3,8,12,15-``.
.. impl-detail::
Prior to version 3.4, extension modules written in C were not fully
searched by doctest.

View File

@ -93,6 +93,7 @@ __all__ = [
]
import __future__
import copy
import difflib
import inspect
import linecache
@ -104,6 +105,8 @@ import traceback
import unittest
from io import StringIO
from collections import namedtuple
from types import ModuleType
from typing import Pattern, List, Optional, Tuple, Union
TestResults = namedtuple('TestResults', 'failed attempted')
@ -846,7 +849,11 @@ class DocTestFinder:
self._recurse = recurse
self._exclude_empty = exclude_empty
def find(self, obj, name=None, module=None, globs=None, extraglobs=None):
def find(self, obj, name: Optional[str]=None,
module: Optional[ModuleType]=None, globs: Optional[dict]=None,
extraglobs: Optional[dict]=None,
filters_or_text: Optional[Union[str, List['DocTestFilter']]]=None)\
-> List[DocTest]:
"""
Return a list of the DocTests that are defined by the given
object's docstring, or by any of its contained objects'
@ -880,6 +887,8 @@ class DocTestFinder:
otherwise. If `extraglobs` is not specified, then it defaults
to {}.
If `filters_or_text` is specified, the tests and examples are also
filtered accordingly.
"""
# If name was not specified, then extract it from the object.
if name is None:
@ -946,8 +955,71 @@ class DocTestFinder:
# <= 2.3 that got lost by accident in 2.4. It was repaired in
# 2.4.4 and 2.5.
tests.sort()
if filters_or_text:
tests = self.filter_tests(tests, filters_or_text)
return tests
def filter_tests(self, tests: List[DocTest],
filters_or_text: Union[str, List['DocTestFilter']]) \
-> List[DocTest]:
"""
Filter collected tests according a user specification, by using
`DocTestFilter`s.
:param tests: The list of tests to filter
:param filters_or_text: Either a user-specified string, or a parsed one
:return: A (not necessarily strict) subset of the passed in tests
"""
if isinstance(filters_or_text, str):
filters_text = filters_or_text
filters = DocTestFilterParser().parse_filters(filters_text)
else:
filters = filters_or_text
return list(filter(None, (
self.filter_test(filters, test)
for test in tests
)))
def filter_test(self, filters, test: DocTest) -> Optional[DocTest]:
"""
Filter a test according the user specifications. If a test shouldn't be
run `None` is returned. If it should be, and all of its examples should
as well, it's returned as is. If only a few of its examples should be,
a new test with only the appropriate examples is returned.
:param filters: The user-specified `DocTestFilter`s
:param test: A test to either discard, accept it fully, or with a subset
of its examples
:return: Either `None` to discard the test, the test as is, or a copy
with fewer examples
"""
matching_filters = [
_filter
for _filter in filters
if _filter.matches_test(test)
]
if not matching_filters:
return None
examples = [
example
for index, example in enumerate(test.examples)
if any(
_filter.matches_example(test, example, index)
for _filter in matching_filters
)
]
if not examples:
return None
if examples != test.examples:
test = copy.copy(test)
test.examples = examples
return test
def _from_module(self, module, object):
"""
Return true if the given object is defined in the given
@ -1125,6 +1197,452 @@ class DocTestFinder:
# We couldn't find the line number.
return None
class DocTestFilter:
"""
Used by `DocTestFinder` to limit which tests should be run. It's
usually created by `DocTestFilterParser`, in response to a user-defined
filter string.
It can filter in two ways:
* on the full qualified name of the test (eg 'module.Class.method') via a
regular expression
* and also optionally via a list of 0-based text index ranges
"""
def __init__(self, name_regex: Pattern,
number_ranges: Optional[List[range]]):
"""
:param name_regex: A compiled regular expression from `re.compile`
:param number_ranges: A list objects that supports the `in` operator.
Normally this will be a list of `range` objects.
"""
self.name_regex = name_regex
self.number_ranges = number_ranges
def matches_test(self, test: DocTest) -> bool:
"""
Check if a test should be run based only on the name.
:param test: The `DocTest` object to check against
:return: Whether it matches
>>> DocTestFilter(re.compile(r"^method$"), []).matches_test(
... DocTest([], {}, "method", "file.py", 0, ""))
True
>>> DocTestFilter(re.compile(r"^method$"), []).matches_test(
... DocTest([], {}, "another_method", "file.py", 0, ""))
False
>>> DocTestFilter(re.compile(r"^.*method$"), []).matches_test(
... DocTest([], {}, "another_method", "file.py", 0, ""))
True
"""
return bool(self.name_regex.match(test.name))
def matches_example(self, test: DocTest, example: Example, index: int
) -> bool:
"""
Check if a test's example should be run based on the example's index. It
is assumed that `matches_test` has already been used to match the `test`
object.
:param test: The `DocTest` object which contains `example`
:param example: The `Example` object to check against
:param index: The 0-based index of the `Example` within `test`
:return: Whether it matches
>>> a_test = DocTest([], {}, "another_method", "file.py", 0, "")
>>> DocTestFilter(re.compile(r"^.*method$"), None).matches_example(
... a_test, Example("", ""), 0)
True
>>> DocTestFilter(re.compile(r"^.*method$"), [
... range(5),
... ]).matches_example(a_test, Example("", ""), 0)
True
>>> DocTestFilter(re.compile(r"^.*method$"), [
... range(5),
... ]).matches_example(a_test, Example("", ""), 5)
False
>>> DocTestFilter(re.compile(r"^.*method$"), [
... range(5), range(10, 20), [5],
... ]).matches_example(a_test, Example("", ""), 5)
True
>>> DocTestFilter(re.compile(r"^.*method$"), [
... range(10), range(20), [5],
... ]).matches_example(a_test, Example("", ""), 5)
True
"""
if self.number_ranges is None:
return True
return any(
index
in number_range
for number_range in self.number_ranges
)
class InvalidTestFilterException(Exception):
"""
Raised in `DocTestFilterParser.parse_filters` (and all other methods in it)
to signify an invalid pattern.
"""
class DocTestFilterParser:
"""
Since we allow the user to pass a filter for selecting tests, this class
helps with parsing that text, and converting it to a list `DocTestFilter`s.
It accepts filters with the following attributes:
* Each filter must have a name suffix specifier
* Name specifiers can have '*' inside them to match any sub-string
* The name can not be empty or only '*'
* Each filter can also optionally contain a set of indexes or ranges of
indexes to limit which examples are run inside a test
Examples of understood patterns:
* method
* group_of_*_methods
* specific.module.*.method
* methods_with_optional_suffix*
* method:5
* method:2-5
* method:2-
* method:-5
* method:-3,5-10,15-
"""
def parse_filters(self, filters_text: str) -> List[DocTestFilter]:
"""
Convert a user-provided filter text to a list of filters
:param filters_text: The user input
:return: A list of `DocTestFilter` instances
"""
return [
self.parse_filter(filter_text)
for filter_text in self.split_filter_texts(filters_text)
]
def split_filter_texts(self, filters_text: str) -> List[str]:
"""
Split the user input into multiple filters, to be parsed
:param filters_text: The user input
:return: A list of strings that each should be converted to a
`DocTestFilter`
>>> DocTestFilterParser().split_filter_texts("")
[]
>>> DocTestFilterParser().split_filter_texts(" " * 20)
[]
>>> DocTestFilterParser().split_filter_texts(" abc def ghi ")
['abc', 'def', 'ghi']
"""
filters_text = filters_text.strip()
if not filters_text:
return []
return re.split(r"\s+", filters_text)
def parse_filter(self, filter_text: str) -> DocTestFilter:
"""
Convert the text for a single filter to a `DocTestFilter`
:param filter_text: Part of the user input
:return: `DocTestFilter`
"""
test_name_text, line_numbers_text = self.get_filter_parts(filter_text)
return DocTestFilter(
self.parse_test_name(test_name_text),
self.parse_number_ranges(line_numbers_text),
)
def get_filter_parts(self, filter_text: str) -> Tuple[str, str]:
"""
Split the filter into two parts: the name specifier and the index
specifier.
:param filter_text: Part of the user input
:return: A tuple of the name specifier and the index specifier
>>> DocTestFilterParser().get_filter_parts("method")
('method', '')
>>> DocTestFilterParser().get_filter_parts("method:512,3,-7")
('method', '512,3,-7')
>>> DocTestFilterParser().get_filter_parts(":512,3,-7")
('', '512,3,-7')
>>> DocTestFilterParser().get_filter_parts(":")
('', '')
>>> DocTestFilterParser().get_filter_parts("method:512,3,-7:") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
"""
filter_text = filter_text.strip()
parts = filter_text.split(':')
if len(parts) > 2:
raise InvalidTestFilterException(
f"A filter should have at most two parts, name and ranges, "
f"not {len(parts)}: '{filter_text}'")
if len(parts) == 1:
test_name_text, = parts
numbers_text = ''
else:
test_name_text, numbers_text = parts
return test_name_text, numbers_text
def parse_test_name(self, test_name_text: str) -> Pattern:
"""
Convert a name speficier to a regex:
* Escape the specifier so that it matches literally the name
* Convert '*' to regex '.*' to allow to match substrings
* The resulting regex should not be equivalent to '^.*$', ie it should
match literally some part of the method
:param test_name_text: The name specifier, perhaps containing '*'
:return: The compiled regex
>>> def test(pattern):
... regex = DocTestFilterParser().parse_test_name(pattern)
... return [
... name
... for name in [
... f'{part}.{_type}.{prefix}{method}{suffix}'
... for part in ['part_a', 'part_b']
... for _type in ['TypeA', 'TypeB']
... for method in ['method', 'function']
... for suffix in ['', '_plus']
... for prefix in ['', 'plus_']
... ]
... if regex.match(name)
... ]
>>> test("method") # doctest: +NORMALIZE_WHITESPACE
['part_a.TypeA.method', 'part_a.TypeA.plus_method',
'part_a.TypeB.method', 'part_a.TypeB.plus_method',
'part_b.TypeA.method', 'part_b.TypeA.plus_method',
'part_b.TypeB.method', 'part_b.TypeB.plus_method']
>>> test("method*") # doctest: +NORMALIZE_WHITESPACE
['part_a.TypeA.method', 'part_a.TypeA.plus_method',
'part_a.TypeA.method_plus', 'part_a.TypeA.plus_method_plus',
'part_a.TypeB.method', 'part_a.TypeB.plus_method',
'part_a.TypeB.method_plus', 'part_a.TypeB.plus_method_plus',
'part_b.TypeA.method', 'part_b.TypeA.plus_method',
'part_b.TypeA.method_plus', 'part_b.TypeA.plus_method_plus',
'part_b.TypeB.method', 'part_b.TypeB.plus_method',
'part_b.TypeB.method_plus', 'part_b.TypeB.plus_method_plus']
>>> test("part_a.*method") # doctest: +NORMALIZE_WHITESPACE
['part_a.TypeA.method', 'part_a.TypeA.plus_method',
'part_a.TypeB.method', 'part_a.TypeB.plus_method']
>>> test("part_a.*method*") # doctest: +NORMALIZE_WHITESPACE
['part_a.TypeA.method', 'part_a.TypeA.plus_method',
'part_a.TypeA.method_plus', 'part_a.TypeA.plus_method_plus',
'part_a.TypeB.method', 'part_a.TypeB.plus_method',
'part_a.TypeB.method_plus', 'part_a.TypeB.plus_method_plus']
>>> test("") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
"""
test_name_text = test_name_text.strip()
if not test_name_text.replace("*", ""):
raise InvalidTestFilterException(
f"You need to specify at least some part of the test name")
parts = re.split(r'\*+', '*' + test_name_text)
escaped_parts = map(re.escape, parts)
return re.compile(f"{'.*'.join(escaped_parts)}$")
def parse_number_ranges(self, number_ranges_text: str) -> List[range]:
"""
Convert an index specifier to a list of `range`s, to allow matching
specific examples. An empty specifier returns one range that should
match any index.
Examples:
* '1': Match a specific example
* '2-5': Match examples 2 to 5, inclusive
* '-5': Up to 5, inclusive
* '2-': From 2 to the end, inclusive
* '-' or '': Match all examples
* '-3,5-10,20-': A combination of the above
:param number_ranges_text: The index specifier for a test
:return: A list of ranges to match the index of examples
>>> def test(pattern):
... result = DocTestFilterParser().parse_number_ranges(pattern)
... return sorted(set(sum(map(list, result), [])))[:1010]
>>> test("") # doctest: +ELLIPSIS
[0, 1, 2, ..., 1000, 1001, ...]
>>> test("-") # doctest: +ELLIPSIS
[0, 1, 2, ..., 1000, 1001, ...]
>>> test("512") # doctest: +ELLIPSIS
[512]
>>> test("-512") # doctest: +ELLIPSIS
[0, 1, 2, ..., 510, 511, 512]
>>> test("512-") # doctest: +ELLIPSIS
[512, 513, 514, ..., 1000, 1001, ...]
>>> test("256-512") # doctest: +ELLIPSIS
[256, 257, 258, ..., 510, 511, 512]
>>> test("512-256") # doctest: +ELLIPSIS
[]
>>> test("256-512-768") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
>>> test("0xf") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
>>> test("5-abc") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
>>> test("1,5,7") # doctest: +ELLIPSIS
[1, 5, 7]
>>> test("1,10-20,7") # doctest: +ELLIPSIS
[1, 7, 10, 11, 12, ..., 18, 19, 20]
>>> test("1,20-10,7") # doctest: +ELLIPSIS
[1, 7]
>>> test("1,20-10,7,10,11") # doctest: +ELLIPSIS
[1, 7, 10, 11]
>>> test("1,10-20,-5") # doctest: +ELLIPSIS
[0, 1, 2, 3, 4, 5, 10, 11, 12, ..., 18, 19, 20]
>>> test("600-,10-20,-5") # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
[0, 1, 2, 3, 4, 5, 10, 11, 12, ..., 18, 19, 20, 600, 601, 602,
..., 1000, 1001, ...]
>>> test("600-,10-20,,-5") # doctest: +ELLIPSIS
[0, 1, 2, ..., 1000, 1001, ...]
>>> test("600-,10-20-40,,-5") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
>>> test("600-,10-0xf,,-5") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
>>> test("600-,10-abc,,-5") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
"""
return [
self.parse_number_range(number_range_text)
for number_range_text in number_ranges_text.split(',')
]
def parse_number_range(self, number_range_text: str) -> range:
"""
Convert a part of an index specifier to a list of `range`s, to allow
matching specific examples. An empty specifier returns a range that
should match any index.
Examples:
* '1': Match a specific example
* '2-5': Match examples 2 to 5, inclusive
* '-5': Up to 5, inclusive
* '2-': From 2 to the end, inclusive
* '-' or '': Match all examples
:param number_range_text: A part of an index specifier
:return: A range that matches the respective example indexes
>>> def test(pattern):
... result = DocTestFilterParser().parse_number_range(pattern)
... return sorted(result)[:1010]
>>> test("") # doctest: +ELLIPSIS
[0, 1, 2, ..., 1000, 1001, ...]
>>> test("-") # doctest: +ELLIPSIS
[0, 1, 2, ..., 1000, 1001, ...]
>>> test("512") # doctest: +ELLIPSIS
[512]
>>> test("-512") # doctest: +ELLIPSIS
[0, 1, 2, ..., 510, 511, 512]
>>> test("512-") # doctest: +ELLIPSIS
[512, 513, 514, ..., 1000, 1001, ...]
>>> test("256-512") # doctest: +ELLIPSIS
[256, 257, 258, ..., 510, 511, 512]
>>> test("512-256") # doctest: +ELLIPSIS
[]
>>> test("256-512-768") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
>>> test("0xf") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
>>> test("5-abc") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
"""
number_range_text = number_range_text.strip()
parts = number_range_text.split('-')
if len(parts) > 2:
raise InvalidTestFilterException(
f"Number ranges must be either a single number "
f"(eg '512'), or a range either without start (eg '-512'), "
f"without end (eg '512-') or with just start and end "
f"(eg '256-512), not with more parts: "
f"'{number_range_text}'")
if len(parts) == 1:
number_text, = parts
if not number_text:
start = 0
end = 10000
else:
start = end = self.parse_number(number_text)
else:
start_number_text, end_number_text = parts
if not start_number_text and not end_number_text:
start = 0
end = 10000
elif not start_number_text:
start = 0
end = self.parse_number(end_number_text)
elif not end_number_text:
start = self.parse_number(start_number_text)
end = 10000
else:
start = self.parse_number(start_number_text)
end = self.parse_number(end_number_text)
return range(start, end + 1)
def parse_number(self, number_text: str) -> int:
"""
A convenient method to raise an appropriate error if the number to parse
was not a non-negative integer
:param number_text: A number string to parse
:return: The parsed number
>>> DocTestFilterParser().parse_number("0")
0
>>> DocTestFilterParser().parse_number("512")
512
>>> DocTestFilterParser().parse_number("-4") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
>>> DocTestFilterParser().parse_number("0xf") # doctest: +ELLIPSIS
Traceback (most recent call last):
...
doctest.InvalidTestFilterException: ...
"""
try:
number = int(number_text)
except ValueError:
number = None
if number is None or number < 0:
raise InvalidTestFilterException(
f"Line numbers must be positive integers, not "
f"'{number_text}'")
return number
######################################################################
## 5. DocTest Runner
######################################################################
@ -1867,7 +2385,8 @@ master = None
def testmod(m=None, name=None, globs=None, verbose=None,
report=True, optionflags=0, extraglobs=None,
raise_on_error=False, exclude_empty=False):
raise_on_error=False, exclude_empty=False, finder=None,
filters_or_text=None):
"""m=None, name=None, globs=None, verbose=None, report=True,
optionflags=0, extraglobs=None, raise_on_error=False,
exclude_empty=False
@ -1923,6 +2442,13 @@ def testmod(m=None, name=None, globs=None, verbose=None,
first unexpected exception or failure. This allows failures to be
post-mortem debugged.
Optional keyword arg "finder" specifies a finder instance to use, besdies
the default `DocTestFinder`.
Optional keyword arg "filters_or_text" specifies either the raw
user-specified filter text, or the parsed version, in order to limit which
tests and examples to run.
Advanced tomfoolery: testmod runs methods of a local instance of
class doctest.Tester, then merges the results into (or creates)
global Tester instance doctest.master. Methods of doctest.master
@ -1949,14 +2475,18 @@ def testmod(m=None, name=None, globs=None, verbose=None,
name = m.__name__
# Find, parse, and run all tests in the given module.
finder = DocTestFinder(exclude_empty=exclude_empty)
if finder is None:
finder = DocTestFinder(exclude_empty=exclude_empty)
if raise_on_error:
runner = DebugRunner(verbose=verbose, optionflags=optionflags)
else:
runner = DocTestRunner(verbose=verbose, optionflags=optionflags)
for test in finder.find(m, name, globs=globs, extraglobs=extraglobs):
tests = finder.find(
m, name, globs=globs, extraglobs=extraglobs,
filters_or_text=filters_or_text)
for test in tests:
runner.run(test)
if report:
@ -2759,6 +3289,10 @@ def _test():
help=('specify a doctest option flag to apply'
' to the test run; may be specified more'
' than once to apply multiple options'))
parser.add_argument('-m', '--match', action='append', default=[],
help=('specify which tests and examples to run; may be '
'specified more than once to select multiple'
' methods'))
parser.add_argument('-f', '--fail-fast', action='store_true',
help=('stop running tests after first failure (this'
' is a shorthand for -o FAIL_FAST, and is'
@ -2775,6 +3309,10 @@ def _test():
options |= OPTIONFLAGS_BY_NAME[option]
if args.fail_fast:
options |= FAIL_FAST
if args.match:
filters_text = " ".join(args.match)
else:
filters_text = None
for filename in testfiles:
if filename.endswith(".py"):
# It is a module -- insert its dir into sys.path and try to
@ -2784,10 +3322,11 @@ def _test():
sys.path.insert(0, dirname)
m = __import__(filename[:-3])
del sys.path[0]
failures, _ = testmod(m, verbose=verbose, optionflags=options)
failures, _ = testmod(m, verbose=verbose, optionflags=options,
filters_or_text=filters_text)
else:
failures, _ = testfile(filename, module_relative=False,
verbose=verbose, optionflags=options)
verbose=verbose, optionflags=options)
if failures:
return 1
return 0

View File

@ -7,6 +7,7 @@ import sys
from textwrap import dedent
from types import FunctionType, MethodType, BuiltinFunctionType
import pyclbr
from typing import _SpecialForm
from unittest import TestCase, main as unittest_main
from test.test_importlib import util as test_importlib_util
@ -87,7 +88,8 @@ class PyclbrTest(TestCase):
self.assertHasattr(module, name, ignore)
py_item = getattr(module, name)
if isinstance(value, pyclbr.Function):
self.assertIsInstance(py_item, (FunctionType, BuiltinFunctionType))
self.assertIsInstance(py_item, (
FunctionType, BuiltinFunctionType, _SpecialForm))
if py_item.__module__ != moduleName:
continue # skip functions that came from somewhere else
self.assertEqual(py_item.__module__, value.module)

View File

@ -114,6 +114,7 @@ Des Barry
Emanuel Barry
Ulf Bartelt
Campbell Barton
Costas Basdekis
Don Bashford
Pior Bastida
Nick Bastin

View File

@ -0,0 +1,2 @@
Add a ``-m``/``--match`` option to the :mod:`doctest` module to filter which
tests and examples should be run.