Skip to content

Commit 666acc9

Browse files
authored
doctest: Add +NUMBER option to ignore irrelevant floating-point… (#5576)
doctest: Add +NUMBER option to ignore irrelevant floating-point differences
2 parents 602cd5e + a740ef2 commit 666acc9

File tree

5 files changed

+283
-35
lines changed

5 files changed

+283
-35
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Danielle Jenkins
7171
Dave Hunt
7272
David Díaz-Barquero
7373
David Mohr
74+
David Paul Röthlisberger
7475
David Szotten
7576
David Vierra
7677
Daw-Ran Liou

changelog/5576.feature.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
New `NUMBER <https://docs.pytest.org/en/latest/doctest.html#using-doctest-options>`__
2+
option for doctests to ignore irrelevant differences in floating-point numbers.
3+
Inspired by Sébastien Boisgérault's `numtest <https://github.com/boisgera/numtest>`__
4+
extension for doctest.

doc/en/doctest.rst

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ that will be used for those doctest files using the
103103
Using 'doctest' options
104104
-----------------------
105105

106-
The standard ``doctest`` module provides some `options <https://docs.python.org/3/library/doctest.html#option-flags>`__
106+
Python's standard ``doctest`` module provides some `options <https://docs.python.org/3/library/doctest.html#option-flags>`__
107107
to configure the strictness of doctest tests. In pytest, you can enable those flags using the
108108
configuration file.
109109

@@ -115,23 +115,50 @@ lengthy exception stack traces you can just write:
115115
[pytest]
116116
doctest_optionflags= NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL
117117
118-
pytest also introduces new options to allow doctests to run in Python 2 and
119-
Python 3 unchanged:
118+
Alternatively, options can be enabled by an inline comment in the doc test
119+
itself:
120+
121+
.. code-block:: rst
122+
123+
>>> something_that_raises() # doctest: +IGNORE_EXCEPTION_DETAIL
124+
Traceback (most recent call last):
125+
ValueError: ...
126+
127+
pytest also introduces new options:
120128

121129
* ``ALLOW_UNICODE``: when enabled, the ``u`` prefix is stripped from unicode
122-
strings in expected doctest output.
130+
strings in expected doctest output. This allows doctests to run in Python 2
131+
and Python 3 unchanged.
123132

124-
* ``ALLOW_BYTES``: when enabled, the ``b`` prefix is stripped from byte strings
133+
* ``ALLOW_BYTES``: similarly, the ``b`` prefix is stripped from byte strings
125134
in expected doctest output.
126135

127-
Alternatively, options can be enabled by an inline comment in the doc test
128-
itself:
136+
* ``NUMBER``: when enabled, floating-point numbers only need to match as far as
137+
the precision you have written in the expected doctest output. For example,
138+
the following output would only need to match to 2 decimal places::
129139

130-
.. code-block:: rst
140+
>>> math.pi
141+
3.14
131142

132-
# content of example.rst
133-
>>> get_unicode_greeting() # doctest: +ALLOW_UNICODE
134-
'Hello'
143+
If you wrote ``3.1416`` then the actual output would need to match to 4
144+
decimal places; and so on.
145+
146+
This avoids false positives caused by limited floating-point precision, like
147+
this::
148+
149+
Expected:
150+
0.233
151+
Got:
152+
0.23300000000000001
153+
154+
``NUMBER`` also supports lists of floating-point numbers -- in fact, it
155+
matches floating-point numbers appearing anywhere in the output, even inside
156+
a string! This means that it may not be appropriate to enable globally in
157+
``doctest_optionflags`` in your configuration file.
158+
159+
160+
Continue on failure
161+
-------------------
135162

136163
By default, pytest would report only the first failure for a given doctest. If
137164
you want to continue the test even when you have failures, do:

src/_pytest/doctest.py

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from _pytest.compat import safe_getattr
1414
from _pytest.fixtures import FixtureRequest
1515
from _pytest.outcomes import Skipped
16+
from _pytest.python_api import approx
1617
from _pytest.warning_types import PytestWarning
1718

1819
DOCTEST_REPORT_CHOICE_NONE = "none"
@@ -286,6 +287,7 @@ def _get_flag_lookup():
286287
COMPARISON_FLAGS=doctest.COMPARISON_FLAGS,
287288
ALLOW_UNICODE=_get_allow_unicode_flag(),
288289
ALLOW_BYTES=_get_allow_bytes_flag(),
290+
NUMBER=_get_number_flag(),
289291
)
290292

291293

@@ -453,10 +455,15 @@ def func():
453455

