Repository: octue/twined

Issue:
Add basic unit tests

Discussion:

Patch:
diff --git a/.isort.cfg b/.isort.cfg
new file mode 100644
index 0000000..338936e
--- /dev/null
+++ b/.isort.cfg
@@ -0,0 +1,7 @@
+[settings]
+line_length = 120
+multi_line_output = 3
+include_trailing_comma = False
+known_standard_library = pkg_resources
+known_third_party = jsonschema
+known_first_party = twined
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..1b8da05
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,34 @@
+exclude: 'build|docs|node_modules|.git|.tox|dist|docs|octue.egg-info|twined.egg-info'
+include: 'twined'
+default_stages: [commit]
+fail_fast: true
+default_language_version:
+  python: python3  # force all unspecified python hooks to run python3
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: master
+    hooks:
+      - id: trailing-whitespace
+      - id: end-of-file-fixer
+      - id: check-yaml
+        language_version: python3
+
+  - repo: https://github.com/pre-commit/mirrors-isort
+    rev: v4.3.21
+    hooks:
+      - id: isort
+
+  - repo: https://github.com/psf/black
+    rev: 19.10b0
+    hooks:
+      - id: black
+        args: ['--line-length', '120']
+        language_version: python3
+
+  - repo: https://gitlab.com/pycqa/flake8
+    rev: 3.8.1
+    hooks:
+      - id: flake8
+#        args: ['--config=setup.cfg']
+        additional_dependencies: [flake8-isort]
+        language_version: python3
diff --git a/MANIFEST.in b/MANIFEST.in
index d2e0ec9..f6419e2 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -4,4 +4,3 @@ include twined/schema/children_schema.json
 include twined/schema/manifest_schema.json
 include twined/schema/twine_schema.json
 recursive-include twined *
-recursive-include docs *
diff --git a/README.md b/README.md
index a04a906..d8cdcd3 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,70 @@
 # twined
 
