Skip to content

Commit 9958d74

Browse files
committed
Implement specified timeout for slow doctests
1 parent 7d83063 commit 9958d74

File tree

3 files changed

+66
-13
lines changed

3 files changed

+66
-13
lines changed

src/sage/doctest/forker.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,52 @@ def getvalue(self):
517517
TestResults = namedtuple('TestResults', 'failed attempted')
518518

519519

520+
def _parse_example_timeout(source: str, default_timeout: float) -> float:
521+
"""
522+
Parse the timeout value from a doctest example's source.
523+
524+
INPUT:
525+
526+
- ``source`` -- the source code of a ``doctest.Example``
527+
- ``default_timeout`` -- the default timeout value to use
528+
529+
OUTPUT:
530+
531+
- a float, the timeout value to use for the example
532+
533+
TESTS::
534+
535+
sage: from sage.doctest.forker import _parse_example_timeout
536+
sage: _parse_example_timeout("sleep(10) # long time (10s)", 5.0r)
537+
10.0
538+
sage: _parse_example_timeout("sleep(10) # long time", 5.0r)
539+
5.0
540+
sage: _parse_example_timeout("sleep(10) # long time (1a2s)", 5.0r)
541+
Traceback (most recent call last):
542+
...
543+
ValueError: malformed optional tag '# long time (1a2s)', should be <number>s
544+
sage: _parse_example_timeout("sleep(10) # long time (:issue:`12345`)", 5.0r)
545+
5.0
546+
"""
547+
# TODO this double-parsing is inefficient, should make :meth:`SageDocTestParser.parse`
548+
# return subclass of doctest.Example that already include the timeout value
549+
from sage.doctest.parsing import parse_optional_tags
550+
value = parse_optional_tags(source).get("long time", None)
551+
if value is None:
552+
# either has the "long time" tag without any value in parentheses,
553+
# or tag not present
554+
return default_timeout
555+
assert isinstance(value, str)
556+
match = re.fullmatch(r'(\S*\d)\s*s', value.strip())
557+
if match:
558+
try:
559+
return float(match[1])
560+
except ValueError:
561+
raise ValueError(f"malformed optional tag '# long time ({value})', should be <number>s")
562+
else:
563+
return default_timeout
564+
565+
520566
class SageDocTestRunner(doctest.DocTestRunner):
521567
def __init__(self, *args, **kwds):
522568
"""
@@ -582,6 +628,8 @@ def _run(self, test, compileflags, out):
582628
Since it needs to be able to read stdout, it should be called
583629
while spoofing using :class:`SageSpoofInOut`.
584630
631+
INPUT: see :meth:`run`.
632+
585633
EXAMPLES::
586634
587635
sage: from sage.doctest.parsing import SageOutputChecker
@@ -627,6 +675,7 @@ def _run(self, test, compileflags, out):
627675
check = self._checker.check_output
628676

629677
# Process each example.
678+
example: doctest.Example
630679
for examplenum, example in enumerate(test.examples):
631680
if failures:
632681
# If exitfirst is set, abort immediately after a
@@ -816,8 +865,10 @@ def compiler(example):
816865
if example.warnings:
817866
for warning in example.warnings:
818867
out(self._failure_header(test, example, f'Warning: {warning}'))
868+
819869
if outcome is SUCCESS:
820-
if self.options.warn_long > 0 and example.cputime + check_timer.cputime > self.options.warn_long:
870+
if self.options.warn_long > 0 and example.cputime + check_timer.cputime > _parse_example_timeout(
871+
example.source, self.options.warn_long):
821872
self.report_overtime(out, test, example, got,
822873
check_timer=check_timer)
823874
elif example.warnings:

src/sage/doctest/parsing.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -829,7 +829,7 @@ def __ne__(self, other):
829829
"""
830830
return not (self == other)
831831

