diff --git a/CHANGELOG.md b/CHANGELOG.md index b6483ad..a2d1d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### [Unreleased] -### [1.0.0] - 2018-03-26 +### [1.0.0] - 2018-04-01 #### Features diff --git a/README.rst b/README.rst index c3da2e4..5abe9f4 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ stdio Manager ============= -*Python context manager for mocking/wrapping stdin/stdout/stderr* +*Python context manager for mocking/wrapping* ``stdin``/``stdout``/``stderr`` .. image:: https://travis-ci.org/bskinn/stdio-mgr.svg?branch=dev :target: https://travis-ci.org/bskinn/stdio-mgr @@ -17,39 +17,158 @@ stdio Manager .. image:: https://img.shields.io/github/license/mashape/apistatus.svg :target: https://github.com/bskinn/stdio-mgr/blob/master/LICENSE.txt -*README draft in progress.* +**Have a CLI Python application?** -Have a command-line Python application? Want to test *[...continued]* +**Want to automate testing of the actual console input & output +of your user-facing components?** +`stdio Manager` can help. +While some functionality here is more or less duplicative of +``redirect_stdout`` and ``redirect_stderr`` in ``contextlib`` +`within the standard library `__, +it provides (i) a much more concise way to mock both ``stdout`` and ``stderr`` at the same time, +and (ii) a mechanism for mocking ``stdin``, which is not available in ``contextlib``. -*[more about mocking stdio]* +**First, install:** +.. code:: + + $ pip install stdio-mgr + +Then use! + +All of the below examples assume ``stdio_mgr`` has already +been imported via: + +.. code:: + + from stdio_mgr import stdio_mgr + +**Mock** ``stdout``\ **:** + +.. code:: + + >>> with stdio_mgr() as (in_, out_, err_): + ... print('foobar') + ... out_cap = out_.getvalue() + >>> out_cap + 'foobar\n' + >>> in_.closed and out_.closed and err_.closed + True + +By default ``print`` +`appends a newline `__ +after each argument, which is why ``out_cap`` is ``'foobar\n'`` +and not just ``'foobar'``. + +As currently implemented, ``stdio_mgr`` closes all three mocked streams +upon exiting the managed context. + + +**Mock** ``stderr``\ **:** + +.. code :: + + >>> import warnings + >>> with stdio_mgr() as (in_, out_, err_): + ... warnings.warn("'foo' has no 'bar'") + ... err_cap = err_.getvalue() + >>> err_cap + "...README.rst:2: UserWarning: 'foo' has no 'bar'\n =============\n" + + +**Mock** ``stdin``\ **:** + +The simulated user input has to be pre-loaded to the mocked stream. +**Be sure to include newlines in the input to correspond to +each mocked** `Enter` **keypress!** +Otherwise, ``input`` will hang, waiting for a newline +that will never come. -In addition to mocking `stdio` for testing, `stdio_mgr` can also be used to -wrap functions that directly interact with `stdio`. Example: +If the entirety of the input is known in advance, +it can just be provided as an argument to ``stdio_mgr``. +Otherwise, ``.append()`` mocked input to ``in_`` +within the managed context as needed: .. code:: - >>> def embellish(func): + >>> with stdio_mgr('foobar\n') as (in_, out_, err_): + ... print('baz') + ... in_cap = input('??? ') + ... + ... _ = in_.append(in_cap[:3] + '\n') + ... in_cap2 = input('??? ') + ... + ... out_cap = out_.getvalue() + >>> in_cap + 'foobar' + >>> in_cap2 + 'foo' + >>> out_cap + 'baz\n??? foobar\n??? foo\n' + +The ``_ =`` assignment suppresses ``print``\ ing of the return value +from the ``in_.append()`` call--otherwise, it would be interleaved +in ``out_cap``, since this example is shown for an interactive context. +For non-interactive execution, as with ``unittest``, ``pytest``, etc., +these 'muting' assignments should not be necessary. + +**Both** the ``'??? '`` prompts for ``input`` +**and** the mocked input strings +are echoed to ``out_``, mimicking what a CLI user would see. + +A subtlety: While the trailing newline on, e.g., ``'foobar\n'`` is stripped +by ``input``, it is *retained* in ``out_``. +This is because ``in_`` tees the content read from it to ``out_`` +*before* that content is passed to ``input``. + + +**Want to modify internal** ``print`` **calls +within a function or method?** + +In addition to mocking, ``stdio_mgr`` can also be used to +wrap functions that directly output to ``stdout``/``stderr``. A ``stdout`` example: + +.. code:: + + >>> def emboxen(func): ... def func_wrapper(s): ... from stdio_mgr import stdio_mgr ... - ... with stdio_mgr() as (i, o, e): + ... with stdio_mgr() as (in_, out_, err_): ... func(s) - ... content = o.getvalue() - ... newcontent = '*** ' + content.replace('\n', ' ***\n*** ') - ... newcontent = newcontent[:-5] + ... content = out_.getvalue() + ... + ... max_len = max(map(len, content.splitlines())) + ... fmt_str = '| {{: <{0}}} |\n'.format(max_len) + ... + ... newcontent = '=' * (max_len + 4) + '\n' + ... for line in content.splitlines(): + ... newcontent += fmt_str.format(line) + ... newcontent += '=' * (max_len + 4) + ... ... print(newcontent) + ... ... return func_wrapper - >>> @embellish + >>> @emboxen ... def testfunc(s): ... print(s) >>> testfunc("""\ ... Foo bar baz quux. - ... Lorem ipsum dolor sit amet....""") - *** Foo bar baz quux. *** - *** Lorem ipsum dolor sit amet.... *** + ... Lorem ipsum dolor sit amet.""") + =============================== + | Foo bar baz quux. | + | Lorem ipsum dolor sit amet. | + =============================== + + +**Feature requests or bug reports?** + +Please submit them as GitHub `Issues `__. + +\(c) 2018 Brian Skinn + diff --git a/requirements-dev.txt b/requirements-dev.txt index 061841d..346071e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,4 +4,5 @@ flake8==3.4.1 flake8-docstrings==1.1.0 ipython restview +twine wget diff --git a/setup.py b/setup.py index cb5b147..efd1b89 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ def readme(): packages=['stdio_mgr'], provides=['stdio_mgr'], requires=['attrs (>=17.1)'], - install_requires=['attrs>=17'], + install_requires=['attrs>=17.1'], python_requires='>=3', url='https://www.github.com/bskinn/stdio-mgr', license='MIT License', @@ -27,10 +27,11 @@ def readme(): 'Intended Audience :: Developers', 'Operating System :: OS Independent', 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Software Development :: Testing', - 'Development Status :: 4 - Beta'], + 'Development Status :: 5 - Production/Stable'], ) diff --git a/stdio_mgr/__init__.py b/stdio_mgr/__init__.py index d25086b..ee2eac9 100644 --- a/stdio_mgr/__init__.py +++ b/stdio_mgr/__init__.py @@ -17,7 +17,7 @@ http://www.github.com/bskinn/stdio-mgr **Documentation** - [pending] + See README.rst at the GitHub repository **License** The MIT License; see |license_txt|_ for full license terms @@ -34,4 +34,4 @@ from .stdio_mgr import stdio_mgr -__version__ = '1.0rc1' +__version__ = '1.0' diff --git a/stdio_mgr/stdio_mgr.py b/stdio_mgr/stdio_mgr.py index dc163c1..1328698 100644 --- a/stdio_mgr/stdio_mgr.py +++ b/stdio_mgr/stdio_mgr.py @@ -17,7 +17,7 @@ http://www.github.com/bskinn/stdio-mgr **Documentation** - [pending] + See README.rst at the GitHub repository **License** The MIT License; see |license_txt|_ for full license terms @@ -36,9 +36,35 @@ class TeeStdin(StringIO): """Class to tee contents to a side buffer on read. - Also provides .append(), which adds new content to the end of the + Subclass of :cls:`~io.StringIO` that overrides + :meth:`~io.StringIO.read` and :meth:`~io.StringIO.readline` + to tee all content *read* from the stream to `tee`. The + canonical use-case is with :func:`stdio_mgr`, + where `tee` is the mocked stream for `stdin`. + + To emphasize: teeing occurs on content *read*, **not write**. + + This class also provides the method + :meth:`TeeStdin..append`, which is not available + for the base :cls:`~io.StringIO` type. + This method adds new content to the end of the stream while leaving the read position unchanged. + Instantiation takes two arguments: + + `tee` + + :cls:`~io.TextIOBase` -- Text stream to receive + content teed from :cls:`TeeStdin` upon read + + `init_text` + + |str| *(optional)* -- + Text to use as the initial contents of the + underlying :cls:`~io.StringIO`. `init_text` is + passed directly to the :cls:~io.StringIO` + instantiation call. Default is an empty |str|. + """ from io import SEEK_SET, SEEK_END @@ -52,19 +78,56 @@ def __attrs_post_init__(self): super().__init__(self.init_text) def read(self, size=None): # pragma: no cover - """Tee text to side buffer when read.""" + """Tee text to side buffer when read. + + Overrides :meth:`io.StringIO.read ` + to implement the teeing. + + Parameters + ---------- + size + + |int| or |None| *(optional)* -- + Number of characters to return; a negative or |None| + value reads to EOF. + + """ text = super().read(size) self.tee.write(text) return text def readline(self, size=-1): - """Tee text to side buffer when read.""" + """Tee text to side buffer when read. + + Overrides :meth:`io.StringIO.readline ` + to implement the teeing. + + Parameters + ---------- + size + + |int| *(optional)* -- + Number of characters to return; a negative value + reads an entire line, regardless of length + + """ text = super().readline(size) self.tee.write(text) return text def append(self, text): - """Write to end of stream, restore position.""" + """Write to end of stream while maintaining seek position. + + Actually stores the current position; seeks to end; + writes `text`; and seeks to prior position. + + Parameters + ---------- + text + + |str| -- Text to append to the current stream contents. + + """ pos = self.tell() self.seek(0, self.SEEK_END) retval = self.write(text) @@ -73,8 +136,43 @@ def append(self, text): @contextmanager -def stdio_mgr(cmd_str=''): - """Prepare indicated sys for wrapped/mocked I/O.""" +def stdio_mgr(in_str=''): + r"""Subsitute temporary text buffers for `stdio` in a managed context. + + Context manager. + + Substitutes empty :cls:`~io.StringIO`\ s for + :cls:`sys.stdout` and :cls:`sys.stderr`, + and a :cls:`TeeStdin` for :cls:`sys.stdin` within the managed context. + + Upon exiting the context, the original stream objects are restored + within :mod:`sys`, and the temporary streams are closed. + + Parameters + ---------- + in_str + + |str| *(optional)* -- Initialization text for + the :cls:`TeeStdin` substitution for `stdin`. + Default is an empty string. + + Yields + ------ + in_ + + :cls:`TeeStdin` -- Temporary stream for `stdin`. + + out_ + + :cls:`~io.StringIO` -- Temporary stream for `stdout`, + initially empty. + + err_ + + :cls:`~io.StringIO` -- Temporary stream for `stderr`, + initially empty. + + """ import sys old_stdin = sys.stdin @@ -83,7 +181,7 @@ def stdio_mgr(cmd_str=''): new_stdout = StringIO() new_stderr = StringIO() - new_stdin = TeeStdin(new_stdout, cmd_str) + new_stdin = TeeStdin(new_stdout, in_str) sys.stdin = new_stdin sys.stdout = new_stdout @@ -95,9 +193,6 @@ def stdio_mgr(cmd_str=''): sys.stdout = old_stdout sys.stderr = old_stderr - sys.stdout.write(new_stdout.read()) - sys.stderr.write(new_stderr.read()) - new_stdin.close() new_stdout.close() new_stderr.close() diff --git a/stdio_mgr/test/stdio_mgr_base.py b/stdio_mgr/test/stdio_mgr_base.py index 0274cb9..ca22558 100644 --- a/stdio_mgr/test/stdio_mgr_base.py +++ b/stdio_mgr/test/stdio_mgr_base.py @@ -97,8 +97,16 @@ def test_ManagedStdin(self): self.assertEqual(str2[:-1], out_str) +def setup_stdiomgr_import(dt_obj): + """Import stdio_mgr into the test globals.""" + from stdio_mgr import stdio_mgr + dt_obj.globs.update({'stdio_mgr': stdio_mgr}) + + TestStdioMgrReadme = dt.DocFileSuite(osp.abspath('README.rst'), - module_relative=False) + module_relative=False, + setUp=setup_stdiomgr_import, + optionflags=dt.ELLIPSIS) def suite_all():