Skip to content

Commit 722bdec

Browse files
author
Release Manager
committed
gh-36024: `sage -fixdoctests`: Handle directory names, call `sage -t` only once <!-- ^^^^^ Please provide a concise, informative and self-explanatory title. Don't put issue numbers in there, do this in the PR body below. For example, instead of "Fixes #1234" use "Introduce new method to calculate 1+1" --> <!-- Describe your changes here in detail --> <!-- Why is this change required? What problem does it solve? --> We change the handling of file name arguments in `sage -fixdoctests`: Instead of calling `sage -t` on files one by one, we just pass them to `sage -t` as a whole and then handle its output for all files. This is much faster because of the startup overhead of `sage -t`, and also adds the handling of directory names. <!-- If this PR resolves an open issue, please link to it here. For example "Fixes #12345". --> Cherry-picked from - #35095 Part of - #29705 <!-- If your change requires a documentation PR, please link it appropriately. --> ### 📝 Checklist <!-- Put an `x` in all the boxes that apply. --> <!-- If your change requires a documentation PR, please link it appropriately --> <!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! --> <!-- Feel free to remove irrelevant items. --> - [x] The title is concise, informative, and self-explanatory. - [x] The description explains in detail what this PR is about. - [ ] I have linked a relevant issue or discussion. - [ ] I have created tests covering the changes. - [ ] I have updated the documentation accordingly. ### ⌛ Dependencies <!-- List all open PRs that this PR logically depends on - #12345: short description why this is a dependency - #34567: ... --> <!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! --> URL: #36024 Reported by: Matthias Köppe Reviewer(s): Kwankyu Lee, Matthias Köppe
2 parents 8b23b9d + 2dccf18 commit 722bdec

File tree

2 files changed

+87
-44
lines changed

2 files changed

+87
-44
lines changed

src/bin/sage-fixdoctests

Lines changed: 83 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import sys
4040
from argparse import ArgumentParser, FileType
4141
from pathlib import Path
4242

43-
from sage.doctest.control import skipfile
43+
from sage.doctest.control import DocTestDefaults, DocTestController
4444
from sage.doctest.parsing import parse_file_optional_tags, parse_optional_tags, unparse_optional_tags, update_optional_tags
4545
from sage.env import SAGE_ROOT
4646
from sage.features import PythonModule
@@ -189,8 +189,12 @@ def process_block(block, src_in_lines, file_optional_tags):
189189
return
190190

191191
# Error testing.
192-
if m := re.search(r"ModuleNotFoundError: No module named '([^']*)'", block):
193-
module = m.group(1)
192+
if m := re.search(r"(?:ModuleNotFoundError: No module named|ImportError: cannot import name '([^']*)' from) '([^']*)'", block):
193+
if m.group(1):
194+
# "ImportError: cannot import name 'function_field_polymod' from 'sage.rings.function_field' (unknown location)"
195+
module = m.group(2) + '.' + m.group(1)
196+
else:
197+
module = m.group(2)
194198
asked_why = re.search('#.*(why|explain)', src_in_lines[first_line_num - 1])
195199
optional = module_feature(module)
196200
if optional and optional.name not in file_optional_tags:
@@ -224,6 +228,8 @@ def process_block(block, src_in_lines, file_optional_tags):
224228
# NameError from top level, so keep it brief
225229
if m := re.match("NameError: name '(.*)'", got[index_NameError:]):
226230
name = m.group(1)
231+
if name == 'x': # Don't mark it '# needs sage.symbolic'; that's almost always wrong
232+
return
227233
if feature := name_feature(name):
228234
add_tags = [feature.name]
229235
else:
@@ -305,17 +311,21 @@ def process_block(block, src_in_lines, file_optional_tags):
305311

306312

307313
# set input and output files
308-
if len(args.filename) == 2 and not args.overwrite and not args.no_overwrite:
309-
inputs, outputs = [args.filename[0]], [args.filename[1]]
310-
print("sage-fixdoctests: When passing two filenames, the second one is taken as an output filename; "
311-
"this is deprecated. To pass two input filenames, use the option --overwrite.")
312-
elif args.no_overwrite:
313-
inputs, outputs = args.filename, [input + ".fixed" for input in args.filename]
314-
else:
315-
inputs = outputs = args.filename
314+
def output_filename(filename):
315+
if len(args.filename) == 2 and not args.overwrite and not args.no_overwrite:
316+
if args.filename[0] == filename:
317+
return args.filename[1]
318+
else:
319+
return None
320+
return filename + ".fixed"
321+
if args.no_overwrite:
322+
return filename + ".fixed"
323+
return filename
316324

317325
# Test the doctester, putting the output of the test into sage's temporary directory
318-
if not args.no_test:
326+
if args.no_test:
327+
doc_out = ''
328+
else:
319329
executable = f'{os.path.relpath(args.venv)}/bin/sage' if args.venv else 'sage'
320330
environment_args = f'--environment {args.environment} ' if args.environment != runtest_default_environment else ''
321331
long_args = f'--long ' if args.long else ''
@@ -329,38 +339,61 @@ if not args.no_test:
329339
if status := os.waitstatus_to_exitcode(os.system(f'{cmdline} > {shlex.quote(doc_file)}')):
330340
print(f'Doctester exited with error status {status}')
331341
sys.exit(status)
342+
# Run the doctester, putting the output of the test into sage's temporary directory
343+
if len(args.filename) == 2 and not args.overwrite and not args.no_overwrite:
344+
print("sage-fixdoctests: When passing two filenames, the second one is taken as an output filename; "
345+
"this is deprecated. To pass two input filenames, use the option --overwrite.")
332346