832-
def parse(self, string, *args):
832+
def parse(self, string, *args) -> list[doctest.Example | str]:
833833
r"""
834834
A Sage specialization of :class:`doctest.DocTestParser`.
835835
@@ -1015,8 +1015,8 @@ def parse(self, string, *args):
10151015
string = find_python_continuation.sub(r"\1" + ellipsis_tag + r"\2", string)
10161016
string = find_sage_prompt.sub(r"\1>>> sage: ", string)
10171017
string = find_sage_continuation.sub(r"\1...", string)
1018-
res = doctest.DocTestParser.parse(self, string, *args)
1019-
filtered = []
1018+
res: list[doctest.Example | str] = doctest.DocTestParser.parse(self, string, *args)
1019+
filtered: list[doctest.Example | str] = []
10201020
persistent_optional_tags = self.file_optional_tags
10211021
persistent_optional_tag_setter = None
10221022
persistent_optional_tag_setter_index = None

src/sage/doctest/sources.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def __ne__(self, other):
193193
"""
194194
return not (self == other)
195195

196-
def _process_doc(self, doctests, doc, namespace, start):
196+
def _process_doc(self, doctests: list[doctest.DocTest], doc, namespace, start):
197197
"""
198198
Appends doctests defined in ``doc`` to the list ``doctests``.
199199
@@ -266,7 +266,7 @@ def file_optional_tags(self):
266266
"""
267267
return set()
268268

269-
def _create_doctests(self, namespace, tab_okay=None):
269+
def _create_doctests(self, namespace, tab_okay=None) -> tuple[list[doctest.DocTest], dict]:
270270
"""
271271
Create a list of doctests defined in this source.
272272
@@ -314,7 +314,7 @@ def _create_doctests(self, namespace, tab_okay=None):
314314
probed_tags=self.options.probe,
315315
file_optional_tags=self.file_optional_tags)
316316
self.linking = False
317-
doctests = []
317+
doctests: list[doctest.DocTest] = []
318318
in_docstring = False
319319
unparsed_doc = False
320320
doc = []
@@ -480,7 +480,7 @@ def __iter__(self):
480480
for lineno, line in enumerate(self.source.split('\n')):
481481
yield lineno + self.lineno_shift, line + '\n'
482482

483-
def create_doctests(self, namespace):
483+
def create_doctests(self, namespace) -> tuple[list[doctest.DocTest], dict]:
484484
r"""
485485
Create doctests from this string.
486486
@@ -492,8 +492,8 @@ def create_doctests(self, namespace):
492492
493493
- ``doctests`` -- list of doctests defined by this string
494494
495-
- ``tab_locations`` -- either ``False`` or a list of linenumbers
496-
on which tabs appear
495+
- ``extras`` -- dictionary with ``extras['tab']`` either
496+
``False`` or a list of linenumbers on which tabs appear
497497
498498
EXAMPLES::
499499
@@ -503,10 +503,12 @@ def create_doctests(self, namespace):
503503
sage: s = "'''\n sage: 2 + 2\n 4\n'''"
504504
sage: PythonStringSource = dynamic_class('PythonStringSource',(StringDocTestSource, PythonSource))
505505
sage: PSS = PythonStringSource('<runtime>', s, DocTestDefaults(), 'runtime')
506-
sage: dt, tabs = PSS.create_doctests({})
506+
sage: dt, extras = PSS.create_doctests({})
507507
sage: for t in dt:
508508
....: print("{} {}".format(t.name, t.examples[0].sage_source))
509509
<runtime> 2 + 2
510+
sage: extras
511+
{...'tab': []...}
510512
"""
511513
return self._create_doctests(namespace)
512514

@@ -736,7 +738,7 @@ def file_optional_tags(self):
736738
from .parsing import parse_file_optional_tags
737739
return parse_file_optional_tags(self)
738740

739-
def create_doctests(self, namespace):
741+
def create_doctests(self, namespace) -> tuple[list[doctest.DocTest], dict]:
740742
r"""
741743
Return a list of doctests for this file.
742744
@@ -910,7 +912,7 @@ class SourceLanguage:
910912
911913
Currently supported languages include Python, ReST and LaTeX.
912914
"""
913-
def parse_docstring(self, docstring, namespace, start):
915+
def parse_docstring(self, docstring, namespace, start) -> list[doctest.DocTest]:
914916
"""
915917
Return a list of doctest defined in this docstring.
916918

0 commit comments

Comments
 (0)