bpo-1812: Fix newline conversion when doctest.testfile loads from a package whose loader has a get_data method (GH-17385)

This pull request fixes the newline conversion bug originally reported in bpo-1812. When that issue was originally submitted, the open builtin did not default to universal newline mode; now it does, which makes the issue fix simpler, since the only code path that needs to be changed is the one in doctest._load_testfile where the file is loaded from a package whose loader has a get_data method.
This commit is contained in:
Peter Donis 2020-03-26 11:53:16 -04:00 committed by GitHub
parent 59c644eaa7
commit e0b8101492
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 100 additions and 5 deletions

View File

@ -211,6 +211,13 @@ def _normalize_module(module, depth=2):
else: else:
raise TypeError("Expected a module, string, or None") raise TypeError("Expected a module, string, or None")
def _newline_convert(data):
# We have two cases to cover and we need to make sure we do
# them in the right order
for newline in ('\r\n', '\r'):
data = data.replace(newline, '\n')
return data
def _load_testfile(filename, package, module_relative, encoding): def _load_testfile(filename, package, module_relative, encoding):
if module_relative: if module_relative:
package = _normalize_module(package, 3) package = _normalize_module(package, 3)
@ -221,7 +228,7 @@ def _load_testfile(filename, package, module_relative, encoding):
file_contents = file_contents.decode(encoding) file_contents = file_contents.decode(encoding)
# get_data() opens files as 'rb', so one must do the equivalent # get_data() opens files as 'rb', so one must do the equivalent
# conversion as universal newlines would do. # conversion as universal newlines would do.
return file_contents.replace(os.linesep, '\n'), filename return _newline_convert(file_contents), filename
with open(filename, encoding=encoding) as f: with open(filename, encoding=encoding) as f:
return f.read(), filename return f.read(), filename

View File

@ -8,8 +8,12 @@ import functools
import os import os
import sys import sys
import importlib import importlib
import importlib.abc
import importlib.util
import unittest import unittest
import tempfile import tempfile
import shutil
import contextlib
# NOTE: There are some additional tests relating to interaction with # NOTE: There are some additional tests relating to interaction with
# zipimport in the test_zipimport_support test module. # zipimport in the test_zipimport_support test module.
@ -437,7 +441,7 @@ We'll simulate a __file__ attr that ends in pyc:
>>> tests = finder.find(sample_func) >>> tests = finder.find(sample_func)
>>> print(tests) # doctest: +ELLIPSIS >>> print(tests) # doctest: +ELLIPSIS
[<DocTest sample_func from ...:21 (1 example)>] [<DocTest sample_func from ...:25 (1 example)>]
The exact name depends on how test_doctest was invoked, so allow for The exact name depends on how test_doctest was invoked, so allow for
leading path components. leading path components.
@ -2663,12 +2667,52 @@ Test the verbose output:
>>> sys.argv = save_argv >>> sys.argv = save_argv
""" """
class TestImporter(importlib.abc.MetaPathFinder, importlib.abc.ResourceLoader):
def find_spec(self, fullname, path, target=None):
return importlib.util.spec_from_file_location(fullname, path, loader=self)
def get_data(self, path):
with open(path, mode='rb') as f:
return f.read()
class TestHook:
def __init__(self, pathdir):
self.sys_path = sys.path[:]
self.meta_path = sys.meta_path[:]
self.path_hooks = sys.path_hooks[:]
sys.path.append(pathdir)
sys.path_importer_cache.clear()
self.modules_before = sys.modules.copy()
self.importer = TestImporter()
sys.meta_path.append(self.importer)
def remove(self):
sys.path[:] = self.sys_path
sys.meta_path[:] = self.meta_path
sys.path_hooks[:] = self.path_hooks
sys.path_importer_cache.clear()
sys.modules.clear()
sys.modules.update(self.modules_before)
@contextlib.contextmanager
def test_hook(pathdir):
hook = TestHook(pathdir)
try:
yield hook
finally:
hook.remove()
def test_lineendings(): r""" def test_lineendings(): r"""
*nix systems use \n line endings, while Windows systems use \r\n. Python *nix systems use \n line endings, while Windows systems use \r\n, and
old Mac systems used \r, which Python still recognizes as a line ending. Python
handles this using universal newline mode for reading files. Let's make handles this using universal newline mode for reading files. Let's make
sure doctest does so (issue 8473) by creating temporary test files using each sure doctest does so (issue 8473) by creating temporary test files using each
of the two line disciplines. One of the two will be the "wrong" one for the of the three line disciplines. At least one will not match either the universal
platform the test is run on. newline \n or os.linesep for the platform the test is run on.
Windows line endings first: Windows line endings first:
@ -2691,6 +2735,47 @@ And now *nix line endings:
TestResults(failed=0, attempted=1) TestResults(failed=0, attempted=1)
>>> os.remove(fn) >>> os.remove(fn)
And finally old Mac line endings:
>>> fn = tempfile.mktemp()
>>> with open(fn, 'wb') as f:
... f.write(b'Test:\r\r >>> x = 1 + 1\r\rDone.\r')
30
>>> doctest.testfile(fn, module_relative=False, verbose=False)
TestResults(failed=0, attempted=1)
>>> os.remove(fn)
Now we test with a package loader that has a get_data method, since that
bypasses the standard universal newline handling so doctest has to do the
newline conversion itself; let's make sure it does so correctly (issue 1812).
We'll write a file inside the package that has all three kinds of line endings
in it, and use a package hook to install a custom loader; on any platform,
at least one of the line endings will raise a ValueError for inconsistent
whitespace if doctest does not correctly do the newline conversion.
>>> dn = tempfile.mkdtemp()
>>> pkg = os.path.join(dn, "doctest_testpkg")
>>> os.mkdir(pkg)
>>> support.create_empty_file(os.path.join(pkg, "__init__.py"))
>>> fn = os.path.join(pkg, "doctest_testfile.txt")
>>> with open(fn, 'wb') as f:
... f.write(
... b'Test:\r\n\r\n'
... b' >>> x = 1 + 1\r\n\r\n'
... b'Done.\r\n'
... b'Test:\n\n'
... b' >>> x = 1 + 1\n\n'
... b'Done.\n'
... b'Test:\r\r'
... b' >>> x = 1 + 1\r\r'
... b'Done.\r'
... )
95
>>> with test_hook(dn):
... doctest.testfile("doctest_testfile.txt", package="doctest_testpkg", verbose=False)
TestResults(failed=0, attempted=3)
>>> shutil.rmtree(dn)
""" """
def test_testmod(): r""" def test_testmod(): r"""

View File

@ -421,6 +421,7 @@ Walter Dörwald
Jaromir Dolecek Jaromir Dolecek
Zsolt Dollenstein Zsolt Dollenstein
Brendan Donegan Brendan Donegan
Peter Donis
Ismail Donmez Ismail Donmez
Ray Donnelly Ray Donnelly
Robert Donohue Robert Donohue

View File

@ -0,0 +1,2 @@
Fix newline handling in doctest.testfile when loading from a package whose
loader has a get_data method. Patch by Peter Donis.