Repository: sernst/cauldron

Issue:
Console Only Display
Add support for writing messages out only to the console from the notebook so that they appear in log output but not in the display of the running notebook. The capability for doing this already exists internally in the `RedirectBuffer`, but should be exposed to make it possible to use externally.


Discussion:

Patch:
diff --git a/cauldron/render/texts.py b/cauldron/render/texts.py
index dbf59f0..269edce 100644
--- a/cauldron/render/texts.py
+++ b/cauldron/render/texts.py
@@ -239,5 +239,6 @@ def markdown(source: str = None, source_path: str = None, **kwargs) -> dict:
     body = pattern.sub('data-src="\g<url>"', body)
     return dict(
         body=body,
-        library_includes=library_includes
+        library_includes=library_includes,
+        rendered=rendered
     )
diff --git a/cauldron/session/display/__init__.py b/cauldron/session/display/__init__.py
index 4eb7432..ec5f30f 100644
--- a/cauldron/session/display/__init__.py
+++ b/cauldron/session/display/__init__.py
@@ -97,7 +97,7 @@ def markdown(source: str = None, source_path: str = None, **kwargs):
 
     r.append_body(result['body'])
     r.stdout_interceptor.write_source(
-        '{}\n'.format(textwrap.dedent(source))
+        '{}\n'.format(textwrap.dedent(result['rendered']))
     )
 
 
diff --git a/cauldron/session/exposed.py b/cauldron/session/exposed.py
index 3a1da3c..8c807dd 100644
--- a/cauldron/session/exposed.py
+++ b/cauldron/session/exposed.py
@@ -236,6 +236,18 @@ class ExposedStep(object):
         if self._step:
             threads.abort_thread()
 
+    def write_to_console(self, message: str):
+        """
+        Writes the specified message to the console stdout without including
+        it in the notebook display.
+        """
+        if not self._step:
+            raise ValueError(
+                'Cannot write to the console stdout on an uninitialized step'
+            )
+        interceptor = self._step.report.stdout_interceptor
+        interceptor.write_source('{}'.format(message))
+
 
 def render_stop_display(step: 'projects.ProjectStep', message: str):
     """Renders a stop action to the Cauldron display."""
diff --git a/cauldron/session/writing/file_io.py b/cauldron/session/writing/file_io.py
index 549f839..ab89da5 100644
--- a/cauldron/session/writing/file_io.py
+++ b/cauldron/session/writing/file_io.py
@@ -1,10 +1,11 @@
 import os
 import shutil
-import typing
 import time
+import typing
 from collections import namedtuple
 
 from cauldron import environ
+from cauldron import writer
 
 FILE_WRITE_ENTRY = namedtuple('FILE_WRITE_ENTRY', ['path', 'contents'])
 FILE_COPY_ENTRY = namedtuple('FILE_COY_ENTRY', ['source', 'destination'])
@@ -13,6 +14,12 @@ FILE_COPY_ENTRY = namedtuple('FILE_COY_ENTRY', ['source', 'destination'])
 def entry_from_dict(
         data: dict
 ) -> typing.Union[FILE_WRITE_ENTRY, FILE_COPY_ENTRY]:
+    """
+    Converts the given data dictionary into either a file write or file copy
+    entry depending on the keys in the dictionary. The dictionary should
+    contain either ('path', 'contents') keys for file write entries or
+    ('source', 'destination') keys for file copy entries.
+    """
     if 'contents' in data:
         return FILE_WRITE_ENTRY(**data)
     return FILE_COPY_ENTRY(**data)
