diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d5cbf1..36698ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ We are currently working on porting this changelog to the specifications in This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Version 0.14.0] - Uneleased + +### Added +* The REQUIRES directive can now inspect existence or values of environment variables. +* Added top-level `doctest_callable` function, which executes the doctests of a + function or class. +* Support for `NO_COLOR` environment variable. + +### Fixed +* `IPython.embed` and `ipdb.launch_ipdb_on_exception` now correctly work from + inside doctests. +* Small incompatibility with `pytest-matrix`. See #82 + ## [Version 0.13.0] - Released 2020-07-10 ### Changed diff --git a/dev/interactive_embed_tests.py b/dev/interactive_embed_tests.py new file mode 100644 index 00000000..6b843d4d --- /dev/null +++ b/dev/interactive_embed_tests.py @@ -0,0 +1,26 @@ + + +def interative_test_xdev_embed(): + """ + CommandLine: + xdoctest -m dev/interactive_embed_tests.py interative_test_xdev_embed + + Example: + >>> interative_test_xdev_embed() + """ + import xdev + with xdev.embed_on_exception_context: + raise Exception + + +def interative_test_ipdb_embed(): + """ + CommandLine: + xdoctest -m dev/interactive_embed_tests.py interative_test_ipdb_embed + + Example: + >>> interative_test_ipdb_embed() + """ + import ipdb + with ipdb.launch_ipdb_on_exception(): + raise Exception diff --git a/testing/test_binary_ext.py b/testing/test_binary_ext.py index 7752f02f..2ce0bad5 100644 --- a/testing/test_binary_ext.py +++ b/testing/test_binary_ext.py @@ -171,6 +171,10 @@ def test_run_binary_doctests(): CommandLine: python ~/code/xdoctest/testing/test_binary_ext.py test_run_binary_doctests + + Notes: + xdoctest -m $HOME/code/xdoctest/testing/pybind11_test/install/my_ext.cpython-38-x86_64-linux-gnu.so list --analysis=dynamic + """ extmod_fpath = build_demo_extmod() print('extmod_fpath = {!r}'.format(extmod_fpath)) diff --git a/testing/test_runner.py b/testing/test_runner.py index 457295af..89e43f32 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -332,6 +332,84 @@ def foo(): assert 'SKIPPED' in cap.text +def test_global_exec(): + """ + pytest testing/test_runner.py::test_global_exec -s + """ + from xdoctest import runner + + source = utils.codeblock( + ''' + def foo(): + """ + Example: + >>> print(a) + """ + ''') + + config = { + 'global_exec': 'a=1', + } + + with utils.TempDir() as temp: + dpath = temp.dpath + modpath = join(dpath, 'test_example_run.py') + + with open(modpath, 'w') as file: + file.write(source) + + with utils.CaptureStdout() as cap: + runner.doctest_module(modpath, 'foo', argv=[''], config=config) + + assert '1 passed' in cap.text + + +def test_hack_the_sys_argv(): + """ + Tests hacky solution to issue #76 + + pytest testing/test_runner.py::test_global_exec -s + + References: + https://github.com/Erotemic/xdoctest/issues/76 + """ + from xdoctest import runner + + source = utils.codeblock( + ''' + def foo(): + """ + Example: + >>> # xdoctest: +REQUIRES(--hackedflag) + >>> print('This will run if global_exec specified') + """ + ''') + + import sys + NEEDS_FIX = '--hackedflag' not in sys.argv + + config = { + 'global_exec': 'import sys; sys.argv.append("--hackedflag")' + } + + with utils.TempDir() as temp: + dpath = temp.dpath + modpath = join(dpath, 'test_example_run.py') + + with open(modpath, 'w') as file: + file.write(source) + + with utils.CaptureStdout() as cap: + runner.doctest_module(modpath, 'foo', argv=[''], config=config) + + if NEEDS_FIX: + # Fix the global state + sys.argv.remove('--hackedflag') + + # print(cap.text) + assert '1 passed' in cap.text + + if __name__ == '__main__': """ CommandLine: diff --git a/xdoctest/__init__.py b/xdoctest/__init__.py index 4ecc2099..4affdc4b 100644 --- a/xdoctest/__init__.py +++ b/xdoctest/__init__.py @@ -162,43 +162,39 @@ def fib(n): .. code:: text usage: xdoctest [-h] [--version] [-m MODNAME] [-c COMMAND] - [--style {auto,google,freeform}] [--durations DURATIONS] - [--time] [--nocolor] [--offset] + [--style {auto,google,freeform}] + [--analysis {auto,static,dynamic}] + [--durations DURATIONS] [--time] [--nocolor] [--offset] [--report {none,cdiff,ndiff,udiff,only_first_failure}] [--options OPTIONS] [--global-exec GLOBAL_EXEC] [--verbose VERBOSE] [--quiet] [--silent] [arg [arg ...]] - Xdoctest 0.11.0 - on Python - 3.7.3 (default, Mar 27 2019, 22:11:17) [GCC - 7.3.0] - discover and run doctests within a python package + Xdoctest 0.14.0 - on Python - 3.8.3 (default, Jul 2 2020, 16:21:59) [GCC 7.3.0] - discover and run doctests within a python package positional arguments: - arg Ignored if optional arguments are specified, - otherwise: Defaults --modname to arg.pop(0). Defaults - --command to arg.pop(0). + arg Ignored if optional arguments are specified, otherwise: Defaults --modname to arg.pop(0). Defaults --command + to arg.pop(0). optional arguments: -h, --help show this help message and exit --version display version info and quit -m MODNAME, --modname MODNAME - module name or path. If specified positional modules - are ignored + module name or path. If specified positional modules are ignored -c COMMAND, --command COMMAND - a doctest name or a command (list|all|). - Defaults to all + a doctest name or a command (list|all|). Defaults to all --style {auto,google,freeform} - choose your style + choose the style of doctests that will be parsed + --analysis {auto,static,dynamic} + How doctests are collected --durations DURATIONS - specify execution times for slowest N tests.N=0 will - show times for all tests + specify execution times for slowest N tests.N=0 will show times for all tests --time Same as if durations=0 --nocolor Disable ANSI coloration in stdout - --offset if True formated source linenumbers will agree with - their location in the source file. Otherwise they will + --offset if True formatted source linenumbers will agree with their location in the source file. Otherwise they will be relative to the doctest itself. --report {none,cdiff,ndiff,udiff,only_first_failure} - choose another output format for diffs on xdoctest - failure + choose another output format for diffs on xdoctest failure --options OPTIONS default directive flags for doctests --global-exec GLOBAL_EXEC exec these lines before every test @@ -207,6 +203,7 @@ def fib(n): --silent sets verbosity to 0 + The xdoctest interface can be run programmatically using ``xdoctest.doctest_module(path)``, which can be placed in the ``__main__`` section of any module as such: @@ -239,7 +236,7 @@ def fib(n): ''' # mkinit xdoctest --nomods -__version__ = '0.13.0' +__version__ = '0.14.0' # TODO: @@ -259,10 +256,11 @@ def fib(n): from xdoctest import utils from xdoctest import docstr -from xdoctest.runner import (doctest_module,) +from xdoctest.runner import (doctest_module, doctest_callable,) from xdoctest.exceptions import (DoctestParseError, ExitTestException, MalformedDocstr,) __all__ = ['DoctestParseError', 'ExitTestException', 'MalformedDocstr', - 'doctest_module', 'utils', 'docstr', '__version__'] + 'doctest_module', 'doctest_callable', 'utils', 'docstr', + '__version__'] diff --git a/xdoctest/__main__.py b/xdoctest/__main__.py index 7c2d859e..d413e459 100644 --- a/xdoctest/__main__.py +++ b/xdoctest/__main__.py @@ -45,12 +45,19 @@ def main(argv=None): print('{} = {}'.format(key, value)) return 0 + # FIXME: default values are reporting incorrectly or are missformated + class RawDescriptionDefaultsHelpFormatter( + argparse.RawDescriptionHelpFormatter, + argparse.ArgumentDefaultsHelpFormatter): + pass + parser = argparse.ArgumentParser( prog='xdoctest', description=( 'Xdoctest {xdoc_version} - on Python - {sys_version} - ' 'discover and run doctests within a python package' - ).format(**version_info) + ).format(**version_info), + formatter_class=RawDescriptionDefaultsHelpFormatter, ) parser.add_argument( 'arg', nargs='*', help=utils.codeblock( diff --git a/xdoctest/directive.py b/xdoctest/directive.py index 6230ab55..479c8bcd 100644 --- a/xdoctest/directive.py +++ b/xdoctest/directive.py @@ -88,6 +88,7 @@ import re import copy import warnings +import operator from xdoctest import static_analysis as static from xdoctest import utils from collections import OrderedDict @@ -364,12 +365,18 @@ def _unpack_args(self, num): 'got {}'.format(self.name, num, nargs)) return self.args - def effect(self, argv=None): + def effect(self, argv=None, environ=None): """ Returns how this directive modifies a RuntimeState object This is used by a RuntimeState object to update itself + Args: + argv (List[str], default=None): + if specified, overwrite sys.argv + environ (Dict[str, str], default=None): + if specified, overwrite os.environ + Returns: Effect: named tuple containing: action (str): code indicating how to update @@ -404,16 +411,26 @@ def effect(self, argv=None): >>> print('directive.effect() = {}'.format(directive.effect())) directive = directive.effect() = Effect(action='set.add', key='REQUIRES', value='module:notamodule') + + >>> directive = list(Directive.extract('# xdoctest: requires(env:FOO==1)'))[0] + >>> print('directive = {}'.format(directive)) + >>> print('directive.effect() = {}'.format(directive.effect(environ={}))) + directive = + directive.effect() = Effect(action='set.add', key='REQUIRES', value='env:FOO==1') + + >>> directive = list(Directive.extract('# xdoctest: requires(env:FOO==1)'))[0] + >>> print('directive = {}'.format(directive)) + >>> print('directive.effect() = {}'.format(directive.effect(environ={'FOO': '1'}))) + directive = + directive.effect() = Effect(action='noop', key='REQUIRES', value=None) """ key = self.name value = None if self.name == 'REQUIRES': # Special handling of REQUIRES - if argv is None: - argv = sys.argv arg, = self._unpack_args(1) - if _is_requires_satisfied(arg, argv): + if _is_requires_satisfied(arg, argv=argv, environ=environ): # If the requirement is met, then do nothing, action = 'noop' else: @@ -437,13 +454,14 @@ def effect(self, argv=None): return Effect(action, key, value) -def _is_requires_satisfied(arg, argv): +def _is_requires_satisfied(arg, argv=None, environ=None): """ Determines if the argument to a REQUIRES directive is satisfied Args: arg (str): condition code - argv (List[str]): cmdline if arg is cmd code + argv (List[str]): cmdline if arg is cmd code usually `sys.argv` + environ (Dict[str, str]): environment variables usually `os.environ` Returns: bool: flag - True if the requirement is met @@ -455,6 +473,32 @@ def _is_requires_satisfied(arg, argv): >>> _is_requires_satisfied('pypy', argv=[]) >>> _is_requires_satisfied('nt', argv=[]) >>> _is_requires_satisfied('linux', argv=[]) + + >>> _is_requires_satisfied('env:FOO', argv=[], environ={'FOO': '1'}) + True + >>> _is_requires_satisfied('env:FOO==1', argv=[], environ={'FOO': '1'}) + True + >>> _is_requires_satisfied('env:FOO==T', argv=[], environ={'FOO': '1'}) + False + >>> _is_requires_satisfied('env:BAR', argv=[], environ={'FOO': '1'}) + False + >>> _is_requires_satisfied('env:BAR==1', argv=[], environ={'FOO': '1'}) + False + >>> _is_requires_satisfied('env:BAR!=1', argv=[], environ={'FOO': '1'}) + True + >>> _is_requires_satisfied('env:BAR!=1', argv=[], environ={'BAR': '0'}) + True + >>> _is_requires_satisfied('env:BAR!=1') + ... + + >>> # xdoctest: +REQUIRES(module:pytest) + >>> import pytest + >>> with pytest.raises(ValueError): + >>> _is_requires_satisfied('badflag:BAR==1', []) + + >>> import pytest + >>> with pytest.raises(KeyError): + >>> _is_requires_satisfied('env:BAR>=1', argv=[], environ={'BAR': '0'}) """ # TODO: add python version options SYS_PLATFORM_TAGS = ['win32', 'linux', 'darwin', 'cywgin'] @@ -463,28 +507,57 @@ def _is_requires_satisfied(arg, argv): # TODO: tox tags: https://tox.readthedocs.io/en/latest/example/basic.html PY_VER_TAGS = ['py2', 'py3'] + arg_lower = arg.lower() + if arg.startswith('-'): + if argv is None: + argv = sys.argv flag = arg in argv elif arg.startswith('module:'): parts = arg.split(':') if len(parts) != 2: - raise ValueError('xdoctest REQUIRES directive has too many parts') + raise ValueError('xdoctest module REQUIRES directive has too many parts') # set flag to False (aka SKIP) if the module does not exist modname = parts[1] flag = _module_exists(modname) - elif arg.lower() in SYS_PLATFORM_TAGS: - flag = sys.platform.startswith(arg.lower()) - elif arg.lower() in OS_NAME_TAGS: - flag = os.name.startswith(arg.lower()) - elif arg.lower() in PY_IMPL_TAGS: + elif arg.startswith('env:'): + if environ is None: + environ = os.environ + parts = arg.split(':') + if len(parts) != 2: + raise ValueError('xdoctest env REQUIRES directive has too many parts') + envexpr = parts[1] + expr_parts = re.split('(==|!=|>=)', envexpr) + if len(expr_parts) == 1: + # Test if the environment variable is truthy + env_key = expr_parts[0] + flag = bool(environ.get(env_key, None)) + elif len(expr_parts) == 3: + # Test if the environment variable is equal to an expression + env_key, op_code, value = expr_parts + env_val = environ.get(env_key, None) + if op_code == '==': + op = operator.eq + elif op_code == '!=': + op = operator.ne + else: + raise KeyError(op_code) + flag = op(env_val, value) + else: + raise ValueError('Too many expr_parts={}'.format(expr_parts)) + elif arg_lower in SYS_PLATFORM_TAGS: + flag = sys.platform.startswith(arg_lower) + elif arg_lower in OS_NAME_TAGS: + flag = os.name.startswith(arg_lower) + elif arg_lower in PY_IMPL_TAGS: import platform - flag = platform.python_implementation().startswith(arg.lower()) - elif arg.lower() in PY_VER_TAGS: + flag = platform.python_implementation().startswith(arg_lower) + elif arg_lower in PY_VER_TAGS: if sys.version_info[0] == 2: # nocover - flag = arg.lower() == 'PY2' - elif sys.version_info[0] == 3: - flag = arg.lower() == 'PY3' - else: + flag = arg_lower == 'py2' + elif sys.version_info[0] == 3: # pragma: nobranch + flag = arg_lower == 'py3' + else: # nocover flag = False else: msg = utils.codeblock( @@ -493,6 +566,7 @@ def _is_requires_satisfied(arg, argv): (1) a PLATFORM or OS tag (e.g. win32, darwin, linux), (2) a command line flag prefixed with '--', or (3) a module prefixed with 'module:'. + (4) an environment variable prefixed with 'env:'. Got arg={!r} ''').replace('\n', ' ').strip().format(arg) raise ValueError(msg) diff --git a/xdoctest/docstr/convert_google_to_numpy.py b/xdoctest/docstr/convert_google_to_numpy.py new file mode 100644 index 00000000..7acf21fd --- /dev/null +++ b/xdoctest/docstr/convert_google_to_numpy.py @@ -0,0 +1,135 @@ + + +def convert_file_docstrings(path_to_convert, dry=True): + """ + path_to_convert = ub.expandpath('~/code/networkx/networkx/algorithms/isomorphism/_embeddinghelpers/balanced_sequence.py') + """ + import ubelt as ub + from xdoctest.core import package_calldefs + pkg_calldefs = list(package_calldefs(path_to_convert)) + def recnone(val, default): + return default if val is None else val + + for calldefs, modpath in pkg_calldefs: + to_insert = [] + old_text = ub.readfrom(modpath) + old_lines = old_text.split('\n') + sortnames = ub.argsort(calldefs, key=lambda node: recnone(node.doclineno, -1)) + for name in sortnames: + node = calldefs[name] + if node.docstr is not None: + google_docstr = node.docstr + numpy_docstr = google_to_numpy_docstr(google_docstr) + body_lines = numpy_docstr.split('\n') + start = node.doclineno + stop = node.doclineno_end + to_insert.append((start, stop, body_lines)) + + to_insert = sorted(to_insert)[::-1] + + new_lines = old_lines.copy() + for start, stop, body_lines in to_insert: + old_middle = old_lines[start - 1:stop] + print('old_middle = {}'.format(ub.repr2(old_middle, nl=1))) + print('start = {!r}'.format(start)) + startline = new_lines[start - 1] + print('startline = {!r}'.format(startline)) + ssline = startline.strip(' ') + sq = ssline[0] + tq = sq * 3 + n_indent = len(startline) - len(ssline) + indent = ' ' * n_indent + print('n_indent = {!r}'.format(n_indent)) + body_lines = [indent + line for line in body_lines] + body_lines = [indent + tq] + body_lines + [indent + tq] + prefix = new_lines[: start - 1] + suffix = new_lines[stop:] + mid = body_lines + new_lines = prefix + mid + suffix + + new_text = '\n'.join(new_lines) + # print(new_text) + if dry: + import xdev + print(xdev.misc.difftext(old_text, new_text, context_lines=10, colored=True)) + print('^^^ modpath = {!r}'.format(modpath)) + else: + ub.writeto(modpath, new_text, verbose=3) + + +def google_to_numpy_docstr(docstr): + """ + Convert a google-style docstring to a numpy-style docstring + + Args: + docstr (str): contents of ``func.__doc__`` for some ``func``, assumed + to be in google-style. + + Returns: + str: numpy style docstring + """ + import ubelt as ub + from xdoctest.docstr import docscrape_google + docblocks = docscrape_google.split_google_docblocks(docstr) + new_parts = [] + for key, block in docblocks: + old_body, relpos = block + new_key = key + new_body = old_body + + if key == '__DOC__': + new_key = None + new_text = new_body + elif key in {'Args'}: + new_key = 'Parameters' + arginfos = list( + docscrape_google.parse_google_argblock(old_body)) + parts = [] + for info in arginfos: + info['desc'] = ub.indent(info['desc']) + p = '{name}: {type}\n{desc}'.format(**info) + parts.append(p) + parts.append('') + new_body = '\n'.join(parts) + if key in {'Returns', 'Yields'}: + retinfos = list( + docscrape_google.parse_google_retblock(old_body)) + parts = [] + for info in retinfos: + info['desc'] = ub.indent(info['desc']) + info['name'] = info.get('name', '') + parts.append('{name}: {type}\n{desc}'.format(**info)) + parts.append('') + new_body = '\n'.join(parts) + + if new_key is not None: + new_text = '\n'.join( + [new_key, '-' * len(new_key), new_body]) + + if new_text.strip(): + new_parts.append(new_text) + + new_docstr = '\n'.join(new_parts) + new_docstr = new_docstr.strip('\n') + return new_docstr + + +def main(): + import scriptconfig as scfg + class Config(scfg.Config): + default = { + 'src': scfg.Value(None, help='path to file to convert'), + 'dry': scfg.Value(True, help='set to false to execute'), + } + config = Config(cmdline=True) + path_to_convert = config['src'] + dry = config['dry'] + convert_file_docstrings(path_to_convert, dry=dry) + + +if __name__ == '__main__': + """ + CommandLine: + python -m xdoctest.docstr.convert_google_to_numpy --src ~/code/networkx/networkx/algorithms/isomorphism/_embeddinghelpers/balanced_sequence.py + """ + main() diff --git a/xdoctest/doctest_example.py b/xdoctest/doctest_example.py index 0dec694e..e25328f3 100644 --- a/xdoctest/doctest_example.py +++ b/xdoctest/doctest_example.py @@ -70,14 +70,20 @@ def _populate_from_cli(self, ns): def _update_argparse_cli(self, add_argument, prefix=None, defaults={}): """ Updates a pytest or argparse CLI + + Args: + add_argument (callable): the parser.add_argument function """ + import argparse def str_lower(x): # python2 fix return str.lower(str(x)) add_argument_kws = [ + (['--colored'], dict(dest='colored', default=self['colored'], + help=('Enable or disable ANSI coloration in stdout'))), (['--nocolor'], dict(dest='colored', action='store_false', - default=self['colored'], + default=argparse.SUPPRESS, help=('Disable ANSI coloration in stdout'))), (['--offset'], dict(dest='offset_linenos', action='store_true', default=self['offset_linenos'], @@ -98,8 +104,10 @@ def str_lower(x): help='verbosity level')), # (['--verbose'], dict(action='store_true', dest='verbose')), (['--quiet'], dict(action='store_true', dest='verbose', + default=argparse.SUPPRESS, help='sets verbosity to 1')), (['--silent'], dict(action='store_false', dest='verbose', + default=argparse.SUPPRESS, help='sets verbosity to 0')), ] @@ -490,8 +498,15 @@ def run(self, verbose=None, on_error=None): # if self.is_disabled(): # runstate['SKIP'] = True + # - [x] TODO: fix CaptureStdout so it doesn't break embedding shells # don't capture stdout for zero-arg blocks - needs_capture = self.block_type != 'zero-arg' + # needs_capture = self.block_type != 'zero-arg' + # I think the bug that broke embedding shells is fixed, so it is now + # safe to capture. If not, uncomment above lines. If this works without + # issue, then remove these notes in a future version. + # needs_capture = False + needs_capture = True + # Use the same capture object for all parts in the test cap = utils.CaptureStdout(supress=self._suppressed_stdout, enabled=needs_capture) diff --git a/xdoctest/plugin.py b/xdoctest/plugin.py index 0c0d4c97..bc2392ea 100644 --- a/xdoctest/plugin.py +++ b/xdoctest/plugin.py @@ -153,6 +153,7 @@ def toterminal(self, tw): class XDoctestItem(pytest.Item): def __init__(self, name, parent, example=None): super(XDoctestItem, self).__init__(name, parent) + self.cls = XDoctestItem self.example = example self.obj = None self.fixture_request = None diff --git a/xdoctest/runner.py b/xdoctest/runner.py index e8cf910d..2ea85525 100644 --- a/xdoctest/runner.py +++ b/xdoctest/runner.py @@ -66,6 +66,31 @@ def log(msg, verbose): DEBUG = '--debug' in sys.argv +def doctest_callable(func): + """ + Executes doctests an in-memory function or class. + + Args: + func (callable): + live method or class for which we will run its doctests. + + Example: + >>> def inception(): + >>> ''' + >>> Example: + >>> >>> print("I heard you liked doctests") + >>> ''' + >>> func = inception + >>> doctest_callable(func) + """ + from xdoctest.core import parse_docstr_examples + doctests = list(parse_docstr_examples( + func.__doc__, callname=func.__name__)) + # TODO: can this be hooked up into runner to get nice summaries? + for doctest in doctests: + doctest.run(verbose=3) + + def doctest_module(modpath_or_name=None, command=None, argv=None, exclude=[], style='auto', verbose=None, config=None, durations=None, analysis='static'): diff --git a/xdoctest/utils/util_str.py b/xdoctest/utils/util_str.py index 1a8cc476..22c9086c 100644 --- a/xdoctest/utils/util_str.py +++ b/xdoctest/utils/util_str.py @@ -8,9 +8,16 @@ import textwrap # import warnings import re +import os import sys +# Global state that determines if ANSI-coloring text is allowed +# (which is mainly to address non-ANSI complient windows consoles) +# complient with https://no-color.org/ +NO_COLOR = bool(os.environ.get('NO_COLOR')) + + def strip_ansi(text): r""" Removes all ansi directives from the string. @@ -63,7 +70,7 @@ def color_text(text, color): >>> assert color_text(text, 'red') == 'raw text' >>> assert color_text(text, None) == 'raw text' """ - if color is None: + if NO_COLOR or color is None: return text try: import pygments @@ -170,6 +177,8 @@ def highlight_code(text, lexer_name='python', **kwargs): >>> new_text = highlight_code(text) >>> print(new_text) """ + if NO_COLOR: + return text # Resolve extensions to languages lexer_name = { 'py': 'python', diff --git a/xdoctest/utils/util_stream.py b/xdoctest/utils/util_stream.py index fc561ffa..0bc14971 100644 --- a/xdoctest/utils/util_stream.py +++ b/xdoctest/utils/util_stream.py @@ -1,6 +1,13 @@ # -*- coding: utf-8 -*- """ -Utilities for capturing stdout +Functions for capturing and redirecting IO streams. + +The :class:`CaptureStdout` captures all text sent to stdout and optionally +prevents it from actually reaching stdout. + +The :class:`TeeStringIO` does the same thing but for arbitrary streams. It is +how the former is implemented. + """ from __future__ import print_function, division, absolute_import, unicode_literals import sys @@ -9,11 +16,30 @@ class TeeStringIO(io.StringIO): - """ simple class to write to a stdout and a StringIO """ + """ + An IO object that writes to itself and another IO stream. + + Attributes: + redirect (io.IOBase): The other stream to write to. + + Example: + >>> redirect = io.StringIO() + >>> self = TeeStringIO(redirect) + """ def __init__(self, redirect=None): - self.redirect = redirect + self.redirect = redirect # type: io.IOBase super(TeeStringIO, self).__init__() + # Logic taken from prompt_toolkit/output/vt100.py version 3.0.5 in + # flush I don't have a full understanding of what the buffer + # attribute is supposed to be capturing here, but this seems to + # allow us to embed in IPython while still capturing and Teeing + # stdout + if hasattr(redirect, 'buffer'): + self.buffer = redirect.buffer # Py3. + else: + self.buffer = redirect + def isatty(self): # nocover """ Returns true of the redirect is a terminal. @@ -25,14 +51,38 @@ def isatty(self): # nocover return (self.redirect is not None and hasattr(self.redirect, 'isatty') and self.redirect.isatty()) + def fileno(self): + """ + Returns underlying file descriptor of the redirected IOBase object + if one exists. + """ + if self.redirect is not None: + return self.redirect.fileno() + else: + return super(TeeStringIO, self).fileno() + @property def encoding(self): + """ + Gets the encoding of the `redirect` IO object + + Example: + >>> redirect = io.StringIO() + >>> assert TeeStringIO(redirect).encoding is None + >>> assert TeeStringIO(None).encoding is None + >>> assert TeeStringIO(sys.stdout).encoding is sys.stdout.encoding + >>> redirect = io.TextIOWrapper(io.StringIO()) + >>> assert TeeStringIO(redirect).encoding is redirect.encoding + """ if self.redirect is not None: return self.redirect.encoding else: return super(TeeStringIO, self).encoding def write(self, msg): + """ + Write to this and the redirected stream + """ if self.redirect is not None: self.redirect.write(msg) if six.PY2: @@ -41,6 +91,9 @@ def write(self, msg): super(TeeStringIO, self).write(msg) def flush(self): # nocover + """ + Flush to this and the redirected stream + """ if self.redirect is not None: self.redirect.flush() super(TeeStringIO, self).flush() @@ -114,6 +167,11 @@ def start(self): sys.stdout = self.cap_stdout def stop(self): + """ + Example: + >>> CaptureStdout(enabled=False).stop() + >>> CaptureStdout(enabled=True).stop() + """ if self.enabled: self.started = False sys.stdout = self.orig_stdout @@ -122,7 +180,7 @@ def __enter__(self): self.start() return self - def __del__(self): + def __del__(self): # nocover if self.started: self.stop() if self.cap_stdout is not None: