Skip to content
This repository has been archived by the owner on May 3, 2024. It is now read-only.

Commit

Permalink
Merge pull request #34 from sernst/33-console-only-display
Browse files Browse the repository at this point in the history
This PR adds a `write_to_console()` function to the `ExposedStep` class that allows for writing message strings to the stdout console without them appearing in the notebook display. From within a notebook it is now possible to call:

`cd.step.write_to_console('my message')`

to write the specified message to the console from within a running step without it appearing in the notebook HTML.
  • Loading branch information
sernst committed Jun 17, 2018
2 parents eb677ec + 4b0e818 commit 4ffc45c
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 55 deletions.
3 changes: 2 additions & 1 deletion cauldron/render/texts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
2 changes: 1 addition & 1 deletion cauldron/session/display/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']))
)


Expand Down
12 changes: 12 additions & 0 deletions cauldron/session/exposed.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,18 @@ def breathe(self):
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."""
Expand Down
52 changes: 26 additions & 26 deletions cauldron/session/writing/file_io.py
Original file line number Diff line number Diff line change
@@ -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'])
Expand All @@ -13,20 +14,25 @@
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)


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
Expand All @@ -44,15 +50,15 @@ def deploy_entry(entry):

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))
Expand All @@ -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):
Expand All @@ -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)
2 changes: 1 addition & 1 deletion cauldron/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"version": "0.3.7",
"version": "0.3.8",
"notebookVersion": "v1"
}
1 change: 1 addition & 0 deletions cauldron/steptest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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

Expand Down
31 changes: 31 additions & 0 deletions cauldron/steptest/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,37 @@
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:
"""
Expand Down
119 changes: 115 additions & 4 deletions cauldron/test/projects/test_exposed.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -26,6 +26,46 @@ def test_no_project_defaults(self):
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"""

Expand All @@ -37,7 +77,6 @@ def test_change_title(self):

def test_no_step_defaults(self):
"""Exposed step should apply defaults without project"""

es = exposed.ExposedStep()
self.assertIsNone(es._step)

Expand Down Expand Up @@ -180,3 +219,75 @@ def test_get_internal_project_fail(
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)))

0 comments on commit 4ffc45c

Please sign in to comment.