@@ -20,13 +27,12 @@ def entry_from_dict(
 
 def deploy(files_list: typing.List[tuple]):
     """
-    Iterates through the specified files_list and copies or writes each entry depending
-    on whether its a file copy entry or a file write entry
+    Iterates through the specified files_list and copies or writes each entry
+    depending on whether its a file copy entry or a file write entry.
 
     :param files_list:
-    :return:
+        A list of file write entries and file copy entries
     """
-
     def deploy_entry(entry):
         if not entry:
             return
@@ -44,15 +50,15 @@ def deploy(files_list: typing.List[tuple]):
 
 def make_output_directory(output_path: str) -> str:
     """
-    Creates the parent directory or directories for the specified output path if they
-    do not already exist to prevent incomplete directory path errors during copying/writing
-    operations
+    Creates the parent directory or directories for the specified output path
+    if they do not already exist to prevent incomplete directory path errors
+    during copying/writing operations.
 
     :param output_path:
-        The path of the destination file or directory that will be written
+        The path of the destination file or directory that will be written.
     :return:
-        The absolute path to the output directory that was created if missing or already
-        existed
+        The absolute path to the output directory that was created if missing
+        or already existed.
     """
 
     output_directory = os.path.dirname(environ.paths.clean(output_path))
@@ -65,17 +71,12 @@ def make_output_directory(output_path: str) -> str:
 
 def copy(copy_entry: FILE_COPY_ENTRY):
     """
-    Copies the specified file from its source location to its destination location
-
-    :param copy_entry:
-    :return:
+    Copies the specified file from its source location to its destination
+    location.
     """
-
     source_path = environ.paths.clean(copy_entry.source)
     output_path = environ.paths.clean(copy_entry.destination)
-
     copier = shutil.copy2 if os.path.isfile(source_path) else shutil.copytree
-
     make_output_directory(output_path)
 
     for i in range(3):
@@ -85,17 +86,16 @@ def copy(copy_entry: FILE_COPY_ENTRY):
         except Exception:
             time.sleep(0.5)
 
+    raise IOError('Unable to copy "{source}" to "{destination}"'.format(
+        source=source_path,
+        destination=output_path
+    ))
+
 
 def write(write_entry: FILE_WRITE_ENTRY):
     """
-    Writes the contents of the specified file entry to its destination path
-
-    :param write_entry:
-    :return:
+    Writes the contents of the specified file entry to its destination path.
     """
-
     output_path = environ.paths.clean(write_entry.path)
     make_output_directory(output_path)
-
-    with open(output_path, 'w+') as f:
-        f.write(write_entry.contents)
+    writer.write_file(output_path, write_entry.contents)
diff --git a/cauldron/settings.json b/cauldron/settings.json
index e67c08f..9e101a7 100644
--- a/cauldron/settings.json
+++ b/cauldron/settings.json
@@ -1,4 +1,4 @@
 {
-  "version": "0.3.7",
+  "version": "0.3.8",
   "notebookVersion": "v1"
 }


Test patch:
diff --git a/cauldron/steptest/__init__.py b/cauldron/steptest/__init__.py
index 30ad7b5..89d8639 100644
--- a/cauldron/steptest/__init__.py
+++ b/cauldron/steptest/__init__.py
@@ -9,6 +9,7 @@ from cauldron.cli import commander
 from cauldron.session import exposed  # noqa
 from cauldron.steptest import support
 from cauldron.steptest.functional import CauldronTest
+from cauldron.steptest.functional import create_test_fixture
 from cauldron.steptest.results import StepTestRunResult
 from cauldron.steptest.support import find_project_directory
 
diff --git a/cauldron/steptest/functional.py b/cauldron/steptest/functional.py
index dbb16c8..13561ea 100644
--- a/cauldron/steptest/functional.py
+++ b/cauldron/steptest/functional.py
@@ -9,6 +9,37 @@ from cauldron.session import exposed  # noqa
 from cauldron.steptest import support
 from cauldron.steptest.results import StepTestRunResult
 
+try:
+    import pytest
+except ImportError:  # pragma: no cover
+    pytest = None
+
+# @pytest.fixture(name='tester')
+# def tester_fixture() -> CauldronTest:
+#     """Create the Cauldron project test environment"""
+#     tester = CauldronTest(project_path=os.path.dirname(__file__))
+#     tester.setup()
+#     yield tester
+#     tester.tear_down()
+
+
+def create_test_fixture(test_file_path: str, fixture_name: str = 'tester'):
+    """..."""
+    path = os.path.realpath(
+        os.path.dirname(test_file_path)
+        if os.path.isfile(test_file_path) else
+        test_file_path
+    )
+
+    @pytest.fixture(name=fixture_name)
+    def tester_fixture() -> CauldronTest:
+        tester = CauldronTest(project_path=path)
+        tester.setup()
+        yield tester
+        tester.tear_down()
+
+    return tester_fixture
+
 
 class CauldronTest:
     """
diff --git a/cauldron/test/projects/test_exposed.py b/cauldron/test/projects/test_exposed.py
index 6992957..1f6bc88 100644
--- a/cauldron/test/projects/test_exposed.py
+++ b/cauldron/test/projects/test_exposed.py
@@ -1,6 +1,8 @@
-from unittest.mock import patch
+import os
+from datetime import datetime
 from unittest.mock import MagicMock
 from unittest.mock import PropertyMock
+from unittest.mock import patch
 
 import cauldron as cd
 from cauldron.session import exposed
@@ -13,9 +15,7 @@ class TestExposed(scaffolds.ResultsTest):
 
     def test_no_project_defaults(self):
         """Expected defaults when no project exists"""
-
         ep = exposed.ExposedProject()
-
         self.assertIsNone(ep.display)
         self.assertIsNone(ep.shared)
         self.assertIsNone(ep.settings)
@@ -26,6 +26,46 @@ class TestExposed(scaffolds.ResultsTest):
         with self.assertRaises(RuntimeError):
             ep.title = 'Some Title'
 
+    @patch(
+        'cauldron.session.exposed.ExposedStep._step',
+        new_callable=PropertyMock
+    )
+    def test_step_properties(self, _step: PropertyMock):
+        """Should return values from the internal _step object."""
+        now = datetime.utcnow()
+        _step.return_value = MagicMock(
+            start_time=now,
+            end_time=now,
+            elapsed_time=0
+        )
+        es = exposed.ExposedStep()
+        self.assertEqual(now, es.start_time)
+        self.assertEqual(now, es.end_time)
+        self.assertEqual(0, es.elapsed_time)
+
+    @patch(
+        'cauldron.session.exposed.ExposedStep._step',
+        new_callable=PropertyMock
+    )
+    def test_step_stop_aborted(self, _step: PropertyMock):
+        """
+        Should abort stopping and not raise an error when no internal step
+        is available to stop.
+        """
+        _step.return_value = None
+        es = exposed.ExposedStep()
+        es.stop()
+
+    @patch('cauldron.session.exposed.ExposedProject.get_internal_project')
+    def test_project_stop_aborted(self, get_internal_project: MagicMock):
+        """
+        Should abort stopping and not raise an error when no internal project
+        is available to stop.
+        """
+        get_internal_project.return_value = None
+        ep = exposed.ExposedProject()
+        ep.stop()
+
     def test_change_title(self):
         """Title should change through exposed project"""
 
@@ -37,7 +77,6 @@ class TestExposed(scaffolds.ResultsTest):
 
     def test_no_step_defaults(self):
         """Exposed step should apply defaults without project"""
-
         es = exposed.ExposedStep()
         self.assertIsNone(es._step)
 
@@ -180,3 +219,75 @@ class TestExposed(scaffolds.ResultsTest):
         result = project.get_internal_project()
         self.assertIsNone(result)
         self.assertEqual(10, sleep.call_count)
+
+    @patch(
+        'cauldron.session.exposed.ExposedStep._step',
+        new_callable=PropertyMock
+    )
+    def test_write_to_console(self, _step: PropertyMock):
+        """
+        Should write to the console using a write_source function
+        call on the internal step report's stdout_interceptor.
+        """
+        trials = [2, True, None, 'This is a test', b'hello']
+
+        for message in trials:
+            _step_mock = MagicMock()
+            write_source = MagicMock()
+            _step_mock.report.stdout_interceptor.write_source = write_source
+            _step.return_value = _step_mock
+            step = exposed.ExposedStep()
+            step.write_to_console(message)
+
+            args, kwargs = write_source.call_args
+            self.assertEqual('{}'.format(message), args[0])
+
+    @patch(
+        'cauldron.session.exposed.ExposedStep._step',
+        new_callable=PropertyMock
+    )
+    def test_write_to_console_fail(self, _step: PropertyMock):
+        """
+        Should raise a ValueError when there is no current step to operate
+        upon by the write function call.
+        """
+        _step.return_value = None
+        step = exposed.ExposedStep()
+        with self.assertRaises(ValueError):
+            step.write_to_console('hello')
+
+    @patch('cauldron.render.stack.get_formatted_stack_frame')
+    def test_render_stop_display(self, get_formatted_stack_frame: MagicMock):
+        """Should render stop display without error"""
+        get_formatted_stack_frame.return_value = [
+            {'filename': 'foo'},
+            {'filename': 'bar'},
+            {'filename': os.path.realpath(exposed.__file__)}
+        ]
+        step = MagicMock()
+        exposed.render_stop_display(step, 'FAKE')
+        self.assertEqual(1, step.report.append_body.call_count)
+
+    @patch('cauldron.templating.render_template')
+    @patch('cauldron.render.stack.get_formatted_stack_frame')
+    def test_render_stop_display_error(
+            self,
+            get_formatted_stack_frame: MagicMock,
+            render_template: MagicMock
+    ):
+        """
+        Should render an empty stack frame when the stack data is invalid.
+        """
+        get_formatted_stack_frame.return_value = None
+        step = MagicMock()
+        exposed.render_stop_display(step, 'FAKE')
+        self.assertEqual({}, render_template.call_args[1]['frame'])
+
+    def test_project_path(self):
+        """Should create an absolute path within the project"""
+        ep = exposed.ExposedProject()
+        project = MagicMock()
+        project.source_directory = os.path.realpath(os.path.dirname(__file__))
+        ep.load(project)
+        result = ep.path('hello.md')
+        self.assertTrue(result.endswith('{}hello.md'.format(os.sep)))
diff --git a/cauldron/test/steptesting/test_functional.py b/cauldron/test/steptesting/test_functional.py
index 7c2b76f..3e58125 100644
--- a/cauldron/test/steptesting/test_functional.py
+++ b/cauldron/test/steptesting/test_functional.py
@@ -6,19 +6,11 @@ import pytest
 
 import cauldron as cd
 from cauldron import steptest
-from cauldron.steptest import CauldronTest
 
+tester_fixture = steptest.create_test_fixture(__file__)
 
-@pytest.fixture(name='tester')
-def tester_fixture() -> CauldronTest:
-    """Create the Cauldron project test environment"""
-    tester = CauldronTest(project_path=os.path.dirname(__file__))
-    tester.setup()
-    yield tester
-    tester.tear_down()
 
-
-def test_first_step(tester: CauldronTest):
+def test_first_step(tester: steptest.CauldronTest):
     """Should not be any null/NaN values in df"""
     assert cd.shared.fetch('df') is None
     step = tester.run_step('S01-first.py')
@@ -29,7 +21,7 @@ def test_first_step(tester: CauldronTest):
     assert error_echo == ''
 
 
-def test_second_step(tester: CauldronTest):
+def test_second_step(tester: steptest.CauldronTest):
     """
     Should fail without exception because of an exception raised in the
     source but failure is allowed
@@ -41,7 +33,7 @@ def test_second_step(tester: CauldronTest):
     assert 0 < len(error_echo)
 
 
-def test_second_step_strict(tester: CauldronTest):
+def test_second_step_strict(tester: steptest.CauldronTest):
     """
     Should fail because of an exception raised in the source when strict
     failure is enforced
@@ -53,7 +45,7 @@ def test_second_step_strict(tester: CauldronTest):
 @patch('_testlib.patching_test')
 def test_second_step_with_patching(
         patching_test: MagicMock,
-        tester: CauldronTest
+        tester: steptest.CauldronTest
 ):
     """Should override the return value with the patch"""
     patching_test.return_value = 12
@@ -63,14 +55,14 @@ def test_second_step_with_patching(
     assert 12 == cd.shared.result
 
 
-def test_second_step_without_patching(tester: CauldronTest):
+def test_second_step_without_patching(tester: steptest.CauldronTest):
     """Should succeed running the step without patching"""
     cd.shared.value = 42
     tester.run_step('S03-lib-patching.py')
     assert 42 == cd.shared.result
 
 
-def test_to_strings(tester: CauldronTest):
+def test_to_strings(tester: steptest.CauldronTest):
     """Should convert list of integers to a list of strings"""
     before = [1, 2, 3]
     step = tester.run_step('S01-first.py')
@@ -79,7 +71,7 @@ def test_to_strings(tester: CauldronTest):
     assert ['1', '2', '3'] == after
 
 
-def test_modes(tester: CauldronTest):
+def test_modes(tester: steptest.CauldronTest):
     """Should be testing and not interactive or single run"""
     step = tester.run_step('S01-first.py')
     assert step.success
@@ -122,19 +114,19 @@ def test_find_failed_at_root():
         func.assert_called_once_with(subdirectory)
 
 
-def test_make_temp_path(tester: CauldronTest):
+def test_make_temp_path(tester: steptest.CauldronTest):
     """Should make a temp path for testing"""
     temp_path = tester.make_temp_path('some-id', 'a', 'b.test')
     assert temp_path.endswith('b.test')
 
 
-def test_no_such_step(tester: CauldronTest):
+def test_no_such_step(tester: steptest.CauldronTest):
     """Should fail if no such step exists"""
     with pytest.raises(Exception):
         tester.run_step('FAKE-STEP.no-exists')
 
 
-def test_no_such_project(tester: CauldronTest):
+def test_no_such_project(tester: steptest.CauldronTest):
     """Should fail if no project exists"""
     project = cd.project.get_internal_project()
     cd.project.load(None)
@@ -145,7 +137,7 @@ def test_no_such_project(tester: CauldronTest):
     cd.project.load(project)
 
 
-def test_open_project_fails(tester: CauldronTest):
+def test_open_project_fails(tester: steptest.CauldronTest):
     """Should raise Assertion error after failing to open the project"""
     with patch('cauldron.steptest.support.open_project') as open_project:
         open_project.side_effect = RuntimeError('FAKE')
diff --git a/cauldron/test/writing/test_writing.py b/cauldron/test/writing/test_writing.py
index a6ea410..9d04680 100644
--- a/cauldron/test/writing/test_writing.py
+++ b/cauldron/test/writing/test_writing.py
@@ -1,3 +1,7 @@
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+from cauldron.session.writing import file_io
 from cauldron.test import support
 from cauldron.test.support import scaffolds
 
@@ -7,8 +11,54 @@ class TestWriting(scaffolds.ResultsTest):
 
     def test_plotly_project(self):
         """Should properly write a project that has been run"""
-
         support.open_project(self, '@examples:time-gender')
-
         response = support.run_command('run')
         self.assertFalse(response.failed)
+
+    def test_entry_from_dict_write_entry(self):
+        """Should create a file write entry from the source dict."""
+        result = file_io.entry_from_dict({'contents': 'abc', 'path': '/foo'})
+        self.assertIsInstance(result, file_io.FILE_WRITE_ENTRY)
+        self.assertEqual('abc', result.contents)
+        self.assertEqual('/foo', result.path)
+
+    def test_entry_from_dict_copy_entry(self):
+        """Should create a file copy entry from the source dict."""
+        result = file_io.entry_from_dict({
+            'source': '/bar',
+            'destination': '/foo'
+        })
+        self.assertIsInstance(result, file_io.FILE_COPY_ENTRY)
+        self.assertEqual('/bar', result.source)
+        self.assertEqual('/foo', result.destination)
+
+    @patch('cauldron.session.writing.file_io.copy')
+    @patch('cauldron.session.writing.file_io.write')
+    def test_deploy(self, write: MagicMock, copy: MagicMock):
+        """Should write and copy given entries according to type."""
+        entries = [
+            file_io.FILE_COPY_ENTRY('a', 'b'),
+            file_io.FILE_WRITE_ENTRY('a', 'b'),
+            None
+        ]
+
+        file_io.deploy(entries)
+        self.assertEqual(1, write.call_count)
+        self.assertEqual(entries[1], write.call_args[0][0])
+        self.assertEqual(1, copy.call_count)
+        self.assertEqual(entries[0], copy.call_args[0][0])
+
+    def test_deploy_failed(self):
+        """Should raise value error for invalid/unknown entries."""
+        entries = ['not a valid entry']
+        with self.assertRaises(ValueError):
+            file_io.deploy(entries)
+
+    @patch('time.sleep')
+    @patch('shutil.copy2')
+    def test_copy_retries(self, copy2: MagicMock, sleep: MagicMock):
+        """Should fail to copy entry and raise an IOError."""
+        copy2.side_effect = ValueError('FAKE')
+        with self.assertRaises(IOError):
+            file_io.copy(file_io.FILE_COPY_ENTRY(__file__, '/fake'))
+        self.assertEqual(3, sleep.call_count)