From 420823070b9e2a898a07201466c82d763d94b752 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 12 Aug 2015 21:41:31 -0300 Subject: [PATCH 1/3] Add ALLOW_UNICODE doctest option When enabled, the ``u`` prefix is stripped from unicode strings in expected doctest output. This allows doctests which use unicode to run in Python 2 and 3 unchanged. Fix #710 --- _pytest/doctest.py | 84 +++++++++++++++++++++++++++++++++++++---- testing/test_doctest.py | 44 +++++++++++++++++++++ 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/_pytest/doctest.py b/_pytest/doctest.py index c6c60b9c2..ba5c082ec 100644 --- a/_pytest/doctest.py +++ b/_pytest/doctest.py @@ -63,7 +63,7 @@ class DoctestItem(pytest.Item): lineno = test.lineno + example.lineno + 1 message = excinfo.type.__name__ reprlocation = ReprFileLocation(filename, lineno, message) - checker = doctest.OutputChecker() + checker = _get_unicode_checker() REPORT_UDIFF = doctest.REPORT_UDIFF filelines = py.path.local(filename).readlines(cr=0) lines = [] @@ -100,7 +100,8 @@ def _get_flag_lookup(): NORMALIZE_WHITESPACE=doctest.NORMALIZE_WHITESPACE, ELLIPSIS=doctest.ELLIPSIS, IGNORE_EXCEPTION_DETAIL=doctest.IGNORE_EXCEPTION_DETAIL, - COMPARISON_FLAGS=doctest.COMPARISON_FLAGS) + COMPARISON_FLAGS=doctest.COMPARISON_FLAGS, + ALLOW_UNICODE=_get_allow_unicode_flag()) def get_optionflags(parent): optionflags_str = parent.config.getini("doctest_optionflags") @@ -110,15 +111,30 @@ def get_optionflags(parent): flag_acc |= flag_lookup_table[flag] return flag_acc + class DoctestTextfile(DoctestItem, pytest.File): + def runtest(self): import doctest fixture_request = _setup_fixtures(self) - failed, tot = doctest.testfile( - str(self.fspath), module_relative=False, - optionflags=get_optionflags(self), - extraglobs=dict(getfixture=fixture_request.getfuncargvalue), - raise_on_error=True, verbose=0) + + # inspired by doctest.testfile; ideally we would use it directly, + # but it doesn't support passing a custom checker + text = self.fspath.read() + filename = str(self.fspath) + name = self.fspath.basename + globs = dict(getfixture=fixture_request.getfuncargvalue) + if '__name__' not in globs: + globs['__name__'] = '__main__' + + optionflags = get_optionflags(self) + runner = doctest.DebugRunner(verbose=0, optionflags=optionflags, + checker=_get_unicode_checker()) + + parser = doctest.DocTestParser() + test = parser.get_doctest(text, globs, name, filename, 0) + runner.run(test) + class DoctestModule(pytest.File): def collect(self): @@ -139,7 +155,8 @@ class DoctestModule(pytest.File): # uses internal doctest module parsing mechanism finder = doctest.DocTestFinder() optionflags = get_optionflags(self) - runner = doctest.DebugRunner(verbose=0, optionflags=optionflags) + runner = doctest.DebugRunner(verbose=0, optionflags=optionflags, + checker=_get_unicode_checker()) for test in finder.find(module, module.__name__, extraglobs=doctest_globals): if test.examples: # skip empty doctests @@ -160,3 +177,54 @@ def _setup_fixtures(doctest_item): fixture_request = FixtureRequest(doctest_item) fixture_request._fillfixtures() return fixture_request + + +def _get_unicode_checker(): + """ + Returns a doctest.OutputChecker subclass that takes in account the + ALLOW_UNICODE option to ignore u'' prefixes in strings. Useful + when the same doctest should run in Python 2 and Python 3. + + An inner class is used to avoid importing "doctest" at the module + level. + """ + if hasattr(_get_unicode_checker, 'UnicodeOutputChecker'): + return _get_unicode_checker.UnicodeOutputChecker() + + import doctest + import re + + class UnicodeOutputChecker(doctest.OutputChecker): + """ + Copied from doctest_nose_plugin.py from the nltk project: + https://github.com/nltk/nltk + """ + + _literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE) + + def _remove_u_prefixes(self, txt): + return re.sub(self._literal_re, r'\1\2', txt) + + def check_output(self, want, got, optionflags): + res = doctest.OutputChecker.check_output(self, want, got, optionflags) + if res: + return True + + if not (optionflags & _get_allow_unicode_flag()): + return False + + cleaned_want = self._remove_u_prefixes(want) + cleaned_got = self._remove_u_prefixes(got) + res = doctest.OutputChecker.check_output(self, cleaned_want, cleaned_got, optionflags) + return res + + _get_unicode_checker.UnicodeOutputChecker = UnicodeOutputChecker + return _get_unicode_checker.UnicodeOutputChecker() + + +def _get_allow_unicode_flag(): + """ + Registers and returns the ALLOW_UNICODE flag. + """ + import doctest + return doctest.register_optionflag('ALLOW_UNICODE') diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 882747b9e..a5650afcf 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1,5 +1,7 @@ +import sys from _pytest.doctest import DoctestItem, DoctestModule, DoctestTextfile import py +import pytest class TestDoctests: @@ -401,3 +403,45 @@ class TestDoctests: result = testdir.runpytest("--doctest-modules") result.stdout.fnmatch_lines('*2 passed*') + @pytest.mark.parametrize('config_mode', ['ini', 'comment']) + def test_allow_unicode(self, testdir, config_mode): + """Test that doctests which output unicode work in all python versions + tested by pytest when the ALLOW_UNICODE option is used (either in + the ini file or by an inline comment). + """ + if config_mode == 'ini': + testdir.makeini(''' + [pytest] + doctest_optionflags = ALLOW_UNICODE + ''') + comment = '' + else: + comment = '#doctest: +ALLOW_UNICODE' + + testdir.maketxtfile(test_doc=""" + >>> b'12'.decode('ascii') {comment} + '12' + """.format(comment=comment)) + testdir.makepyfile(foo=""" + def foo(): + ''' + >>> b'12'.decode('ascii') {comment} + '12' + ''' + """.format(comment=comment)) + reprec = testdir.inline_run("--doctest-modules") + reprec.assertoutcome(passed=2) + + @pytest.mark.skipif(sys.version_info[0] >= 3, reason='Python 2 only') + def test_unicode_string_fails(self, testdir): + """Test that doctests which output unicode fail in Python 2 when + the ALLOW_UNICODE option is not used. + """ + testdir.maketxtfile(test_doc=""" + >>> b'12'.decode('ascii') {comment} + '12' + """) + reprec = testdir.inline_run() + reprec.assertoutcome(failed=1) + + From 93aee0f8146922eed473b9f02997be4404bcb070 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 12 Aug 2015 22:13:42 -0300 Subject: [PATCH 2/3] Add docs and CHANGELOG for ALLOW_UNICODE option --- CHANGELOG | 5 +++++ doc/en/doctest.rst | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 6c9c1adbc..7d96418c8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,11 @@ with parametrization markers. Thanks to Markus Unterwaditzer for the PR. +- fix issue710: introduce ALLOW_UNICODE doctest option: when enabled, the + ``u`` prefix is stripped from unicode strings in expected doctest output. This + allows doctests which use unicode to run in Python 2 and 3 unchanged. + Thanks Jason R. Coombs for the report and Bruno Oliveira for the PR. + - parametrize now also generates meaningful test IDs for enum, regex and class objects (as opposed to class instances). Thanks to Florian Bruhin for the PR. diff --git a/doc/en/doctest.rst b/doc/en/doctest.rst index e33fed676..a456488e3 100644 --- a/doc/en/doctest.rst +++ b/doc/en/doctest.rst @@ -72,3 +72,18 @@ ignore lengthy exception stack traces you can just write:: # content of pytest.ini [pytest] doctest_optionflags= NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL + + +py.test also introduces a new ``ALLOW_UNICODE`` option flag: when enabled, the +``u`` prefix is stripped from unicode strings in expected doctest output. This +allows doctests which use unicode to run in Python 2 and 3 unchanged. + +As with any other option flag, this flag can be enabled in ``pytest.ini`` using +the ``doctest_optionflags`` ini option or by an inline comment in the doc test +itself:: + + # content of example.rst + >>> get_unicode_greeting() # doctest: +ALLOW_UNICODE + 'Hello' + + From d749021a31f76167a2305cb81d21f70613867390 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 12 Aug 2015 22:46:13 -0300 Subject: [PATCH 3/3] Fix coverage Also make sure a test that doesn't set ALLOW_UNICODE fails on Python 2 and passes Python 3. --- _pytest/doctest.py | 21 +++++++++++++-------- testing/test_doctest.py | 11 ++++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/_pytest/doctest.py b/_pytest/doctest.py index ba5c082ec..fe71c8284 100644 --- a/_pytest/doctest.py +++ b/_pytest/doctest.py @@ -202,21 +202,26 @@ def _get_unicode_checker(): _literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE) - def _remove_u_prefixes(self, txt): - return re.sub(self._literal_re, r'\1\2', txt) - def check_output(self, want, got, optionflags): - res = doctest.OutputChecker.check_output(self, want, got, optionflags) + res = doctest.OutputChecker.check_output(self, want, got, + optionflags) if res: return True if not (optionflags & _get_allow_unicode_flag()): return False - cleaned_want = self._remove_u_prefixes(want) - cleaned_got = self._remove_u_prefixes(got) - res = doctest.OutputChecker.check_output(self, cleaned_want, cleaned_got, optionflags) - return res + else: # pragma: no cover + # the code below will end up executed only in Python 2 in + # our tests, and our coverage check runs in Python 3 only + def remove_u_prefixes(txt): + return re.sub(self._literal_re, r'\1\2', txt) + + want = remove_u_prefixes(want) + got = remove_u_prefixes(got) + res = doctest.OutputChecker.check_output(self, want, got, + optionflags) + return res _get_unicode_checker.UnicodeOutputChecker = UnicodeOutputChecker return _get_unicode_checker.UnicodeOutputChecker() diff --git a/testing/test_doctest.py b/testing/test_doctest.py index a5650afcf..6975ecc2c 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -432,16 +432,17 @@ class TestDoctests: reprec = testdir.inline_run("--doctest-modules") reprec.assertoutcome(passed=2) - @pytest.mark.skipif(sys.version_info[0] >= 3, reason='Python 2 only') - def test_unicode_string_fails(self, testdir): + def test_unicode_string(self, testdir): """Test that doctests which output unicode fail in Python 2 when - the ALLOW_UNICODE option is not used. + the ALLOW_UNICODE option is not used. The same test should pass + in Python 3. """ testdir.maketxtfile(test_doc=""" - >>> b'12'.decode('ascii') {comment} + >>> b'12'.decode('ascii') '12' """) reprec = testdir.inline_run() - reprec.assertoutcome(failed=1) + passed = int(sys.version_info[0] >= 3) + reprec.assertoutcome(passed=passed, failed=int(not passed))