doctest: Add +NUMBER option to ignore irrelevant floating-point… (#5576)
doctest: Add +NUMBER option to ignore irrelevant floating-point differences
This commit is contained in:
commit
666acc9b7a
1
AUTHORS
1
AUTHORS
|
@ -71,6 +71,7 @@ Danielle Jenkins
|
||||||
Dave Hunt
|
Dave Hunt
|
||||||
David Díaz-Barquero
|
David Díaz-Barquero
|
||||||
David Mohr
|
David Mohr
|
||||||
|
David Paul Röthlisberger
|
||||||
David Szotten
|
David Szotten
|
||||||
David Vierra
|
David Vierra
|
||||||
Daw-Ran Liou
|
Daw-Ran Liou
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
New `NUMBER <https://docs.pytest.org/en/latest/doctest.html#using-doctest-options>`__
|
||||||
|
option for doctests to ignore irrelevant differences in floating-point numbers.
|
||||||
|
Inspired by Sébastien Boisgérault's `numtest <https://github.com/boisgera/numtest>`__
|
||||||
|
extension for doctest.
|
|
@ -103,7 +103,7 @@ that will be used for those doctest files using the
|
||||||
Using 'doctest' options
|
Using 'doctest' options
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
The standard ``doctest`` module provides some `options <https://docs.python.org/3/library/doctest.html#option-flags>`__
|
Python's standard ``doctest`` module provides some `options <https://docs.python.org/3/library/doctest.html#option-flags>`__
|
||||||
to configure the strictness of doctest tests. In pytest, you can enable those flags using the
|
to configure the strictness of doctest tests. In pytest, you can enable those flags using the
|
||||||
configuration file.
|
configuration file.
|
||||||
|
|
||||||
|
@ -115,23 +115,50 @@ lengthy exception stack traces you can just write:
|
||||||
[pytest]
|
[pytest]
|
||||||
doctest_optionflags= NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL
|
doctest_optionflags= NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL
|
||||||
|
|
||||||
pytest also introduces new options to allow doctests to run in Python 2 and
|
|
||||||
Python 3 unchanged:
|
|
||||||
|
|
||||||
* ``ALLOW_UNICODE``: when enabled, the ``u`` prefix is stripped from unicode
|
|
||||||
strings in expected doctest output.
|
|
||||||
|
|
||||||
* ``ALLOW_BYTES``: when enabled, the ``b`` prefix is stripped from byte strings
|
|
||||||
in expected doctest output.
|
|
||||||
|
|
||||||
Alternatively, options can be enabled by an inline comment in the doc test
|
Alternatively, options can be enabled by an inline comment in the doc test
|
||||||
itself:
|
itself:
|
||||||
|
|
||||||
.. code-block:: rst
|
.. code-block:: rst
|
||||||
|
|
||||||
# content of example.rst
|
>>> something_that_raises() # doctest: +IGNORE_EXCEPTION_DETAIL
|
||||||
>>> get_unicode_greeting() # doctest: +ALLOW_UNICODE
|
Traceback (most recent call last):
|
||||||
'Hello'
|
ValueError: ...
|
||||||
|
|
||||||
|
pytest also introduces new options:
|
||||||
|
|
||||||
|
* ``ALLOW_UNICODE``: when enabled, the ``u`` prefix is stripped from unicode
|
||||||
|
strings in expected doctest output. This allows doctests to run in Python 2
|
||||||
|
and Python 3 unchanged.
|
||||||
|
|
||||||
|
* ``ALLOW_BYTES``: similarly, the ``b`` prefix is stripped from byte strings
|
||||||
|
in expected doctest output.
|
||||||
|
|
||||||
|
* ``NUMBER``: when enabled, floating-point numbers only need to match as far as
|
||||||
|
the precision you have written in the expected doctest output. For example,
|
||||||
|
the following output would only need to match to 2 decimal places::
|
||||||
|
|
||||||
|
>>> math.pi
|
||||||
|
3.14
|
||||||
|
|
||||||
|
If you wrote ``3.1416`` then the actual output would need to match to 4
|
||||||
|
decimal places; and so on.
|
||||||
|
|
||||||
|
This avoids false positives caused by limited floating-point precision, like
|
||||||
|
this::
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
0.233
|
||||||
|
Got:
|
||||||
|
0.23300000000000001
|
||||||
|
|
||||||
|
``NUMBER`` also supports lists of floating-point numbers -- in fact, it
|
||||||
|
matches floating-point numbers appearing anywhere in the output, even inside
|
||||||
|
a string! This means that it may not be appropriate to enable globally in
|
||||||
|
``doctest_optionflags`` in your configuration file.
|
||||||
|
|
||||||
|
|
||||||
|
Continue on failure
|
||||||
|
-------------------
|
||||||
|
|
||||||
By default, pytest would report only the first failure for a given doctest. If
|
By default, pytest would report only the first failure for a given doctest. If
|
||||||
you want to continue the test even when you have failures, do:
|
you want to continue the test even when you have failures, do:
|
||||||
|
|
|
@ -13,6 +13,7 @@ from _pytest._code.code import TerminalRepr
|
||||||
from _pytest.compat import safe_getattr
|
from _pytest.compat import safe_getattr
|
||||||
from _pytest.fixtures import FixtureRequest
|
from _pytest.fixtures import FixtureRequest
|
||||||
from _pytest.outcomes import Skipped
|
from _pytest.outcomes import Skipped
|
||||||
|
from _pytest.python_api import approx
|
||||||
from _pytest.warning_types import PytestWarning
|
from _pytest.warning_types import PytestWarning
|
||||||
|
|
||||||
DOCTEST_REPORT_CHOICE_NONE = "none"
|
DOCTEST_REPORT_CHOICE_NONE = "none"
|
||||||
|
@ -286,6 +287,7 @@ def _get_flag_lookup():
|
||||||
COMPARISON_FLAGS=doctest.COMPARISON_FLAGS,
|
COMPARISON_FLAGS=doctest.COMPARISON_FLAGS,
|
||||||
ALLOW_UNICODE=_get_allow_unicode_flag(),
|
ALLOW_UNICODE=_get_allow_unicode_flag(),
|
||||||
ALLOW_BYTES=_get_allow_bytes_flag(),
|
ALLOW_BYTES=_get_allow_bytes_flag(),
|
||||||
|
NUMBER=_get_number_flag(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -453,10 +455,15 @@ def _setup_fixtures(doctest_item):
|
||||||
|
|
||||||
def _get_checker():
|
def _get_checker():
|
||||||
"""
|
"""
|
||||||
Returns a doctest.OutputChecker subclass that takes in account the
|
Returns a doctest.OutputChecker subclass that supports some
|
||||||
ALLOW_UNICODE option to ignore u'' prefixes in strings and ALLOW_BYTES
|
additional options:
|
||||||
to strip b'' prefixes.
|
|
||||||
Useful when the same doctest should run in Python 2 and Python 3.
|
* ALLOW_UNICODE and ALLOW_BYTES options to ignore u'' and b''
|
||||||
|
prefixes (respectively) in string literals. Useful when the same
|
||||||
|
doctest should run in Python 2 and Python 3.
|
||||||
|
|
||||||
|
* NUMBER to ignore floating-point differences smaller than the
|
||||||
|
precision of the literal number in the doctest.
|
||||||
|
|
||||||
An inner class is used to avoid importing "doctest" at the module
|
An inner class is used to avoid importing "doctest" at the module
|
||||||
level.
|
level.
|
||||||
|
@ -469,38 +476,89 @@ def _get_checker():
|
||||||
|
|
||||||
class LiteralsOutputChecker(doctest.OutputChecker):
|
class LiteralsOutputChecker(doctest.OutputChecker):
|
||||||
"""
|
"""
|
||||||
Copied from doctest_nose_plugin.py from the nltk project:
|
Based on doctest_nose_plugin.py from the nltk project
|
||||||
https://github.com/nltk/nltk
|
(https://github.com/nltk/nltk) and on the "numtest" doctest extension
|
||||||
|
by Sebastien Boisgerault (https://github.com/boisgera/numtest).
|
||||||
Further extended to also support byte literals.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_unicode_literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE)
|
_unicode_literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE)
|
||||||
_bytes_literal_re = re.compile(r"(\W|^)[bB]([rR]?[\'\"])", re.UNICODE)
|
_bytes_literal_re = re.compile(r"(\W|^)[bB]([rR]?[\'\"])", re.UNICODE)
|
||||||
|
_number_re = re.compile(
|
||||||
|
r"""
|
||||||
|
(?P<number>
|
||||||
|
(?P<mantissa>
|
||||||
|
(?P<integer1> [+-]?\d*)\.(?P<fraction>\d+)
|
||||||
|
|
|
||||||
|
(?P<integer2> [+-]?\d+)\.
|
||||||
|
)
|
||||||
|
(?:
|
||||||
|
[Ee]
|
||||||
|
(?P<exponent1> [+-]?\d+)
|
||||||
|
)?
|
||||||
|
|
|
||||||
|
(?P<integer3> [+-]?\d+)
|
||||||
|
(?:
|
||||||
|
[Ee]
|
||||||
|
(?P<exponent2> [+-]?\d+)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
def check_output(self, want, got, optionflags):
|
def check_output(self, want, got, optionflags):
|
||||||
res = doctest.OutputChecker.check_output(self, want, got, optionflags)
|
if doctest.OutputChecker.check_output(self, want, got, optionflags):
|
||||||
if res:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
allow_unicode = optionflags & _get_allow_unicode_flag()
|
allow_unicode = optionflags & _get_allow_unicode_flag()
|
||||||
allow_bytes = optionflags & _get_allow_bytes_flag()
|
allow_bytes = optionflags & _get_allow_bytes_flag()
|
||||||
if not allow_unicode and not allow_bytes:
|
allow_number = optionflags & _get_number_flag()
|
||||||
|
|
||||||
|
if not allow_unicode and not allow_bytes and not allow_number:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
else: # pragma: no cover
|
def remove_prefixes(regex, txt):
|
||||||
|
return re.sub(regex, r"\1\2", txt)
|
||||||
|
|
||||||
def remove_prefixes(regex, txt):
|
if allow_unicode:
|
||||||
return re.sub(regex, r"\1\2", txt)
|
want = remove_prefixes(self._unicode_literal_re, want)
|
||||||
|
got = remove_prefixes(self._unicode_literal_re, got)
|
||||||
|
|
||||||
if allow_unicode:
|
if allow_bytes:
|
||||||
want = remove_prefixes(self._unicode_literal_re, want)
|
want = remove_prefixes(self._bytes_literal_re, want)
|
||||||
got = remove_prefixes(self._unicode_literal_re, got)
|
got = remove_prefixes(self._bytes_literal_re, got)
|
||||||
if allow_bytes:
|
|
||||||
want = remove_prefixes(self._bytes_literal_re, want)
|
if allow_number:
|
||||||
got = remove_prefixes(self._bytes_literal_re, got)
|
got = self._remove_unwanted_precision(want, got)
|
||||||
res = doctest.OutputChecker.check_output(self, want, got, optionflags)
|
|
||||||
return res
|
return doctest.OutputChecker.check_output(self, want, got, optionflags)
|
||||||
|
|
||||||
|
def _remove_unwanted_precision(self, want, got):
|
||||||
|
wants = list(self._number_re.finditer(want))
|
||||||
|
gots = list(self._number_re.finditer(got))
|
||||||
|
if len(wants) != len(gots):
|
||||||
|
return got
|
||||||
|
offset = 0
|
||||||
|
for w, g in zip(wants, gots):
|
||||||
|
fraction = w.group("fraction")
|
||||||
|
exponent = w.group("exponent1")
|
||||||
|
if exponent is None:
|
||||||
|
exponent = w.group("exponent2")
|
||||||
|
if fraction is None:
|
||||||
|
precision = 0
|
||||||
|
else:
|
||||||
|
precision = len(fraction)
|
||||||
|
if exponent is not None:
|
||||||
|
precision -= int(exponent)
|
||||||
|
if float(w.group()) == approx(float(g.group()), abs=10 ** -precision):
|
||||||
|
# They're close enough. Replace the text we actually
|
||||||
|
# got with the text we want, so that it will match when we
|
||||||
|
# check the string literally.
|
||||||
|
got = (
|
||||||
|
got[: g.start() + offset] + w.group() + got[g.end() + offset :]
|
||||||
|
)
|
||||||
|
offset += w.end() - w.start() - (g.end() - g.start())
|
||||||
|
return got
|
||||||
|
|
||||||
_get_checker.LiteralsOutputChecker = LiteralsOutputChecker
|
_get_checker.LiteralsOutputChecker = LiteralsOutputChecker
|
||||||
return _get_checker.LiteralsOutputChecker()
|
return _get_checker.LiteralsOutputChecker()
|
||||||
|
@ -524,6 +582,15 @@ def _get_allow_bytes_flag():
|
||||||
return doctest.register_optionflag("ALLOW_BYTES")
|
return doctest.register_optionflag("ALLOW_BYTES")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_number_flag():
|
||||||
|
"""
|
||||||
|
Registers and returns the NUMBER flag.
|
||||||
|
"""
|
||||||
|
import doctest
|
||||||
|
|
||||||
|
return doctest.register_optionflag("NUMBER")
|
||||||
|
|
||||||
|
|
||||||
def _get_report_choice(key):
|
def _get_report_choice(key):
|
||||||
"""
|
"""
|
||||||
This function returns the actual `doctest` module flag value, we want to do it as late as possible to avoid
|
This function returns the actual `doctest` module flag value, we want to do it as late as possible to avoid
|
||||||
|
|
|
@ -3,6 +3,7 @@ import textwrap
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from _pytest.compat import MODULE_NOT_FOUND_ERROR
|
from _pytest.compat import MODULE_NOT_FOUND_ERROR
|
||||||
|
from _pytest.doctest import _get_checker
|
||||||
from _pytest.doctest import _is_mocked
|
from _pytest.doctest import _is_mocked
|
||||||
from _pytest.doctest import _patch_unwrap_mock_aware
|
from _pytest.doctest import _patch_unwrap_mock_aware
|
||||||
from _pytest.doctest import DoctestItem
|
from _pytest.doctest import DoctestItem
|
||||||
|
@ -838,6 +839,154 @@ class TestLiterals:
|
||||||
reprec = testdir.inline_run()
|
reprec = testdir.inline_run()
|
||||||
reprec.assertoutcome(failed=1)
|
reprec.assertoutcome(failed=1)
|
||||||
|
|
||||||
|
def test_number_re(self):
|
||||||
|
for s in [
|
||||||
|
"1.",
|
||||||
|
"+1.",
|
||||||
|
"-1.",
|
||||||
|
".1",
|
||||||
|
"+.1",
|
||||||
|
"-.1",
|
||||||
|
"0.1",
|
||||||
|
"+0.1",
|
||||||
|
"-0.1",
|
||||||
|
"1e5",
|
||||||
|
"+1e5",
|
||||||
|
"1e+5",
|
||||||
|
"+1e+5",
|
||||||
|
"1e-5",
|
||||||
|
"+1e-5",
|
||||||
|
"-1e-5",
|
||||||
|
"1.2e3",
|
||||||
|
"-1.2e-3",
|
||||||
|
]:
|
||||||
|
print(s)
|
||||||
|
m = _get_checker()._number_re.match(s)
|
||||||
|
assert m is not None
|
||||||
|
assert float(m.group()) == pytest.approx(float(s))
|
||||||
|
for s in ["1", "abc"]:
|
||||||
|
print(s)
|
||||||
|
assert _get_checker()._number_re.match(s) is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("config_mode", ["ini", "comment"])
|
||||||
|
def test_number_precision(self, testdir, config_mode):
|
||||||
|
"""Test the NUMBER option."""
|
||||||
|
if config_mode == "ini":
|
||||||
|
testdir.makeini(
|
||||||
|
"""
|
||||||
|
[pytest]
|
||||||
|
doctest_optionflags = NUMBER
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
comment = ""
|
||||||
|
else:
|
||||||
|
comment = "#doctest: +NUMBER"
|
||||||
|
|
||||||
|
testdir.maketxtfile(
|
||||||
|
test_doc="""
|
||||||
|
|
||||||
|
Scalars:
|
||||||
|
|
||||||
|
>>> import math
|
||||||
|
>>> math.pi {comment}
|
||||||
|
3.141592653589793
|
||||||
|
>>> math.pi {comment}
|
||||||
|
3.1416
|
||||||
|
>>> math.pi {comment}
|
||||||
|
3.14
|
||||||
|
>>> -math.pi {comment}
|
||||||
|
-3.14
|
||||||
|
>>> math.pi {comment}
|
||||||
|
3.
|
||||||
|
>>> 3. {comment}
|
||||||
|
3.0
|
||||||
|
>>> 3. {comment}
|
||||||
|
3.
|
||||||
|
>>> 3. {comment}
|
||||||
|
3.01
|
||||||
|
>>> 3. {comment}
|
||||||
|
2.99
|
||||||
|
>>> .299 {comment}
|
||||||
|
.3
|
||||||
|
>>> .301 {comment}
|
||||||
|
.3
|
||||||
|
>>> 951. {comment}
|
||||||
|
1e3
|
||||||
|
>>> 1049. {comment}
|
||||||
|
1e3
|
||||||
|
>>> -1049. {comment}
|
||||||
|
-1e3
|
||||||
|
>>> 1e3 {comment}
|
||||||
|
1e3
|
||||||
|
>>> 1e3 {comment}
|
||||||
|
1000.
|
||||||
|
|
||||||
|
Lists:
|
||||||
|
|
||||||
|
>>> [3.1415, 0.097, 13.1, 7, 8.22222e5, 0.598e-2] {comment}
|
||||||
|
[3.14, 0.1, 13., 7, 8.22e5, 6.0e-3]
|
||||||
|
>>> [[0.333, 0.667], [0.999, 1.333]] {comment}
|
||||||
|
[[0.33, 0.667], [0.999, 1.333]]
|
||||||
|
>>> [[[0.101]]] {comment}
|
||||||
|
[[[0.1]]]
|
||||||
|
|
||||||
|
Doesn't barf on non-numbers:
|
||||||
|
|
||||||
|
>>> 'abc' {comment}
|
||||||
|
'abc'
|
||||||
|
>>> None {comment}
|
||||||
|
""".format(
|
||||||
|
comment=comment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reprec = testdir.inline_run()
|
||||||
|
reprec.assertoutcome(passed=1)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"expression,output",
|
||||||
|
[
|
||||||
|
# ints shouldn't match floats:
|
||||||
|
("3.0", "3"),
|
||||||
|
("3e0", "3"),
|
||||||
|
("1e3", "1000"),
|
||||||
|
("3", "3.0"),
|
||||||
|
# Rounding:
|
||||||
|
("3.1", "3.0"),
|
||||||
|
("3.1", "3.2"),
|
||||||
|
("3.1", "4.0"),
|
||||||
|
("8.22e5", "810000.0"),
|
||||||
|
# Only the actual output is rounded up, not the expected output:
|
||||||
|
("3.0", "2.98"),
|
||||||
|
("1e3", "999"),
|
||||||
|
# The current implementation doesn't understand that numbers inside
|
||||||
|
# strings shouldn't be treated as numbers:
|
||||||
|
pytest.param("'3.1416'", "'3.14'", marks=pytest.mark.xfail),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_number_non_matches(self, testdir, expression, output):
|
||||||
|
testdir.maketxtfile(
|
||||||
|
test_doc="""
|
||||||
|
>>> {expression} #doctest: +NUMBER
|
||||||
|
{output}
|
||||||
|
""".format(
|
||||||
|
expression=expression, output=output
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reprec = testdir.inline_run()
|
||||||
|
reprec.assertoutcome(passed=0, failed=1)
|
||||||
|
|
||||||
|
def test_number_and_allow_unicode(self, testdir):
|
||||||
|
testdir.maketxtfile(
|
||||||
|
test_doc="""
|
||||||
|
>>> from collections import namedtuple
|
||||||
|
>>> T = namedtuple('T', 'a b c')
|
||||||
|
>>> T(a=0.2330000001, b=u'str', c=b'bytes') # doctest: +ALLOW_UNICODE, +ALLOW_BYTES, +NUMBER
|
||||||
|
T(a=0.233, b=u'str', c='bytes')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
reprec = testdir.inline_run()
|
||||||
|
reprec.assertoutcome(passed=1)
|
||||||
|
|
||||||
|
|
||||||
class TestDoctestSkips:
|
class TestDoctestSkips:
|
||||||
"""
|
"""
|
||||||
|
|
Loading…
Reference in New Issue