Repository: sernst/cauldron

Issue:
Enhanced Syncing
Improve the file sync handling in the `cauldron.cli.sync` subpackage.


Discussion:

Patch:
diff --git a/cauldron/cli/server/routes/synchronize/__init__.py b/cauldron/cli/server/routes/synchronize/__init__.py
index 81f5aff..1b1ea08 100644
--- a/cauldron/cli/server/routes/synchronize/__init__.py
+++ b/cauldron/cli/server/routes/synchronize/__init__.py
@@ -3,8 +3,9 @@ import mimetypes
 import os
 import tempfile
 
-import cauldron as cd
 import flask
+
+import cauldron as cd
 from cauldron import cli
 from cauldron import writer
 from cauldron.cli import sync
@@ -115,6 +116,7 @@ def sync_open_project():
     sync_status.update({}, time=-1, project=None)
 
     open_response = project_opener.open_project(project_folder, forget=True)
+    open_response.join()
     project = cd.project.internal_project
     project.remote_source_directory = source_directory
 
@@ -142,6 +144,7 @@ def sync_source_file():
     index = args.get('index', 0)
     sync_time = args.get('sync_time', -1)
     location = args.get('location', 'project')
+    offset = args.get('offset', 0)
 
     if None in [relative_path, chunk]:
         return r.fail(
@@ -173,11 +176,16 @@ def sync_source_file():
     if not os.path.exists(parent_directory):
         os.makedirs(parent_directory)
 
-    sync.io.write_file_chunk(file_path, chunk, append=index > 0)
+    sync.io.write_file_chunk(
+        file_path=file_path,
+        packed_chunk=chunk,
+        append=index > 0,
+        offset=offset
+    )
 
     sync_status.update({}, time=sync_time)
 
-    print('SAVED CHUNK:', file_path)
+    print('SAVED CHUNK:', offset, file_path)
     return r.notify(
         kind='SUCCESS',
         code='SAVED_CHUNK',
diff --git a/cauldron/cli/sync/comm.py b/cauldron/cli/sync/comm.py
index c66d111..f60cc7f 100644
--- a/cauldron/cli/sync/comm.py
+++ b/cauldron/cli/sync/comm.py
@@ -20,7 +20,6 @@ def assemble_url(
     :return:
         The fully-resolved URL for the given endpoint
     """
-
     url_root = (
         remote_connection.url
         if remote_connection else
@@ -51,7 +50,6 @@ def parse_http_response(http_response: HttpResponse) -> 'environ.Response':
     :return:
         The Cauldron response object for the given http response
     """
-
     try:
         response = environ.Response.deserialize(http_response.json())
     except Exception as error:
@@ -67,35 +65,65 @@ def parse_http_response(http_response: HttpResponse) -> 'environ.Response':
     return response
 
 
-def get_request_function(data: dict = None, method: str = None):
-    """ """
-
-    default_method = 'post' if data else 'get'
-    return getattr(requests, method.lower() if method else default_method)
-
-
 def send_request(
         endpoint: str,
         data: dict = None,
         remote_connection: 'environ.RemoteConnection' = None,
         method: str = None,
+        timeout: int = 10,
+        max_retries: int = 10,
         **kwargs
 ) -> 'environ.Response':
-    """ """
+    """
+    Sends a request to the remote kernel specified by the RemoteConnection
+    object and processes the result. If the request fails or times out it
+    will be retried until the max retries is reached. After that a failed
+    response will be returned instead.
+
+    :param endpoint:
+        Remote endpoint where the request will be directed.
+    :param data:
+        An optional JSON-serializable dictionary containing the request
+        body data.
+    :param remote_connection:
+        Defines the connection to the remote server where the request will
+        be sent.
+    :param method:
+        The HTTP method type for the request, e.g. GET, POST.
+    :param timeout:
+        Number of seconds before the request aborts when trying to either
+        connect to the target endpoint or receive data from the server.
+    :param max_retries:
+        Number of retry attempts to make before giving up if a non-HTTP
+        error is encountered during communication.
+    """
+    if max_retries < 0:
+        return environ.Response().fail(
+            code='COMMUNICATION_ERROR',
+            error=None,
+            message='Unable to communicate with the remote kernel.'
+        ).console(whitespace=1).response
 
     url = assemble_url(endpoint, remote_connection)
-    func = get_request_function(data, method)
 
     try:
-        http_response = func(url, json=data, **kwargs)
-    except Exception as error:
-        return environ.Response().fail(
-            code='CONNECTION_ERROR',
-            error=error,
-            message='Unable to communicate with the remote connection'
-        ).console(
-            whitespace=1
-        ).response
+        http_response = requests.request(
+            method=method,
+            url=url,
+            json=data,
+            timeout=10,
+            **kwargs
+        )
+    except (requests.ConnectionError, requests.HTTPError, requests.Timeout):
+        return send_request(
+            endpoint=endpoint,
+            data=data,
+            remote_connection=remote_connection,
+            method=method,
+            timeout=timeout,
+            max_retries=max_retries - 1,
+            **kwargs
+        )
 
     return parse_http_response(http_response)
 
diff --git a/cauldron/cli/sync/files.py b/cauldron/cli/sync/files.py
index 047d0c9..9d130b7 100644
--- a/cauldron/cli/sync/files.py
+++ b/cauldron/cli/sync/files.py
@@ -11,25 +11,24 @@ from cauldron.environ.response import Response
 def send_chunk(
         chunk: str,
         index: int,
+        offset: int,
         relative_path: str,
         file_kind: str = '',
         remote_connection: 'environ.RemoteConnection' = None,
         sync_time: float = -1
 ):
     """ """
-
-    args = dict(
-        relative_path=relative_path,
-        chunk=chunk,
-        type=file_kind,
-        index=index,
-        sync_time=time.time() if sync_time < 0 else sync_time
-    )
-
     return sync.comm.send_request(
         endpoint='/sync-file',
         remote_connection=remote_connection,
-        data=args
+        data=dict(
+            relative_path=relative_path,
+            chunk=chunk,
+            offset=offset,
+            type=file_kind,
+            index=index,
+            sync_time=time.time() if sync_time < 0 else sync_time
+        )
     )
 
 
@@ -44,7 +43,6 @@ def send(
         sync_time: float = -1
 ) -> Response:
     """ """
-
     response = Response()
     sync_time = time.time() if sync_time < 0 else sync_time
     callback = (
@@ -91,15 +89,18 @@ def send(
         )
     ))
 
+    offset = 0
     for index, chunk in enumerate(chunks):
         response = send_chunk(
             chunk=chunk,
             index=index,
+            offset=offset,
             relative_path=relative_path,
             file_kind=file_kind,
             remote_connection=remote_connection,
             sync_time=sync_time
         )
+        offset += len(chunk)
 
         if response.failed:
             return response
@@ -146,7 +147,6 @@ def send_all_in(
         sync_time: float = -1
 ) -> Response:
     """ """
-
     sync_time = time.time() if sync_time < 0 else sync_time
 
     glob_end = ('**', '*') if recursive else ('*',)
@@ -156,10 +156,10 @@ def send_all_in(
         relative_root_path
         if relative_root_path else
         directory
-    ).rstrip(os.sep)
+    ).rstrip(os.path.sep)
 
     for file_path in glob.iglob(glob_path, recursive=True):
-        relative_path = file_path[len(root_path):].lstrip(os.sep)
+        relative_path = file_path[len(root_path):].lstrip(os.path.sep)
 
         response = send(
             file_path=file_path,
diff --git a/cauldron/cli/sync/sync_io.py b/cauldron/cli/sync/sync_io.py
index 9836a25..4cb3b07 100644
--- a/cauldron/cli/sync/sync_io.py
+++ b/cauldron/cli/sync/sync_io.py
@@ -3,10 +3,8 @@ import math
 import os
 import zlib
 
-
 from cauldron import writer
 
-
 # Default Chunks are 1MB in size
 DEFAULT_CHUNK_SIZE = 1048576  # type: int
 
@@ -20,7 +18,6 @@ def pack_chunk(source_data: bytes) -> str:
     :param source_data:
         The data to be converted to a compressed, base64 string
     """
-
     if not source_data:
         return ''
 
@@ -61,18 +58,17 @@ def get_file_chunk_count(
         The number of chunks necessary to send the entire contents of the
         specified file for the given chunk size
     """
-
     if not os.path.exists(file_path):
         return 0
 
     file_size = os.path.getsize(file_path)
-    return max(1, math.ceil(file_size / chunk_size))
+    return max(1, int(math.ceil(file_size / chunk_size)))
 
 
 def read_file_chunks(
         file_path: str,
         chunk_size: int = DEFAULT_CHUNK_SIZE
-) -> str:
+) -> bytes:
     """
     Reads the specified file in chunks and returns a generator where
     each returned chunk is a compressed base64 encoded string for sync
@@ -84,7 +80,6 @@ def read_file_chunks(
         The size, in bytes, of each chunk. The final chunk will be less than
         or equal to this size as the remainder.
     """
-
     chunk_count = get_file_chunk_count(file_path, chunk_size)
 
     if chunk_count < 1:
@@ -97,7 +92,12 @@ def read_file_chunks(
             yield chunk
 
 
-def write_file_chunk(file_path: str, chunk_data: str, append: bool = True):
+def write_file_chunk(
+        file_path: str,
+        packed_chunk: str,
+        append: bool = True,
+        offset: int = -1
+):
     """
     Write or append the specified chunk data to the given file path, unpacking
     the chunk before writing. If the file does not yet exist, it will be
@@ -106,13 +106,19 @@ def write_file_chunk(file_path: str, chunk_data: str, append: bool = True):
 
     :param file_path:
         The file where the chunk will be written or appended
-    :param chunk_data:
-        The packed chunk data to write to the file
+    :param packed_chunk:
+        The packed chunk data to write to the file. It will be unpacked before
+        the file is written.
     :param append:
         Whether or not the chunk should be appended to the existing file. If
         False the chunk data will overwrite the existing file.
+    :param offset:
+        The byte offset in the file where the chunk should be written.
+        If the value is less than zero, the chunk will be written or appended
+        based on the `append` argument. Note that if you indicate an append
+        write mode and an offset, the mode will be forced to write instead of
+        append.
     """
-
     mode = 'ab' if append else 'wb'
-    contents = unpack_chunk(chunk_data)
-    writer.write_file(file_path, contents, mode=mode)
+    contents = unpack_chunk(packed_chunk)
+    writer.write_file(file_path, contents, mode=mode, offset=offset)
diff --git a/cauldron/environ/response.py b/cauldron/environ/response.py
index eabf446..aea5182 100644
--- a/cauldron/environ/response.py
+++ b/cauldron/environ/response.py
@@ -191,6 +191,25 @@ class Response(object):
         print(self.echo())
         return self
 
+    def join(self, timeout: float = None) -> bool:
+        """
+        Joins on the thread associated with the response if it exists, or
+        just returns after a no-op if no thread exists to join.
+
+        :param timeout:
+            Maximum number of seconds to block on the join before given up
+            and continuing operation. The default `None` value will wait
+            forever.
+        :return:
+            A boolean indicating whether or not a thread existed to join
+            upon.
+        """
+        try:
+            self.thread.join(timeout)
+            return True
+        except AttributeError:
+            return False
+
     def echo(self) -> str:
         """ """
 
diff --git a/cauldron/environ/systems.py b/cauldron/environ/systems.py
index d2f8abc..c0a408a 100644
--- a/cauldron/environ/systems.py
+++ b/cauldron/environ/systems.py
@@ -3,6 +3,7 @@ import os
 import shutil
 import site
 import sys
+import time
 import typing
 
 from cauldron.environ import paths
@@ -119,13 +120,19 @@ def get_package_data() -> dict:
         return json.load(f)
 
 
-def remove(path: str) -> bool:
+def remove(path: str, max_retries: int = 3) -> bool:
     """
+    Removes the specified path from the local filesystem if it exists.
+    Directories will be removed along with all files and folders within
+    them as well as files.
 
     :param path:
+        The location of the file or folder to remove.
+    :param max_retries:
+        The number of times to retry before giving up.
     :return:
+        A boolean indicating whether or not the removal was successful.
     """
-
     if not path:
         return False
 
@@ -134,12 +141,14 @@ def remove(path: str) -> bool:
 
     remover = os.remove if os.path.isfile(path) else shutil.rmtree
 
-    for attempt in range(3):
+    for attempt in range(max_retries):
         try:
             remover(path)
             return True
         except Exception:
-            pass
+            # Pause briefly in case there's a race condition on lock
+            # for the target.
+            time.sleep(0.02)
 
     return False
 
diff --git a/cauldron/settings.json b/cauldron/settings.json
index a2fb2f3..b042ac5 100644
--- a/cauldron/settings.json
+++ b/cauldron/settings.json
@@ -1,4 +1,4 @@
 {
-  "version": "0.3.4",
+  "version": "0.3.5",
   "notebookVersion": "v1"
 }
diff --git a/cauldron/writer.py b/cauldron/writer.py
index 3ff8551..ca30c69 100644
--- a/cauldron/writer.py
+++ b/cauldron/writer.py
@@ -6,7 +6,8 @@ import typing
 def attempt_file_write(
         path: str,
         contents: typing.Union[str, bytes],
-        mode: str = 'w'
+        mode: str = 'w',
+        offset: int = 0
 ) -> typing.Union[None, Exception]:
     """
     Attempts to write the specified contents to a file and returns None if
@@ -18,14 +19,34 @@ def attempt_file_write(
         The contents of the file to write
     :param mode:
         The mode in which the file will be opened when written
+    :param offset:
+        The byte offset in the file where the contents should be written.
+        If the value is zero, the offset information will be ignored and
+        the operation will write entirely based on mode. Note that if you
+        indicate an append write mode and an offset, the mode will be forced
+        to write instead of append.
     :return:
         None if the write operation succeeded. Otherwise, the exception that
         was raised by the failed write action.
     """
+    try:
+        data = contents.encode()
+    except Exception:
+        data = contents
 
+    if offset > 0:
+        with open(path, 'rb') as f:
+            existing = f.read(offset)
+    else:
+        existing = None
+
+    append = 'a' in mode
+    write_mode = 'wb' if offset > 0 or not append else 'ab'
     try:
-        with open(path, mode) as f:
-            f.write(contents)
+        with open(path, write_mode) as f:
+            if existing is not None:
+                f.write(existing)
+            f.write(data)
         return None
     except Exception as error:
         return error
@@ -35,7 +56,8 @@ def write_file(
         path: str,
         contents,
         mode: str = 'w',
-        retry_count: int = 3
+        retry_count: int = 3,
+        offset: int = 0
 ) -> typing.Tuple[bool, typing.Union[None, Exception]]:
     """
     Writes the specified contents to a file, with retry attempts if the write
@@ -51,16 +73,21 @@ def write_file(
     :param retry_count:
         The number of attempts to make before giving up and returning a
         failed write.
+    :param offset:
+        The byte offset in the file where the contents should be written.
+        If the value is zero, the offset information will be ignored and the
+        operation will write entirely based on mode. Note that if you indicate
+        an append write mode and an offset, the mode will be forced to write
+        instead of append.
     :return:
         Returns two arguments. The first is a boolean specifying whether or
         not the write operation succeeded. The second is the error result, which
         is None if the write operation succeeded. Otherwise, it will be the 
         exception that was raised by the last failed write attempt.
     """
-
     error = None
     for i in range(retry_count):
-        error = attempt_file_write(path, contents, mode)
+        error = attempt_file_write(path, contents, mode, offset)
         if error is None:
             return True, None
         time.sleep(0.2)
@@ -86,7 +113,6 @@ def attempt_json_write(
         None if the write operation succeeded. Otherwise, the exception that
         was raised by the failed write operation.
     """
-
     try:
         with open(path, mode) as f:
             json.dump(contents, f)
@@ -122,7 +148,6 @@ def write_json_file(
         is None if the write operation succeeded. Otherwise, it will be the 
         exception that was raised by the last failed write attempt.
     """
-
     error = None
     for i in range(retry_count):
         error = attempt_json_write(path, contents, mode)
diff --git a/docker-compose.yml b/docker-compose.yml
index 5bc6481..4ae3145 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,12 +1,40 @@
- version: '3'
- services:
-   app:
-     build: .
-     container_name: cauldron_app
-     volumes:
-       - .:/cauldron
-     ports:
-       - "5010:5010"
-     stdin_open: true
-     tty: true
-     entrypoint: /bin/bash
+version: '3'
+services:
+  app:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    volumes:
+      - .:/cauldron
+    ports:
+      - "5010:5010"
+    stdin_open: true
+    tty: true
+    entrypoint: /bin/bash
+
+  kernel-conda:
+    build:
+      context: .
+      dockerfile: docker-conda.dockerfile
+    volumes:
+      - .:/cauldron_local
+    ports:
+      - "5010:5010"
+
+  kernel-miniconda:
+    build:
+      context: .
+      dockerfile: docker-miniconda.dockerfile
+    volumes:
+      - .:/cauldron_local
+    ports:
+      - "5010:5010"
+
+  kernel-standard:
+    build:
+      context: .
+      dockerfile: docker-standard.dockerfile
+    volumes:
+      - .:/cauldron_local
+    ports:
+      - "5010:5010"
diff --git a/docker-run.sh b/docker-run.sh
index 90b704d..8cd1d7b 100644
--- a/docker-run.sh
+++ b/docker-run.sh
@@ -2,5 +2,3 @@
 
 echo "Starting Cauldron Kernel"
 cauldron kernel --port=5010 --name=0.0.0.0
-
-


Test patch:
diff --git a/cauldron/steptest/functional.py b/cauldron/steptest/functional.py
index 16ac45c..dbb16c8 100644
--- a/cauldron/steptest/functional.py
+++ b/cauldron/steptest/functional.py
@@ -1,4 +1,3 @@
-import inspect
 import os
 import sys
 import tempfile
@@ -12,32 +11,37 @@ from cauldron.steptest.results import StepTestRunResult
 
 
 class CauldronTest:
-    """A Decorator class for use with the Pytest testing framework."""
+    """
+    A Dependency injection class for use with the Pytest or similar testing
+    framework.
+    """
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, project_path: str, *args, **kwargs):
         """
         Initializes the decorator with default values that will be populated
         during the lifecycle of the decorator and test.
         """
+        self.project_path = os.path.realpath(project_path)
         self._call_args = args
         self._call_kwargs = kwargs
         self._test_function = None
         self.results_directory = None
         self.temp_directories = dict()
+        self._library_paths = []
 
-    def __call__(self, test_function):
+    def setup(self) -> 'CauldronTest':
         """
-        Executed during decoration, this function wraps the supplied test
-        function and returns a wrapped function that should be called to
-        executed the step test.
+        Handles initializing the environment and opening the project for
+        testing.
         """
-        self._test_function = test_function
-
-        def cauldron_test_wrapper(*args, **kwargs):
-            return self.run_test(*args, **kwargs)
+        environ.modes.add(environ.modes.TESTING)
 
         project_path = self.make_project_path('cauldron.json')
-        self._library_paths = support.get_library_paths(project_path)
+        self._library_paths = [
+            path
+            for path in support.get_library_paths(project_path)
+            if path not in sys.path
+        ]
         sys.path.extend(self._library_paths)
 
         # Load libraries before calling test functions so that patching works
@@ -45,15 +49,6 @@ class CauldronTest:
         # patching isn't reverted.
         runner.reload_libraries(self._library_paths)
 
-        cauldron_test_wrapper.__name__ = test_function.__name__
-        return cauldron_test_wrapper
-
-    def setup(self):
-        """
-        Handles initializing the environment and opening the project for
-        testing.
-        """
-        environ.modes.add(environ.modes.TESTING)
         results_directory = tempfile.mkdtemp(
             prefix='cd-step-test-results-{}--'.format(self.__class__.__name__)
         )
@@ -62,6 +57,8 @@ class CauldronTest:
         self.temp_directories = dict()
         self.open_project()
 
+        return self
+
     def make_project_path(self, *args) -> str:
         """
         Returns an absolute path to the specified location within the Cauldron
@@ -74,8 +71,7 @@ class CauldronTest:
             components are specified, the location returned will be the
             project directory itself.
         """
-        filename = inspect.getfile(self._test_function)
-        project_directory = support.find_project_directory(filename)
+        project_directory = support.find_project_directory(self.project_path)
         return os.path.join(project_directory, *args)
 
     def open_project(self) -> 'exposed.ExposedProject':
@@ -129,22 +125,6 @@ class CauldronTest:
         """
         return support.make_temp_path(self.temp_directories, identifier, *args)
 
-    def run_test(self, *args, **kwargs):
-        """
-        The function called to actually carry out the execution of the test,
-        which is wrapped within a function and closure in the `__call__`
-        method for decoration purposes.
-
-        :param args:
-            Positional arguments passed to the test function at execution time.
-        :param kwargs:
-            Keyword arguments passed to the test function at execution time.
-        """
-        self.setup()
-        result = self._test_function(self, *args, **kwargs)
-        self.tear_down()
-        return result
-
     def tear_down(self):
         """
         After a test is run this function is used to undo any side effects that
diff --git a/cauldron/test/cli/sync/test_sync_comm.py b/cauldron/test/cli/sync/test_sync_comm.py
index 9f208d4..bdcbe3a 100644
--- a/cauldron/test/cli/sync/test_sync_comm.py
+++ b/cauldron/test/cli/sync/test_sync_comm.py
@@ -2,6 +2,7 @@ import os
 from unittest.mock import MagicMock
 from unittest.mock import patch
 
+import requests
 from requests import Response as HttpResponse
 
 from cauldron import environ
@@ -11,11 +12,10 @@ from cauldron.test.support import scaffolds
 
 
 class TestSyncComm(scaffolds.ResultsTest):
-    """ Tests for the cauldron.cli.sync.sync_comm module """
+    """Test suite for the cauldron.cli.sync.sync_comm module"""
 
     def test_assemble_url_without_connection(self):
-        """ should assemble url """
-
+        """Should assemble url"""
         endpoint = '/some-endpoint'
         url_assembled = comm.assemble_url(endpoint)
         self.assertEqual(
@@ -24,8 +24,7 @@ class TestSyncComm(scaffolds.ResultsTest):
         )
 
     def test_assemble_url_specified_connection(self):
-        """ should assemble url using the specified remote connection data """
-
+        """Should assemble url using the specified remote connection data"""
         url = 'some.url:5451'
         endpoint = '/some-endpoint'
         remote_connection = environ.RemoteConnection(url=url, active=True)
@@ -34,8 +33,7 @@ class TestSyncComm(scaffolds.ResultsTest):
         self.assertEqual(url_assembled, 'http://{}{}'.format(url, endpoint))
 
     def test_assemble_url_global_connection(self):
-        """ should assemble url using the specified remote connection data """
-
+        """Should assemble url using the specified remote connection data"""
         url = 'some.url:5451'
         endpoint = '/some-endpoint'
 
@@ -46,25 +44,22 @@ class TestSyncComm(scaffolds.ResultsTest):
 
         support.run_command('disconnect')
 
-    @patch('requests.get')
-    def test_send_request_invalid(self, request_get: MagicMock):
-        """ should fail to send request """
-
-        request_get.side_effect = ValueError('Fake Error')
-
-        response = comm.send_request('/fake', method='get')
-        self.assert_has_error_code(response, 'CONNECTION_ERROR')
+    @patch('requests.request')
+    def test_send_request_invalid(self, request: MagicMock):
+        """Should fail to send request"""
+        request.side_effect = requests.ConnectionError('Fake Error')
+        response = comm.send_request('/fake', method='GET')
+        self.assert_has_error_code(response, 'COMMUNICATION_ERROR')
 
     @patch('cauldron.cli.sync.comm.parse_http_response')
-    @patch('requests.post')
+    @patch('requests.request')
     def test_send_request_valid(
             self,
-            request_post: MagicMock,
+            request: MagicMock,
             parse_http_response: MagicMock
     ):
-        """ should fail to send request """
-
-        request_post.return_value = HttpResponse()
+        """Should successfully send request"""
+        request.return_value = HttpResponse()
         parse_http_response.return_value = environ.Response('test')
 
         response = comm.send_request(
@@ -75,8 +70,7 @@ class TestSyncComm(scaffolds.ResultsTest):
         self.assertEqual(response.identifier, 'test')
 
     def test_parse_valid_http_response(self):
-        """ should fail to send request """
-
+        """Should fail to send request"""
         source_response = environ.Response().update(test='hello_world')
 
         def json_mock(*args, **kwargs):
@@ -93,8 +87,7 @@ class TestSyncComm(scaffolds.ResultsTest):
         )
 
     def test_parse_invalid_http_response(self):
-        """ should fail to parse invalid http response """
-
+        """Should fail to parse invalid http response"""
         http_response = HttpResponse()
         response = comm.parse_http_response(http_response)
         self.assert_has_error_code(response, 'INVALID_REMOTE_RESPONSE')
@@ -102,10 +95,8 @@ class TestSyncComm(scaffolds.ResultsTest):
 
     @patch('requests.get')
     def test_failed_download(self, requests_get: MagicMock):
-        """ should fail to download if the GET request raises an exception """
-
+        """Should fail to download if the GET request raises an exception"""
         requests_get.side_effect = IOError('FAKE ERROR')
-
         path = self.get_temp_path('failed_download', 'fake.filename')
         response = comm.download_file('fake.filename', path)
 
@@ -114,8 +105,7 @@ class TestSyncComm(scaffolds.ResultsTest):
 
     @patch('requests.get')
     def test_failed_download_write(self, requests_get: MagicMock):
-        """ should fail to download if the GET request raises an exception """
-
+        """Should fail to download if the GET request raises an exception"""
         requests_get.return_value = dict()
         path = self.get_temp_path('failed_download', 'fake.filename')
 
@@ -128,8 +118,7 @@ class TestSyncComm(scaffolds.ResultsTest):
 
     @patch('requests.get')
     def test_download(self, requests_get: MagicMock):
-        """ should successfully download saved cauldron file """
-
+        """Should successfully download saved cauldron file"""
         def mock_iter_content(*args, **kwargs):
             yield from [b'a', b'b', b'', None, b'c']
 
@@ -146,4 +135,3 @@ class TestSyncComm(scaffolds.ResultsTest):
         with open(path, 'rb') as f:
             contents = f.read()
         self.assertEqual(contents, b'abc')
-
diff --git a/cauldron/test/environ/test_response.py b/cauldron/test/environ/test_response.py
index b25ee9d..92d2ef8 100644
--- a/cauldron/test/environ/test_response.py
+++ b/cauldron/test/environ/test_response.py
@@ -1,22 +1,21 @@
 from unittest import mock
 from unittest.mock import patch
+from unittest.mock import MagicMock
 
 from cauldron.environ.response import Response
 from cauldron.test.support import scaffolds
 
 
 class TestResponse(scaffolds.ResultsTest):
-    """ """
+    """Test suite for the Response class"""
 
     def test_get_response(self):
-        """ should get the response back from the response message """
-
+        """Should get the response back from the response message"""
         r = Response()
         self.assertEqual(r, r.fail().get_response())
 
     def test_warn(self):
-        """ should notify if warned is called """
-
+        """Should notify if warned is called"""
         r = Response()
         r.warn('FAKE_WARN', key='VALUE')
 
@@ -25,14 +24,12 @@ class TestResponse(scaffolds.ResultsTest):
         self.assertEqual(r.warnings[0].data['key'], 'VALUE')
 
     def test_debug_echo(self):
-        """ should echo debug information """
-
+        """Should echo debug information"""
         r = Response()
         r.debug_echo()
 
     def test_echo(self):
-        """ should echo information """
-
+        """Should echo information"""
         r = Response()
         r.warn('WARNING', something=[1, 2, 3], value=False)
         r.fail('ERROR')
@@ -41,8 +38,7 @@ class TestResponse(scaffolds.ResultsTest):
         self.assertGreater(result.find('ERROR'), 0)
 
     def test_echo_parented(self):
-        """ should call parent echo """
-
+        """Should call parent echo"""
         r = Response()
         parent = Response().consume(r)
 
@@ -52,14 +48,12 @@ class TestResponse(scaffolds.ResultsTest):
             func.assert_any_call()
 
     def test_consume_nothing(self):
-        """ should abort consuming if there is nothing to consume """
-
+        """Should abort consuming if there is nothing to consume"""
         r = Response()
         r.consume(None)
 
     def test_grandparent(self):
-        """ should parent correctly if parented """
-
+        """Should parent correctly if parented"""
         child = Response()
         parent = Response()
         grandparent = Response()
@@ -70,8 +64,7 @@ class TestResponse(scaffolds.ResultsTest):
         self.assertEqual(child.parent, grandparent)
 
     def test_update_parented(self):
-        """ should update through parent """
-
+        """Should update through parent"""
         child = Response()
         parent = Response()
         parent.consume(child)
@@ -80,8 +73,7 @@ class TestResponse(scaffolds.ResultsTest):
         self.assertEqual(parent.data['banana'], 'orange')
 
     def test_notify_parented(self):
-        """ should notify through parent """
-
+        """Should notify through parent"""
         child = Response()
         parent = Response()
         parent.consume(child)
@@ -95,8 +87,7 @@ class TestResponse(scaffolds.ResultsTest):
         self.assertEqual(m.message, 'Good Stuff')
 
     def test_end_parented(self):
-        """ should end the parent """
-
+        """Should end the parent"""
         child = Response()
         parent = Response()
         parent.consume(child)
@@ -105,8 +96,7 @@ class TestResponse(scaffolds.ResultsTest):
         self.assertTrue(parent.ended)
 
     def test_logging(self):
-        """ should log messages to the log """
-
+        """Should log messages to the log"""
         r = Response()
         r.notify(
             kind='TEST',
@@ -130,7 +120,18 @@ class TestResponse(scaffolds.ResultsTest):
         self.assertEqual(out, compare)
 
     def test_self_consumption(self):
-        """ should not consume itself and cause regression error """
-
+        """Should not consume itself and cause regression error"""
         r = Response()
         r.consume(r)
+
+    def test_join_nothing(self):
+        """Should do nothing and return if no thread exists"""
+        r = Response()
+        self.assertFalse(r.join())
+
+    def test_join_thread(self):
+        """Should join the associated thread and return True"""
+        r = Response()
+        r.thread = MagicMock()
+        self.assertTrue(r.join())
+        self.assertEqual(1, r.thread.join.call_count)
diff --git a/cauldron/test/steptesting/test_functional.py b/cauldron/test/steptesting/test_functional.py
index 76ce75d..c65438f 100644
--- a/cauldron/test/steptesting/test_functional.py
+++ b/cauldron/test/steptesting/test_functional.py
@@ -9,7 +9,15 @@ from cauldron import steptest
 from cauldron.steptest import CauldronTest
 
 
-@CauldronTest()
+@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):
     """Should not be any null/NaN values in df"""
     assert cd.shared.fetch('df') is None
@@ -21,7 +29,6 @@ def test_first_step(tester: CauldronTest):
     assert error_echo == ''
 
 
-@CauldronTest()
 def test_second_step(tester: CauldronTest):
     """
     Should fail without exception because of an exception raised in the
@@ -34,7 +41,6 @@ def test_second_step(tester: CauldronTest):
     assert 0 < len(error_echo)
 
 
-@CauldronTest()
 def test_second_step_strict(tester: CauldronTest):
     """
     Should fail because of an exception raised in the source when strict
@@ -45,10 +51,9 @@ def test_second_step_strict(tester: CauldronTest):
 
 
 @patch('_testlib.patching_test')
-@CauldronTest()
 def test_second_step_with_patching(
-        tester: CauldronTest,
-        patching_test: MagicMock
+        patching_test: MagicMock,
+        tester: CauldronTest
 ):
     """Should override the return value with the patch"""
     patching_test.return_value = 12
@@ -58,7 +63,6 @@ def test_second_step_with_patching(
     assert 12 == cd.shared.result
 
 
-@CauldronTest()
 def test_second_step_without_patching(tester: CauldronTest):
     """Should succeed running the step without patching"""
     cd.shared.value = 42
@@ -66,7 +70,6 @@ def test_second_step_without_patching(tester: CauldronTest):
     assert 42 == cd.shared.result
 
 
-@CauldronTest()
 def test_to_strings(tester: CauldronTest):
     """Should convert list of integers to a list of strings"""
     before = [1, 2, 3]
@@ -76,7 +79,6 @@ def test_to_strings(tester: CauldronTest):
     assert ['1', '2', '3'] == after
 
 
-@CauldronTest()
 def test_modes(tester: CauldronTest):
     """Should be testing and not interactive or single run"""
     step = tester.run_step('S01-first.py')
@@ -86,16 +88,14 @@ def test_modes(tester: CauldronTest):
     assert not step.local.is_single_run
 
 
-@CauldronTest()
-def test_find_in_current_path(tester: CauldronTest):
+def test_find_in_current_path():
     """Should find a project in this file's directory"""
     directory = os.path.dirname(os.path.realpath(__file__))
     result = steptest.find_project_directory(directory)
     assert directory == result
 
 
-@CauldronTest()
-def test_find_in_parent_path(tester: CauldronTest):
+def test_find_in_parent_path():
     """Should find a project in the parent directory"""
     directory = os.path.dirname(os.path.realpath(__file__))
     subdirectory = os.path.join(directory, 'fake')
@@ -103,8 +103,7 @@ def test_find_in_parent_path(tester: CauldronTest):
     assert directory == result
 
 
-@CauldronTest()
-def test_find_in_grandparent_path(tester: CauldronTest):
+def test_find_in_grandparent_path():
     """Should find a project in the grandparent directory"""
     directory = os.path.dirname(os.path.realpath(__file__))
     subdirectory = os.path.join(directory, 'fake', 'fake')
@@ -112,8 +111,7 @@ def test_find_in_grandparent_path(tester: CauldronTest):
     assert directory == result
 
 
-@CauldronTest()
-def test_find_failed_at_root(tester: CauldronTest):
+def test_find_failed_at_root():
     """Should raise FileNotFoundError if top-level directory has no project"""
     directory = os.path.dirname(os.path.realpath(__file__))
     subdirectory = os.path.join(directory, 'fake')
@@ -124,21 +122,18 @@ def test_find_failed_at_root(tester: CauldronTest):
         func.assert_called_once_with(subdirectory)
 
 
-@CauldronTest()
 def test_make_temp_path(tester: 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')
 
 
-@CauldronTest()
 def test_no_such_step(tester: CauldronTest):
     """Should fail if no such step exists"""
     with pytest.raises(Exception):
         tester.run_step('FAKE-STEP.no-exists')
 
 
-@CauldronTest()
 def test_no_such_project(tester: CauldronTest):
     """Should fail if no project exists"""
     project = cd.project.internal_project
@@ -150,7 +145,6 @@ def test_no_such_project(tester: CauldronTest):
     cd.project.load(project)
 
 
-@CauldronTest()
 def test_open_project_fails(tester: CauldronTest):
     """Should raise Assertion error after failing to open the project"""
     with patch('cauldron.steptest.support.open_project') as open_project:
diff --git a/cauldron/test/test_writer.py b/cauldron/test/test_writer.py
index 2788bb5..3d4ca8e 100644
--- a/cauldron/test/test_writer.py
+++ b/cauldron/test/test_writer.py
@@ -1,99 +1,130 @@
-import unittest
-from unittest.mock import patch
+import tempfile
 from unittest.mock import MagicMock
+from unittest.mock import patch
 
+from cauldron import environ
 from cauldron import writer
 
 
-class TestWriter(unittest.TestCase):
-    """Test suite for the cauldron.writer module"""
-
-    @patch('time.sleep')
-    @patch('cauldron.writer.attempt_file_write')
-    def test_write_file(self, attempt_file_write: MagicMock, sleep: MagicMock):
-        """Should successfully write without retries"""
-
-        attempt_file_write.return_value = None
-
-        success, error = writer.write_file('fake', 'fake')
-        self.assertTrue(success)
-        self.assertIsNone(error)
-        self.assertEqual(1, attempt_file_write.call_count)
-        self.assertEqual(0, sleep.call_count)
-
-    @patch('time.sleep')
-    @patch('cauldron.writer.attempt_file_write')
-    def test_write_file_fail(
-            self,
-            attempt_file_write: MagicMock,
-            sleep: MagicMock
-    ):
-        """Should fail to write with retries"""
-
-        attempt_file_write.return_value = IOError('FAKE')
-
-        success, error = writer.write_file('fake', 'fake', retry_count=5)
-        self.assertFalse(success)
-        self.assertIsNotNone(error)
-        self.assertEqual(5, attempt_file_write.call_count)
-        self.assertEqual(5, sleep.call_count)
-
-    @patch('time.sleep')
-    @patch('cauldron.writer.attempt_json_write')
-    def test_write_json(self, attempt_json_write: MagicMock, sleep: MagicMock):
-        """Should successfully write JSON without retries"""
-
-        attempt_json_write.return_value = None
-
-        success, error = writer.write_json_file('fake', {})
-        self.assertTrue(success)
-        self.assertIsNone(error)
-        self.assertEqual(1, attempt_json_write.call_count)
-        self.assertEqual(0, sleep.call_count)
-
-    @patch('time.sleep')
-    @patch('cauldron.writer.attempt_json_write')
-    def test_write_json_fail(
-            self,
-            attempt_json_write: MagicMock,
-            sleep: MagicMock
-    ):
-        """Should fail to write JSON data to file with retries"""
-
-        attempt_json_write.return_value = IOError('FAKE')
-
-        success, error = writer.write_json_file('fake', {}, retry_count=5)
-        self.assertFalse(success)
-        self.assertIsNotNone(error)
-        self.assertEqual(5, attempt_json_write.call_count)
-        self.assertEqual(5, sleep.call_count)
-
-    def test_attempt_file_write(self):
-        """Should write to file without error"""
-
-        with patch('builtins.open'):
-            result = writer.attempt_file_write('fake', 'fake')
-        self.assertIsNone(result)
-
-    def test_attempt_file_write_failed(self):
-        """Should fail to write to file"""
-
-        with patch('builtins.open') as open_func:
-            open_func.side_effect = IOError('Fake')
-            result = writer.attempt_file_write('fake', 'fake')
-        self.assertIsNotNone(result)
-
-    def test_attempt_json_write(self):
-        """Should write to JSON file without error"""
-
-        with patch('builtins.open'):
-            result = writer.attempt_json_write('fake', {})
-        self.assertIsNone(result)
-
-    def test_attempt_json_write_failed(self):
-        """Should fail to write JSON data to file"""
-
-        with patch('builtins.open') as open_func:
-            open_func.side_effect = IOError('Fake')
-            result = writer.attempt_json_write('fake', {})
-        self.assertIsNotNone(result)
+@patch('time.sleep')
+@patch('cauldron.writer.attempt_file_write')
+def test_write_file(attempt_file_write: MagicMock, sleep: MagicMock):
+    """Should successfully write without retries"""
+    attempt_file_write.return_value = None
+
+    success, error = writer.write_file('fake', 'fake')
+    assert success
+    assert error is None
+    assert 1 == attempt_file_write.call_count
+    assert 0 == sleep.call_count
+
+
+@patch('time.sleep')
+@patch('cauldron.writer.attempt_file_write')
+def test_write_file_fail(attempt_file_write: MagicMock, sleep: MagicMock):
+    """Should fail to write with retries"""
+    attempt_file_write.return_value = IOError('FAKE')
+
+    success, error = writer.write_file('fake', 'fake', retry_count=5)
+    assert not success
+    assert error is not None
+    assert 5 == attempt_file_write.call_count
+    assert 5 == sleep.call_count
+
+
+@patch('time.sleep')
+@patch('cauldron.writer.attempt_json_write')
+def test_write_json(attempt_json_write: MagicMock, sleep: MagicMock):
+    """Should successfully write JSON without retries"""
+    attempt_json_write.return_value = None
+    success, error = writer.write_json_file('fake', {})
+    assert success
+    assert error is None
+    assert 1 == attempt_json_write.call_count
+    assert 0 == sleep.call_count
+
+
+@patch('time.sleep')
+@patch('cauldron.writer.attempt_json_write')
+def test_write_json_fail(attempt_json_write: MagicMock, sleep: MagicMock):
+    """Should fail to write JSON data to file with retries"""
+    attempt_json_write.return_value = IOError('FAKE')
+    success, error = writer.write_json_file('fake', {}, retry_count=5)
+    assert not success
+    assert error is not None
+    assert 5 == attempt_json_write.call_count
+    assert 5 == sleep.call_count
+
+
+def test_attempt_file_write():
+    """Should write to file without error"""
+    with patch('builtins.open'):
+        result = writer.attempt_file_write('fake', 'fake')
+    assert result is None
+
+
+def test_attempt_file_write_failed():
+    """Should fail to write to file"""
+    with patch('builtins.open') as open_func:
+        open_func.side_effect = IOError('Fake')
+        result = writer.attempt_file_write('fake', 'fake')
+    assert result is not None
+
+
+def test_attempt_json_write():
+    """Should write to JSON file without error"""
+    with patch('builtins.open'):
+        result = writer.attempt_json_write('fake', {})
+    assert result is None
+
+
+def test_attempt_json_write_failed():
+    """Should fail to write JSON data to file"""
+    with patch('builtins.open') as open_func:
+        open_func.side_effect = IOError('Fake')
+        result = writer.attempt_json_write('fake', {})
+    assert result is not None
+
+
+def test_write_offset_binary():
+    """Should write binary data correctly to the given offset"""
+    path = tempfile.mkstemp()[-1]
+    try:
+        writer.write_file(path, b'abc', 'wb')
+        writer.write_file(path, b'def', 'ab', offset=1)
+        writer.write_file(path, b'ghi', 'w+b', offset=2)
+        writer.write_file(path, b'jkl', 'a+b', offset=3)
+
+        with open(path, 'rb') as f:
+            contents = f.read()
+    except Exception:  # pragma: no cover
+        raise
+    finally:
+        environ.systems.remove(path, 10)
+
+    assert b'adgjkl' == contents, """
+        Expected the offset argument to adjust how data is written
+        to the file to produce an overlapped result.
+        """
+
+
+def test_write_offset_ascii():
+    """Should write string data correctly to the given offset"""
+    path = tempfile.mkstemp()[-1]
+    try:
+        writer.write_file(path, 'abc', 'w')
+        writer.write_file(path, 'def', 'a', offset=1)
+        writer.write_file(path, '\u00A9hi', 'w', offset=2)
+        writer.write_file(path, '\u0113kl', 'a', offset=4)
+
+        with open(path, 'rb') as f:
+            contents = f.read().decode()
+    except Exception:  # pragma: no cover
+        raise
+    finally:
+        environ.systems.remove(path, 10)
+
+    assert 'ad\u00A9\u0113kl' == contents, """
+        Expected the offset argument to adjust how data is written
+        to the file to produce an overlapped result.
+        """