-A library to help digital twins talk to one another. Read more at [twined.readthedocs.io](https://twined.readthedocs.io)
+A library to help digital twins and data services talk to one another. Read more at [twined.readthedocs.io](https://twined.readthedocs.io)
 
+[![Build Status](https://travis-ci.com/octue/twined.svg?branch=master)](https://travis-ci.com/octue/twined)
 [![codecov](https://codecov.io/gh/octue/twined/branch/master/graph/badge.svg)](https://codecov.io/gh/octue/twined)
 [![Documentation Status](https://readthedocs.org/projects/twined/badge/?version=latest)](https://twined.readthedocs.io/en/latest/?badge=latest)
- 
\ No newline at end of file
+[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
+[![black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black)
+
+## Developer notes
+
+**You don't need to pay attention to this unless you plan to develop Twined.**
+
+### Contributing
+
+- Please raise an issue on the board (or add your $0.02 to an existing issue) so the maintainers know
+what's happening and can advise / steer you.
+
+- Create a fork of twined, undertake your changes on a new branch (call it whatever you want). To run tests and make commits,
+you'll need to do something like:
+```
+git clone <your_forked_repo_address>    # fetches the repo to your local machine
+cd twined                               # move into the repo directory
+pyenv virtualenv 3.6.9 twinedenv        # Makes a virtual environment for you to install the dev tools into. Use any python >= 3.6
+pyend activate twinedenv                # Activates the virtual environment so you don't screw up other installations
+pip install -r requirements-dev.txt     # Installs the testing and code formatting utilities
+pre-commit install                      # Installs the pre-commit code formatting hooks in the git repo
+tox                                     # Runs the tests with coverage. NB you can also just set up pycharm or vscode to run these.
+```
+
+- If you don't know what TDD is, go away and find out. We practice TDD here and it'll be glaringly obvious if you don't.
+
+- Ask the `twined` maintainers *where* to make your pull request. We'll create a version branch, according to the
+roadmap, into which you can make your PR. We'll help review the changes and improve the PR.
+
+- Once checks have passed, test coverage of the new code is >=95%, documentation is updated and the Review is passed, we'll merge into the version branch.
+
+- Once all the roadmapped features for that version are done, we'll release.
+
+
+### Release process
+
+The process for creating a new release is as follows:
+
+1. Check out a branch for the next version, called `vX.Y.Z`
+2. Create a Pull Request into the `master` branch.
+3. Undertake your changes, committing and pushing to branch `vX.Y.Z`
+4. Ensure that documentation is updated to match changes, and increment the changelog. **Pull requests which do not update documentation will be refused.**
+5. Ensure that test coverage is sufficient. **Pull requests that decrease test coverage will be refused.**
+6. Ensure code meets style guidelines (pre-commit scripts and flake8 tests will fail otherwise)
+7. Address Review Comments on the PR
+8. Ensure the version in `setup.py` is correct and matches the branch version.
+9. Merge to master. Successful test, doc build, flake8 and a new version number will automatically create the release on pypi.
+10. Go to code > releases and create a new release on GitHub at the same SHA.
+
+
+### Building documents locally
+
+Install `doxgen`. On a mac, that's `brew install doxygen`; other systems may differ.
+
+Install sphinx and other requirements for building the docs:
+```
+pip install -r docs/requirements.txt
+```
+
+Run the build process:
+```
+sphinx-build -b html docs/source docs/build
+```
diff --git a/docs/source/examples.rst b/docs/source/examples.rst
index 7bd0957..4434d73 100644
--- a/docs/source/examples.rst
+++ b/docs/source/examples.rst
@@ -96,3 +96,4 @@ copied straight from the unit test cases, so you can always check there to see h
                  }
              }
          }
+
diff --git a/docs/source/schema.rst b/docs/source/schema.rst
index 278c07e..f821595 100644
--- a/docs/source/schema.rst
+++ b/docs/source/schema.rst
@@ -7,7 +7,7 @@ About Twines (Schema)
 The core of **twined** is to provide and use schemas for digital twins.
 
 Below, we set out requirements and a framework for creating a *schema* to represent a digital twin.
-We call these schema "twines". To just get started building a **twine**, check out the :ref:`_quick_start`.
+We call these schema "twines". To just get started building a **twine**, check out the :ref:`quick_start`.
 
 
 .. _requirements:
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..109c3be
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,12 @@
+
+# Testing
+# ------------------------------------------------------------------------------
+tox
+pluggy
+
+# Code quality
+# ------------------------------------------------------------------------------
+flake8==3.8.3  # https://github.com/PyCQA/flake8
+flake8-isort==3.0.0  # https://github.com/gforcada/flake8-isort
+black==19.10.b0  # https://github.com/ambv/black
+pre-commit  # https://github.com/pre-commit/pre-commit
diff --git a/setup.py b/setup.py
index 6ffcd26..75971a8 100644
--- a/setup.py
+++ b/setup.py
@@ -1,4 +1,4 @@
-from setuptools import setup, find_packages
+from setuptools import find_packages, setup
 
 # Note:
 #   The Hitchiker's guide to python provides an excellent, standard, method for creating python packages:
@@ -7,34 +7,34 @@ from setuptools import setup, find_packages
 #   To deploy on PYPI follow the instructions at the bottom of:
 #       https://packaging.python.org/tutorials/distributing-packages/#uploading-your-project-to-pypi
 
-with open('README.md') as f:
+with open("README.md") as f:
     readme_text = f.read()
 
-with open('LICENSE') as f:
+with open("LICENSE") as f:
     license_text = f.read()
 
 setup(
-    name='twined',
-    version='0.0.8',
+    name="twined",
+    version="0.0.9",
     py_modules=[],
-    install_requires=['jsonschema ~= 3.2.0'],
-    url='https://www.github.com/octue/twined',
+    install_requires=["jsonschema ~= 3.2.0"],
+    url="https://www.github.com/octue/twined",
     license=license_text,
-    author='Octue (github: octue)',
-    description='A library to help digital twins talk to one another.',
+    author="Octue (github: octue)",
+    description="A library to help digital twins talk to one another.",
     long_description=readme_text,
     long_description_content_type="text/markdown",
-    packages=find_packages(exclude=('tests', 'docs')),
+    packages=find_packages(exclude=("tests", "docs")),
     include_package_data=True,
     classifiers=[
-        'Development Status :: 4 - Beta',
-        'Intended Audience :: Developers',
-        'Topic :: Software Development :: Libraries :: Python Modules',
-        'License :: OSI Approved :: MIT License',
-        'Programming Language :: Python :: 3.6',
-        'Programming Language :: Python :: 3.7',
-        'Operating System :: OS Independent'
+        "Development Status :: 4 - Beta",
+        "Intended Audience :: Developers",
+        "Topic :: Software Development :: Libraries :: Python Modules",
+        "License :: OSI Approved :: MIT License",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Operating System :: OS Independent",
     ],
-    python_requires='>=3.6',
-    keywords=['digital', 'twins', 'python', 'schema']
+    python_requires=">=3.6",
+    keywords=["digital", "twins", "python", "schema"],
 )
diff --git a/tox.ini b/tox.ini
index 772027b..f204def 100644
--- a/tox.ini
+++ b/tox.ini
@@ -9,7 +9,7 @@ commands =
     coverage report --show-missing
 deps =
     jsonschema==3.0.2
-    coverage==5.0.2
+    coverage==4.5.4
 
 [testenv:py36-flake8]
 commands = flake8 .
diff --git a/twined/exceptions.py b/twined/exceptions.py
index 06361f2..184bde7 100644
--- a/twined/exceptions.py
+++ b/twined/exceptions.py
@@ -1,49 +1,158 @@
+from jsonschema import ValidationError
 
 
 class TwineException(Exception):
-    """ All exceptions raised by the twine framework inherits from TwineException"""
+    """ All exceptions raised by the twine framework inherit from TwineException
+    """
+
+
+class NotImplementedYet(TwineException):
+    """ Raised when you attempt to use a function whose high-level API is in place, but which is not implemented yet
+    """
+
+
+class TwineTypeException(TwineException, TypeError):
+    """ Raised when a python TypeError is appropriate to ensure all errors still also inherit from TwineException
+    """
+
+
+class TwineVersionConflict(TwineException):
+    """ Raised when the (optional) "twined_version" field in the twine file does not match the current installed version of twined
+    """
+
+
+# --------------------- Exceptions relating to the twine itself ------------------------
 
 
 class InvalidTwine(TwineException):
-    """ Raised when the specified twine is invalid
+    """ Raised when the specified twine is invalid for some reason
     """
 
 
-class MissingTwine(TwineException):
-    """ Raised when the specified twine file is not present
+class InvalidTwineJson(InvalidTwine):
+    """ Raised when the JSON in the twine file is broken
     """
 
 
-class InvalidInput(TwineException):
-    """ Raised when an object is instantiated or a function called with invalid inputs
+class InvalidTwineContents(InvalidTwine, ValidationError):
+    """ Raised when the JSON in the twine file is not valid (eg doesn't match twine schema)
     """
 
 
-class FolderNotPresent(InvalidInput):
+# --------------------- Exceptions relating to missing files/folders ------------------------
+
+
+class FolderNotFound(TwineException):
     """ Raised when a required folder (e.g. <data_dir>/input) cannot be found
     """
 
 
-class ManifestNotFound(InvalidInput):
-    """ Raised when a multi manifest can not be refined to a single manifest in a search
+class TwineFileNotFound(TwineException, FileNotFoundError):
+    """ Raised when the specified twine file is not present
+    """
+
+
+class ConfigFileNotFound(TwineException, FileNotFoundError):
+    """ Raised when the config.json file is not present in the input directory
+    """
+
+
+class InputManifestFileNotFound(TwineException, FileNotFoundError):
+    """ Raised when an input manifest file is required by a twine, but is not present in the input directory
+    """
+
+
+class InputValuesFileNotFound(TwineException, FileNotFoundError):
+    """ Raised when attempting to read input values from a file that is missing
     """
 
 
-class InvalidManifest(InvalidInput):
+class OutputManifestFileNotFound(TwineException, FileNotFoundError):
+    """ Raised when twined checks that output manifest file has been produced, but it is not present in the output directory
+    """
+
+
+class OutputValuesFileNotFound(TwineException, FileNotFoundError):
+    """ Raised when attempting to read output values from a file that is missing
+    """
+
+
+# --------------------- Exceptions relating to validation of JSON data (input, output, config values) ------------------
+
+
+class InvalidValues(TwineException):
+    """ Raised when JSON data (like Config data, Input Values or Output Values) is invalid
+    """
+
+
+class InvalidValuesJson(InvalidValues):
+    """ Raised when the JSON in the file or string is broken so cannot be parsed
+    """
+
+
+class InvalidValuesContents(InvalidValues, ValidationError):
+    """ Raised when the JSON in the file is not valid according to its matching schema.
+    """
+
+
+# --------------------- Exceptions relating to validation of manifests ------------------------
+
+
+class InvalidManifest(TwineException):
     """ Raised when a manifest loaded from JSON does not pass validation
     """
 
 
+class InvalidManifestJson(InvalidManifest):
+    """ Raised when the json in the manifest file is broken
+    """
+
+
 class InvalidManifestType(InvalidManifest):
-    """ Raised when user attempts to create a manifest of a type other than 'input', 'output' or 'build'
+    """ Raised when user attempts to create a manifest of an invalid type
     """
 
 
-class NotImplementedYet(TwineException):
-    """ Raised when you attempt to use a function whose high-level API is in place, but which is not implemented yet
+class InvalidManifestContents(InvalidManifest, ValidationError):
+    """ Raised when the manifest files are missing or do not match tags, sequences, clusters, extensions etc as required
     """
 
 
+# --------------------- Exceptions relating to access of data using the Twine instance ------------------------
+
+# TODO This is related to filtering files from a manifest. Determine whether this belongs here,
+#  or whether we should port the filtering code across from the SDK.
 class UnexpectedNumberOfResults(TwineException):
     """ Raise when searching for a single data file (or a particular number of data files) and the number of results exceeds that expected
     """
+
+
+# --------------------- Maps allowing customised exceptions per-strand (simplifies code elsewhere) ------------------
+
+
+file_not_found_map = {
+    "twine": TwineFileNotFound,
+    "configuration": ConfigFileNotFound,
+    "input_values": InputValuesFileNotFound,
+    "output_values": OutputValuesFileNotFound,
+}
+
+# TODO Specialised per-strand exceptions to help drill to the root of the issues
+invalid_json_map = {
+    "twine": InvalidTwineJson,
+    "configuration": InvalidValuesJson,
+    "input_values": InvalidValuesJson,
+    "output_values": InvalidValuesJson,
+    "input_manifest": InvalidManifestJson,
+    "output_manifest": InvalidManifestJson,
+}
+
+# TODO Specialised per-strand exceptions to help drill to the root of the issues
+invalid_contents_map = {
+    "twine": InvalidTwineContents,
+    "configuration": InvalidValuesContents,
+    "input_values": InvalidValuesContents,
+    "output_values": InvalidValuesContents,
+    "input_manifest": InvalidManifestContents,
+    "output_manifest": InvalidManifestContents,
+}
diff --git a/twined/twine.py b/twined/twine.py
index 1d5c752..7e387fd 100644
--- a/twined/twine.py
+++ b/twined/twine.py
@@ -1,15 +1,39 @@
 import json as jsonlib
 import logging
 import pkg_resources
-from . import exceptions
-from jsonschema import validate as jsonschema_validate, ValidationError
 
+from jsonschema import ValidationError
+from jsonschema import validate as jsonschema_validate
+
+from . import exceptions
 
 logger = logging.getLogger(__name__)
 
 
-class Twine:
+SCHEMA_STRANDS = (
+    "input_values",
+    "configuration",
+    "output_values",
+)
+
+MANIFEST_STRANDS = (
+    "input_manifest",
+    "output_manifest",
+)
+
+CREDENTIAL_STRANDS = ("credentials",)
 
+CHILDREN_STRANDS = ("children",)
+
+ALL_STRANDS = (
+    *SCHEMA_STRANDS,
+    *MANIFEST_STRANDS,
+    *CREDENTIAL_STRANDS,
+    *CHILDREN_STRANDS,
+)
+
+
+class Twine:
     def __init__(self, **kwargs):
         """ Instantiate a twine class, providing a file name or a utf-8 encoded string containing valid json.
         The twine is itself validated to be correct against the twine schema.
@@ -21,60 +45,116 @@ class Twine:
         self._load_twine(**kwargs)
 
     def _load_twine(self, file=None, json=None):
-        """ Load twine from a *.json file or a json string and validate its contents
+        """ Load twine from a *.json file or a json string and validates twine contents
         """
 
-        # Default twine with nothing in it
         if (file is None) and (json is None):
+            # If loading an unspecified twine, return an empty one rather than raising error (like in _load_data())
             self._raw = {}
-            logger.warning('No twine file specified. Loading empty twine.')
-            return
+            logger.warning("No twine file specified. Loading empty twine.")
+        else:
+            self._raw = self._load_json("twine", file=file, json=json)
+
+        self._validate_against_schema("twine", self._raw)
+        self._validate_twine_version()
+
+    def _load_json(self, kind, file=None, json=None):
+        """ Loads data from either a *.json file or a json string
+        """
+
+        if (file is None) and (json is None):
+            raise exceptions.TwineTypeException(f"Cannot load {kind} - no file name or json string specified")
 
         # Decode the json string and deserialize to objects
         try:
             # From the file...
             if file is not None:
                 if json is not None:
-                    raise exceptions.InvalidInput('You cannot specify both file and json inputs')
+                    raise exceptions.TwineTypeException("You cannot specify both file and json inputs")
 
                 try:
                     with open(file) as f:
-                        self._raw = jsonlib.load(f)
-                        logger.debug('Loaded twine from file %s', file)
+                        data = jsonlib.load(f)
+                        logger.debug("Loaded %s from file %s", kind, file)
                 except FileNotFoundError as e:
-                    raise exceptions.MissingTwine(e)
+                    raise exceptions.file_not_found_map[kind](e)
 
             # Directly from the string...
             else:
-                self._raw = jsonlib.loads(json)
-                logger.debug('Loaded twine from input json string')
-
-            self._validate_twine()
+                data = jsonlib.loads(json)
+                logger.debug("Loaded %s from input json string", kind)
 
         except jsonlib.decoder.JSONDecodeError as e:
-            raise exceptions.InvalidTwine(e)
+            raise exceptions.invalid_json_map[kind](e)
 
-    def _validate_twine(self):
-        """ Validate that the loaded twine contains all required parts and that each part is valid.
+        return data
 
-         A twine *contains* schema, but we also need to verify that it matches a certain schema itself.
+    def _validate_against_schema(self, strand, data):
+        """ Validates data against a schema, raises exceptions of type Invalid<strand>Json if not compliant.
 
+        Can be used to validate:
+            - values data for compliance with schema (for schema based strands) or
+            - the twine file contents itself against the present version twine spec
         """
-        twine_schema = jsonlib.loads(pkg_resources.resource_string('twined', 'schema/twine_schema.json'))
+        if strand == "twine":
+            # A twine *contains* schema, but we also need to verify that it matches a certain schema itself
+            # The twine schema is distributed with this packaged to ensure version consistency...
+            schema = jsonlib.loads(pkg_resources.resource_string("twined", "schema/twine_schema.json"))
+        else:
+            if strand not in SCHEMA_STRANDS:
+                raise exceptions.TwineTypeException(f"Unknown strand {strand}. Try one of {SCHEMA_STRANDS}.")
+            schema_key = strand + "_schema"
+            schema = self._raw[schema_key]
 
         try:
-            jsonschema_validate(instance=self._raw, schema=twine_schema)
-            logger.debug('Validated raw twine against schema')
+            jsonschema_validate(instance=data, schema=schema)
+            logger.debug("Validated %s against schema", strand)
 
         except ValidationError as e:
-            raise exceptions.InvalidTwine(e.message)
-
-    def validate(
-        self,
-        configuration=None,
-        manifest=None,
-        credentials=None,
-        monitors=None,
-        logs=None,
-    ):
-        pass
+            raise exceptions.invalid_contents_map[strand](e.message)
+
+    def _validate_twine_version(self):
+        """ Validates that the installed version is consistent with an optional version specification in the twine file
+        """
+        installed_twined_version = pkg_resources.get_distribution("twined").version
+        twine_file_twined_version = self._raw.get("twined_version", None)
+        logger.debug(
+            "Twine versions... %s installed, %s specified in twine", installed_twined_version, twine_file_twined_version
+        )
+        if (twine_file_twined_version is not None) and (installed_twined_version != twine_file_twined_version):
+            raise exceptions.TwineVersionConflict(
+                f"Twined library version conflict. Twine file requires {twine_file_twined_version} but you have {installed_twined_version} installed"
+            )
+
+    def validate_configuration(self, **kwargs):
+        """ Validates that the configuration values, passed as either a file or a json string, are correct
+        """
+        config = self._load_json("configuration", **kwargs)
+        self._validate_against_schema("configuration", config)
+        return config
+
+    def validate_input_values(self, **kwargs):
+        """ Validates that the input values, passed as either a file or a json string, are correct
+        """
+        data = self._load_json("input_values", **kwargs)
+        self._validate_against_schema("input_values", data)
+        return data
+
+    def validate_output_values(self, **kwargs):
+        """ Validates that the output values, passed as either a file or a json string, are correct
+        """
+        data = self._load_json("output_values", **kwargs)
+        self._validate_against_schema("output_values", data)
+        return data
+
+    # def validate(
+    #     self,
+    #     configuration=None,
+    #     manifest=None,
+    #     credentials=None,
+    #     monitors=None,
+    #     logs=None,
+    # ):
+    #     """ Validates that inputs to an application are correct
+    #     """
+    #     pass


Test patch:
diff --git a/tests/base.py b/tests/base.py
index 5e8fa1b..d0f8b1e 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -8,5 +8,5 @@ class BaseTestCase(unittest.TestCase):
     """
 
     def setUp(self):
-        self.path = str(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', ''))
+        self.path = str(os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", ""))
         super().setUp()
diff --git a/tests/data/empty_app/twine.json b/tests/data/apps/empty_app/twine.json
similarity index 100%
rename from tests/data/empty_app/twine.json
rename to tests/data/apps/empty_app/twine.json
diff --git a/tests/data/example_app/input/config.json b/tests/data/apps/example_app/input/config.json
similarity index 92%
rename from tests/data/example_app/input/config.json
rename to tests/data/apps/example_app/input/config.json
index 9a2c60b..7b5c018 100644
--- a/tests/data/example_app/input/config.json
+++ b/tests/data/apps/example_app/input/config.json
@@ -1,3 +1,3 @@
 {
   "n_iterations": 16
-}
\ No newline at end of file
+}
diff --git a/tests/data/example_app/input/manifest.json b/tests/data/apps/example_app/input/manifest.json
similarity index 100%
rename from tests/data/example_app/input/manifest.json
rename to tests/data/apps/example_app/input/manifest.json
diff --git a/tests/data/example_app/twine.json b/tests/data/apps/example_app/twine.json
similarity index 100%
rename from tests/data/example_app/twine.json
rename to tests/data/apps/example_app/twine.json
diff --git a/tests/data/simple_app/input/config.json b/tests/data/apps/simple_app/input/config.json
similarity index 98%
rename from tests/data/simple_app/input/config.json
rename to tests/data/apps/simple_app/input/config.json
index 59a6167..3a97ba3 100644
--- a/tests/data/simple_app/input/config.json
+++ b/tests/data/apps/simple_app/input/config.json
@@ -6,4 +6,4 @@
   "x_range": [-1.5, 0.6],
   "y_range": [-1.26, 1.26],
   "type": "png"
-}
\ No newline at end of file
+}
diff --git a/tests/data/simple_app/twine.json b/tests/data/apps/simple_app/twine.json
similarity index 100%
rename from tests/data/simple_app/twine.json
rename to tests/data/apps/simple_app/twine.json
diff --git a/tests/data/configurations/empty.json b/tests/data/configurations/empty.json
new file mode 100644
index 0000000..e69de29
diff --git a/tests/data/configurations/incorrect.json b/tests/data/configurations/incorrect.json
new file mode 100644
index 0000000..62b2516
--- /dev/null
+++ b/tests/data/configurations/incorrect.json
@@ -0,0 +1,3 @@
+{
+  "n_iterations": "should not be a string, this field requires an integer"
+}
diff --git a/tests/data/configurations/valid.json b/tests/data/configurations/valid.json
new file mode 100644
index 0000000..2133c61
--- /dev/null
+++ b/tests/data/configurations/valid.json
@@ -0,0 +1,3 @@
+{
+  "n_iterations": 1
+}
diff --git a/tests/data/configurations/valid_with_extra.json b/tests/data/configurations/valid_with_extra.json
new file mode 100644
index 0000000..c8219bf
--- /dev/null
+++ b/tests/data/configurations/valid_with_extra.json
@@ -0,0 +1,4 @@
+{
+  "n_iterations": 1,
+  "another_field": "may or may not be quietly ignored"
+}
diff --git a/tests/data/inputs/missing_required.json b/tests/data/inputs/missing_required.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/data/inputs/missing_required.json
@@ -0,0 +1,1 @@
+{}
diff --git a/tests/data/inputs/valid.json b/tests/data/inputs/valid.json
new file mode 100644
index 0000000..a824857
--- /dev/null
+++ b/tests/data/inputs/valid.json
@@ -0,0 +1,3 @@
+{
+  "height": 40
+}
diff --git a/tests/data/outputs/missing_not_required.json b/tests/data/outputs/missing_not_required.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/tests/data/outputs/missing_not_required.json
@@ -0,0 +1,1 @@
+{}
diff --git a/tests/data/outputs/valid.json b/tests/data/outputs/valid.json
new file mode 100644
index 0000000..cd42907
--- /dev/null
+++ b/tests/data/outputs/valid.json
@@ -0,0 +1,3 @@
+{
+  "width": 36
+}
diff --git a/tests/data/twines/incorrect_version_twine.json b/tests/data/twines/incorrect_version_twine.json
new file mode 100644
index 0000000..cb00ff7
--- /dev/null
+++ b/tests/data/twines/incorrect_version_twine.json
@@ -0,0 +1,3 @@
+{
+	"twined_version": "0.0.0"
+}
diff --git a/tests/data/twines/valid_schema_twine.json b/tests/data/twines/valid_schema_twine.json
new file mode 100644
index 0000000..9051d7a
--- /dev/null
+++ b/tests/data/twines/valid_schema_twine.json
@@ -0,0 +1,43 @@
+{
+	"configuration_schema": {
+		"$schema": "http://json-schema.org/2019-09/schema#",
+		"title": "The example configuration form",
+		"description": "The configuration strand of an example twine",
+		"type": "object",
+		"properties": {
+			"n_iterations": {
+				"description": "An example of an integer configuration variable, called 'n_iterations'.",
+				"type": "integer",
+				"minimum": 1,
+				"maximum": 10,
+				"default": 5
+			}
+		}
+	},
+	"input_values_schema": {
+		"$schema": "http://json-schema.org/2019-09/schema#",
+		"title": "Input Values",
+		"description": "The input values strand of an example twine, with a required height value",
+		"type": "object",
+		"properties": {
+			"height": {
+				"description": "An example of an integer value called 'height'",
+				"type": "integer",
+				"minimum": 2
+			}
+		},
+		"required": ["height"]
+	},
+	"output_values_schema": {
+		"title": "Output Values",
+		"description": "The output values strand of an example twine",
+		"type": "object",
+		"properties": {
+			"width": {
+				"description": "An example of an integer value called 'result'",
+				"type": "integer",
+				"minimum": 2
+			}
+		}
+	}
+}
diff --git a/tests/test_children.py b/tests/test_children.py
index 455c532..eea79da 100644
--- a/tests/test_children.py
+++ b/tests/test_children.py
@@ -1,5 +1,7 @@
 import unittest
+
 from twined import Twine, exceptions
+
 from .base import BaseTestCase
 
 
@@ -12,7 +14,7 @@ class TestChildrenTwine(BaseTestCase):
         """ Ensures InvalidTwine exceptions are raised when instantiating twines where `children` entry is incorrectly
         specified as a dict, not an array
         """
-        twine_file = self.path + 'twines/invalid_children_dict_not_array_twine.json'
+        twine_file = self.path + "twines/invalid_children_dict_not_array_twine.json"
         with self.assertRaises(exceptions.InvalidTwine):
             Twine(file=twine_file)
 
@@ -20,16 +22,16 @@ class TestChildrenTwine(BaseTestCase):
         """ Ensures InvalidTwine exceptions are raised when instantiating twines where a child
         is specified without the required `key` field
         """
-        twine_file = self.path + 'twines/invalid_children_no_key_twine.json'
+        twine_file = self.path + "twines/invalid_children_no_key_twine.json"
         with self.assertRaises(exceptions.InvalidTwine):
             Twine(file=twine_file)
 
     def test_valid_children(self):
         """ Ensures that a twine can be instantiated with correctly specified children
         """
-        twine_file = self.path + 'twines/valid_children_twine.json'
+        twine_file = self.path + "twines/valid_children_twine.json"
         twine = Twine(file=twine_file)
-        self.assertEqual(len(twine._raw['children']), 1)
+        self.assertEqual(len(twine._raw["children"]), 1)
 
 
 # class TestChildrenValidation(unittest.TestCase):
@@ -57,5 +59,5 @@ class TestChildrenTwine(BaseTestCase):
 #         raise exceptions.NotImplementedYet()
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_credentials.py b/tests/test_credentials.py
index eafc0f4..dbb1de5 100644
--- a/tests/test_credentials.py
+++ b/tests/test_credentials.py
@@ -1,5 +1,7 @@
 import unittest
+
 from twined import Twine, exceptions
+
 from .base import BaseTestCase
 
 
@@ -12,7 +14,7 @@ class TestCredentialsTwine(BaseTestCase):
         """ Ensures InvalidTwine exceptions are raised when instantiating twines
         with a missing `name` field in a credential
         """
-        twine_file = self.path + 'twines/invalid_credentials_no_name_twine.json'
+        twine_file = self.path + "twines/invalid_credentials_no_name_twine.json"
         with self.assertRaises(exceptions.InvalidTwine):
             Twine(file=twine_file)
 
@@ -20,7 +22,7 @@ class TestCredentialsTwine(BaseTestCase):
         """ Ensures InvalidTwine exceptions are raised when instantiating twines
         with lowercase letters in the `name` field
         """
-        twine_file = self.path + 'twines/invalid_credentials_lowercase_name_twine.json'
+        twine_file = self.path + "twines/invalid_credentials_lowercase_name_twine.json"
         with self.assertRaises(exceptions.InvalidTwine):
             Twine(file=twine_file)
 
@@ -28,12 +30,12 @@ class TestCredentialsTwine(BaseTestCase):
         """ Ensures InvalidTwine exceptions are raised when instantiating twines
         with invalid `credentials` entries (given as a dict, not an array)
         """
-        twine_file = self.path + 'twines/invalid_credentials_dict_not_array_twine.json'
+        twine_file = self.path + "twines/invalid_credentials_dict_not_array_twine.json"
         with self.assertRaises(exceptions.InvalidTwine):
             Twine(file=twine_file)
 
     def test_fails_on_name_whitespace(self):
-        twine_file = self.path + 'twines/invalid_credentials_space_in_name_twine.json'
+        twine_file = self.path + "twines/invalid_credentials_space_in_name_twine.json"
         with self.assertRaises(exceptions.InvalidTwine):
             Twine(file=twine_file)
 
@@ -59,5 +61,5 @@ class TestCredentialsTwine(BaseTestCase):
 #         raise exceptions.NotImplementedYet()
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_schema_strands.py b/tests/test_schema_strands.py
new file mode 100644
index 0000000..f40281f
--- /dev/null
+++ b/tests/test_schema_strands.py
@@ -0,0 +1,112 @@
+import unittest
+
+from twined import Twine, exceptions
+
+from .base import BaseTestCase
+
+
+class TestSchemaStrands(BaseTestCase):
+    """ Testing operation of the Twine class for validation of data using strands which contain schema
+     """
+
+    def test_cannot_load_with_no_file_or_json(self):
+        """ Ensures the correct exception is thrown when a values file is unspecified
+        """
+
+        twine_file = self.path + "twines/valid_schema_twine.json"
+        twine = Twine(file=twine_file)
+        with self.assertRaises(exceptions.TwineTypeException):
+            twine.validate_configuration()
+
+    def test_invalid_strand(self):
+        """ Ensures that an incorrect strand name would lead to the correct exception
+        Note: This tests an internal method. The current API doesn't allow this error to emerge but tthis check allows
+        us to extend to a generic method
+        """
+        twine_file = self.path + "twines/valid_schema_twine.json"
+        twine = Twine(file=twine_file)
+        values_file = self.path + "configurations/valid.json"
+        data = twine._load_json("configuration", file=values_file)
+        with self.assertRaises(exceptions.TwineTypeException):
+            twine._validate_against_schema("not_a_strand_name", data)
+
+    def test_missing_values_files(self):
+        """ Ensures that if you try to read values from missing files, the right exceptions get raised
+        """
+        twine_file = self.path + "twines/valid_schema_twine.json"
+        twine = Twine(file=twine_file)
+        values_file = self.path + "not_a_file.json"
+        with self.assertRaises(exceptions.ConfigFileNotFound):
+            twine.validate_configuration(file=values_file)
+
+        with self.assertRaises(exceptions.InputValuesFileNotFound):
+            twine.validate_input_values(file=values_file)
+
+        with self.assertRaises(exceptions.OutputValuesFileNotFound):
+            twine.validate_output_values(file=values_file)
+
+    def test_empty_values(self):
+        """ Ensures that appropriate errors are generated for invalid values
+        """
+        twine_file = self.path + "twines/valid_schema_twine.json"
+        twine = Twine(file=twine_file)
+        values_file = self.path + "configurations/empty.json"
+        with self.assertRaises(exceptions.InvalidValuesJson):
+            twine.validate_configuration(file=values_file)
+
+    def test_incorrect_values(self):
+        """ Ensures that appropriate errors are generated for invalid values
+        """
+        twine_file = self.path + "twines/valid_schema_twine.json"
+        twine = Twine(file=twine_file)
+        values_file = self.path + "configurations/incorrect.json"
+        with self.assertRaises(exceptions.InvalidValuesContents):
+            twine.validate_configuration(file=values_file)
+
+    def test_missing_not_required_values(self):
+        """ Ensures that appropriate errors are generated for missing values
+        """
+        twine_file = self.path + "twines/valid_schema_twine.json"
+        twine = Twine(file=twine_file)
+        values_file = self.path + "outputs/missing_not_required.json"
+        twine.validate_output_values(file=values_file)
+
+    def test_missing_required_values(self):
+        """ Ensures that appropriate errors are generated for missing values
+        """
+        twine_file = self.path + "twines/valid_schema_twine.json"
+        twine = Twine(file=twine_file)
+        values_file = self.path + "inputs/missing_required.json"
+        with self.assertRaises(exceptions.InvalidValuesContents):
+            twine.validate_input_values(file=values_file)
+
+    def test_valid_values_files(self):
+        """ Ensures that values can be read and validated correctly from files on disk
+        """
+        twine_file = self.path + "twines/valid_schema_twine.json"
+        twine = Twine(file=twine_file)
+        twine.validate_configuration(file=self.path + "configurations/valid.json")
+        twine.validate_input_values(file=self.path + "inputs/valid.json")
+        twine.validate_output_values(file=self.path + "outputs/valid.json")
+
+    def test_valid_values_json(self):
+        """ Ensures that values can be read and validated correctly from a json string
+        """
+        twine_file = self.path + "twines/valid_schema_twine.json"
+        twine = Twine(file=twine_file)
+        values_file = self.path + "configurations/valid.json"
+        with open(values_file, "r", encoding="utf-8") as f:
+            json_string = f.read()
+        twine.validate_configuration(json=json_string)
+
+    def test_valid_with_extra_values(self):
+        """ Ensures that extra values get ignored
+        """
+        twine_file = self.path + "twines/valid_schema_twine.json"
+        twine = Twine(file=twine_file)
+        values_file = self.path + "configurations/valid_with_extra.json"
+        twine.validate_configuration(file=values_file)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_twine.py b/tests/test_twine.py
index 90c4da6..3d93648 100644
--- a/tests/test_twine.py
+++ b/tests/test_twine.py
@@ -1,5 +1,7 @@
 import unittest
+
 from twined import Twine, exceptions
+
 from .base import BaseTestCase
 
 
@@ -10,77 +12,81 @@ class TestTwine(BaseTestCase):
     def test_init_twine_with_filename(self):
         """ Ensures that the twine class can be instantiated with a file
         """
-        twine_file = self.path + 'simple_app/twine.json'
+        twine_file = self.path + "apps/simple_app/twine.json"
         Twine(file=twine_file)
 
     def test_init_twine_with_json(self):
         """ Ensures that a twine can be instantiated with a json string
         """
-        with open(self.path + 'simple_app/twine.json', 'r', encoding='utf-8') as f:
+        with open(self.path + "apps/simple_app/twine.json", "r", encoding="utf-8") as f:
             json_string = f.read()
         Twine(json=json_string)
 
     def test_init_twine_with_incorrect_file_string(self):
         """ Ensures that error is raised instantiating with a non-filename
         """
-        with self.assertRaises(exceptions.MissingTwine):
+        with self.assertRaises(exceptions.TwineFileNotFound):
             Twine(file='{"mistakenly_passed": "json instead of filename"}')
 
     def test_init_twine_with_both_inputs(self):
         """ Ensures that error is raised when attempting to instantiate with both file and json inputs
         """
-        with self.assertRaises(exceptions.InvalidInput):
-            Twine(
-                file=self.path + 'simple_app/twine.json',
-                json='{"input_values": "something"}'
-            )
+        with self.assertRaises(exceptions.TwineTypeException):
+            Twine(file=self.path + "apps/simple_app/twine.json", json='{"input_values": "something"}')
 
     def test_missing_twine_file(self):
         """ Ensures that an absent file raises a MissingTwine exception
         """
-        twine_file = 'file_is_missing.json'
-        with self.assertRaises(exceptions.MissingTwine):
+        twine_file = "file_is_missing.json"
+        with self.assertRaises(exceptions.TwineFileNotFound):
             Twine(file=twine_file)
 
     def test_no_twine(self):
         """ Tests that the canonical-but-useless case of no twine provided validates whilst issuing a warning
         """
-        with self.assertLogs(level='WARNING') as log:
+        with self.assertLogs(level="WARNING") as log:
             Twine()
             self.assertEqual(len(log.output), 1)
             self.assertEqual(len(log.records), 1)
-            self.assertIn('No twine file specified', log.output[0])
+            self.assertIn("No twine file specified", log.output[0])
+
+    def test_incorrect_version_twine(self):
+        """ Ensures exception is thrown on mismatch between installed and specified versions of twined
+        """
+        twine_file = self.path + "twines/incorrect_version_twine.json"
+        with self.assertRaises(exceptions.TwineVersionConflict):
+            Twine(file=twine_file)
 
     def test_empty_twine(self):
         """ Ensures that an empty twine file can be loaded
         """
-        twine_file = self.path + 'empty_app/twine.json'
-        with self.assertLogs(level='DEBUG') as log:
+        twine_file = self.path + "apps/empty_app/twine.json"
+        with self.assertLogs(level="DEBUG") as log:
             Twine(file=twine_file)
-            self.assertEqual(len(log.output), 2)
-            self.assertEqual(len(log.records), 2)
-            self.assertIn('Loaded', log.output[0])
-            self.assertIn('Validated', log.output[1])
+            self.assertEqual(len(log.output), 3)
+            self.assertEqual(len(log.records), 3)
+            self.assertIn("Loaded", log.output[0])
+            self.assertIn("Validated", log.output[1])
 
     def test_example_twine(self):
         """ Ensures that the example (full) twine can be loaded and validated
         """
-        twine_file = self.path + 'example_app/twine.json'
+        twine_file = self.path + "apps/example_app/twine.json"
         Twine(file=twine_file)
 
     def test_simple_twine(self):
         """ Ensures that the simple app schema can be loaded and used to parse some basic config and values data
         """
-        twine_file = self.path + 'simple_app/twine.json'
+        twine_file = self.path + "apps/simple_app/twine.json"
         Twine(file=twine_file)
 
     def test_broken_json_twine(self):
         """ Ensures that an invalid json file raises an InvalidTwine exception
         """
-        twine_file = self.path + 'twines/invalid_json_twine.json'
-        with self.assertRaises(exceptions.InvalidTwine):
+        twine_file = self.path + "twines/invalid_json_twine.json"
+        with self.assertRaises(exceptions.InvalidTwineJson):
             Twine(file=twine_file)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     unittest.main()