454456
def _get_checker():
455457
"""
456-
Returns a doctest.OutputChecker subclass that takes in account the
457-
ALLOW_UNICODE option to ignore u'' prefixes in strings and ALLOW_BYTES
458-
to strip b'' prefixes.
459-
Useful when the same doctest should run in Python 2 and Python 3.
458+
Returns a doctest.OutputChecker subclass that supports some
459+
additional options:
460+
461+
* ALLOW_UNICODE and ALLOW_BYTES options to ignore u'' and b''
462+
prefixes (respectively) in string literals. Useful when the same
463+
doctest should run in Python 2 and Python 3.
464+
465+
* NUMBER to ignore floating-point differences smaller than the
466+
precision of the literal number in the doctest.
460467
461468
An inner class is used to avoid importing "doctest" at the module
462469
level.
@@ -469,38 +476,89 @@ def _get_checker():
469476

470477
class LiteralsOutputChecker(doctest.OutputChecker):
471478
"""
472-
Copied from doctest_nose_plugin.py from the nltk project:
473-
https://github.com/nltk/nltk
474-
475-
Further extended to also support byte literals.
479+
Based on doctest_nose_plugin.py from the nltk project
480+
(https://github.com/nltk/nltk) and on the "numtest" doctest extension
481+
by Sebastien Boisgerault (https://github.com/boisgera/numtest).
476482
"""
477483

478484
_unicode_literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE)
479485
_bytes_literal_re = re.compile(r"(\W|^)[bB]([rR]?[\'\"])", re.UNICODE)
486+
_number_re = re.compile(
487+
r"""
488+
(?P<number>
489+
(?P<mantissa>
490+
(?P<integer1> [+-]?\d*)\.(?P<fraction>\d+)
491+
|
492+
(?P<integer2> [+-]?\d+)\.
493+
)
494+
(?:
495+
[Ee]
496+
(?P<exponent1> [+-]?\d+)
497+
)?
498+
|
499+
(?P<integer3> [+-]?\d+)
500+
(?:
501+
[Ee]
502+
(?P<exponent2> [+-]?\d+)
503+
)
504+
)
505+
""",
506+
re.VERBOSE,
507+
)
480508

481509
def check_output(self, want, got, optionflags):
482-
res = doctest.OutputChecker.check_output(self, want, got, optionflags)
483-
if res:
510+
if doctest.OutputChecker.check_output(self, want, got, optionflags):
484511
return True
485512

486513
allow_unicode = optionflags & _get_allow_unicode_flag()
487514
allow_bytes = optionflags & _get_allow_bytes_flag()
488-
if not allow_unicode and not allow_bytes:
489-
return False
515+
allow_number = optionflags & _get_number_flag()
490516

491-
else: # pragma: no cover
492-
493-
def remove_prefixes(regex, txt):
494-
return re.sub(regex, r"\1\2", txt)
517+
if not allow_unicode and not allow_bytes and not allow_number:
518+
return False
495519

496-
if allow_unicode:
497-
want = remove_prefixes(self._unicode_literal_re, want)
498-
got = remove_prefixes(self._unicode_literal_re, got)
499-
if allow_bytes:
500-
want = remove_prefixes(self._bytes_literal_re, want)
501-
got = remove_prefixes(self._bytes_literal_re, got)
502-
res = doctest.OutputChecker.check_output(self, want, got, optionflags)
503-
return res
520+
def remove_prefixes(regex, txt):
521+
return re.sub(regex, r"\1\2", txt)
522+
523+
if allow_unicode:
524+
want = remove_prefixes(self._unicode_literal_re, want)
525+
got = remove_prefixes(self._unicode_literal_re, got)
526+
527+
if allow_bytes:
528+
want = remove_prefixes(self._bytes_literal_re, want)
529+
got = remove_prefixes(self._bytes_literal_re, got)
530+
531+
if allow_number:
532+
got = self._remove_unwanted_precision(want, got)
533+
534+
return doctest.OutputChecker.check_output(self, want, got, optionflags)
535+
536+
def _remove_unwanted_precision(self, want, got):
537+
wants = list(self._number_re.finditer(want))
538+
gots = list(self._number_re.finditer(got))
539+
if len(wants) != len(gots):
540+
return got
541+
offset = 0
542+
for w, g in zip(wants, gots):
543+
fraction = w.group("fraction")
544+
exponent = w.group("exponent1")
545+
if exponent is None:
546+
exponent = w.group("exponent2")
547+
if fraction is None:
548+
precision = 0
549+
else:
550+
precision = len(fraction)
551+
if exponent is not None:
552+
precision -= int(exponent)
553+
if float(w.group()) == approx(float(g.group()), abs=10 ** -precision):
554+
# They're close enough. Replace the text we actually
555+
# got with the text we want, so that it will match when we
556+
# check the string literally.
557+
got = (
558+
got[: g.start() + offset] + w.group() + got[g.end() + offset :]
559+
)
560+
offset += w.end() - w.start() - (g.end() - g.start())
561+
return got
504562

505563
_get_checker.LiteralsOutputChecker = LiteralsOutputChecker
506564
return _get_checker.LiteralsOutputChecker()
@@ -524,6 +582,15 @@ def _get_allow_bytes_flag():
524582
return doctest.register_optionflag("ALLOW_BYTES")
525583

526584

585+
def _get_number_flag():
586+
"""
587+
Registers and returns the NUMBER flag.
588+
"""
589+
import doctest
590+
591+
return doctest.register_optionflag("NUMBER")
592+
593+
527594
def _get_report_choice(key):
528595
"""
529596
This function returns the actual `doctest` module flag value, we want to do it as late as possible to avoid

0 commit comments

Comments
 (0)