333-
for input, output in zip(inputs, outputs):
334-
if (skipfile_result := skipfile(input, True, log=print)) is True:
335-
continue
336-
337-
if args.no_test:
338-
doc_out = ''
347+
input_filenames = [args.filename[0]]
339348
else:
340-
# Run the doctester, putting the output of the test into sage's temporary directory
341-
cmdline = f'{shlex.quote(executable)} -t {environment_args}{long_args}{probe_args}{lib_args}{shlex.quote(input)}'
342-
print(f'Running "{cmdline}"')
343-
os.system(f'{cmdline} > {shlex.quote(doc_file)}')
349+
input_filenames = args.filename
350+
input_args = " ".join(shlex.quote(f) for f in input_filenames)
351+
cmdline = f'{shlex.quote(executable)} -t -p {environment_args}{long_args}{probe_args}{lib_args}{input_args}'
352+
print(f'Running "{cmdline}"')
353+
os.system(f'{cmdline} > {shlex.quote(doc_file)}')
344354

345-
with open(doc_file, 'r') as doc:
346-
doc_out = doc.read()
355+
with open(doc_file, 'r') as doc:
356+
doc_out = doc.read()
347357

348358
# echo control messages
349359
for m in re.finditer('^Skipping .*', doc_out, re.MULTILINE):
350360
print('sage-runtests: ' + m.group(0))
351-
break
352-
else:
353-
sep = "**********************************************************************\n"
354-
doctests = doc_out.split(sep)
361+
362+
sep = "**********************************************************************\n"
363+
doctests = doc_out.split(sep)
364+
365+
seen = set()
366+
367+
def block_filename(block):
368+
if not (m := re.match('File "([^"]*)", line ([0-9]+), in ', block)):
369+
return None
370+
return m.group(1)
371+
372+
def expanded_filename_args():
373+
DD = DocTestDefaults(optional='all', warn_long=10000)
374+
DC = DocTestController(DD, input_filenames)
375+
DC.add_files()
376+
DC.expand_files_into_sources()
377+
for source in DC.sources:
378+
yield source.path
379+
380+
def process_grouped_blocks(grouped_iterator):
381+
382+
for input, blocks in grouped_iterator:
383+
384+
if not input: # Blocks of noise
385+
continue
386+
if input in seen:
387+
continue
388+
seen.add(input)
355389

356390
with open(input, 'r') as test_file:
357391
src_in = test_file.read()
358392
src_in_lines = src_in.splitlines()
359393
shallow_copy_of_src_in_lines = list(src_in_lines)
360-
361394
file_optional_tags = set(parse_file_optional_tags(enumerate(src_in_lines)))
362395

363-
for block in doctests:
396+
for block in blocks:
364397
process_block(block, src_in_lines, file_optional_tags)
365398

366399
# Now source line numbers do not matter any more, and lines can be real lines again
@@ -392,20 +425,26 @@ for input, output in zip(inputs, outputs):
392425
persistent_optional_tags = {}
393426

394427
if src_in_lines != shallow_copy_of_src_in_lines:
395-
with open(output, 'w') as test_output:
396-
for line in src_in_lines:
397-
if line is None:
398-
continue
399-
test_output.write(line)
400-
test_output.write('\n')
401-
402-
# Show summary of changes
403-
if input != output:
404-
print("The fixed doctests have been saved as '{0}'.".format(output))
428+
if (output := output_filename(input)) is None:
429+
print(f"Not saving modifications made in '{input}'")
405430
else:
406-
relative = os.path.relpath(output, SAGE_ROOT)
407-
print(f"The input file '{output}' has been overwritten.")
408-
if not relative.startswith('..'):
409-
subprocess.call(['git', '--no-pager', 'diff', relative], cwd=SAGE_ROOT)
431+
with open(output, 'w') as test_output:
432+
for line in src_in_lines:
433+
if line is None:
434+
continue
435+
test_output.write(line)
436+
test_output.write('\n')
437+
# Show summary of changes
438+
if input != output :
439+
print("The fixed doctests have been saved as '{0}'.".format(output))
440+
else:
441+
relative = os.path.relpath(output, SAGE_ROOT)
442+
print(f"The input file '{output}' has been overwritten.")
443+
if not relative.startswith('..'):
444+
subprocess.call(['git', '--no-pager', 'diff', relative], cwd=SAGE_ROOT)
410445
else:
411446
print(f"No fixes made in '{input}'")
447+
448+
process_grouped_blocks(
449+
itertools.chain(itertools.groupby(doctests, block_filename),
450+
((filename, []) for filename in expanded_filename_args())))

src/sage/doctest/control.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,10 @@ def skipfile(filename, tested_optional_tags=False, *,
281281
if log:
282282
log(f"Skipping '{filename}' because it does not have one of the recognized file name extensions")
283283
return True
284+
if if_installed and ext not in ('.py', '.pyx'):
285+
if log:
286+
log(f"Skipping '{filename}' because it is not the source file of a Python module")
287+
return True
284288
if "jupyter_execute" in filename:
285289
if log:
286290
log(f"Skipping '{filename}' because it is created by the jupyter-sphinx extension for internal use and should not be tested")

0 commit comments

Comments
 (0)