Repository: pypr/automan

Issue:
Add sphinx documentation
This would be a lot more accessible than reading the formal paper. This was also suggested here: https://www.authorea.com/users/99991/articles/307464-review-automan-a-python-based-automation-framework-for-numerical-computing

Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index 28016a7..9e684bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,8 @@
 build/
 dist/
 *.egg-info/
+*.pytest_cache/
+examples/tutorial/config.json
+examples/tutorial/outputs
+examples/tutorial/manuscript
+examples/tutorial/.automan
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..5ce40e1
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,5 @@
+include MANIFEST.in *.py *.rst *.txt *.yml
+recursive-include docs *.*
+recursive-include examples *.py
+recursive-exclude examples/tutorial/.automan *.*
+recursive-exclude docs/build *.*
\ No newline at end of file
diff --git a/automan/automation.py b/automan/automation.py
index 8374ddc..4ed9cf4 100644
--- a/automan/automation.py
+++ b/automan/automation.py
@@ -59,8 +59,7 @@ class TaskRunner(object):
     def __init__(self, tasks, scheduler):
         """Constructor.
 
-        Parameters
-        ----------
+        **Parameters**
 
         tasks: iterable of `Task` instances.
         scheduler: `automan.jobs.Scheduler` instance
@@ -176,8 +175,7 @@ class CommandTask(Task):
     def __init__(self, command, output_dir, job_info=None):
         """Constructor
 
-        Parameters
-        ----------
+        **Parameters**
 
         command: str or list: command to run $output_dir is substituted.
         output_dir: str : path of output directory.
@@ -215,6 +213,9 @@ class CommandTask(Task):
             return self._copy_output_and_check_status()
 
     def run(self, scheduler):
+        # Remove the error status file if it exists and we are going to run.
+        if os.path.exists(self._error_status_file):
+            os.remove(self._error_status_file)
         self.job_proxy = scheduler.submit(self.job)
 
     def clean(self):
@@ -297,8 +298,7 @@ class PySPHTask(CommandTask):
     def __init__(self, command, output_dir, job_info=None):
         """Constructor
 
-        Parameters
-        ----------
+        **Parameters**
 
         command: str or list: command to run $output_dir is substituted.
         output_dir: str : path of output directory.
@@ -363,8 +363,7 @@ class Problem(object):
     def __init__(self, simulation_dir, output_dir):
         """Constructor.
 
-        Parameters
-        ----------
+        **Parameters**
 
         simulation_dir : str : directory where simulation output goes.
         output_dir : str : directory where outputs from `run` go.
@@ -482,8 +481,7 @@ def kwargs_to_command_line(kwargs):
     """Convert a dictionary of keyword arguments to a list of command-line
     options.  If the value of the key is None, no value is passed.
 
-    Examples
-    --------
+    **Examples**
 
     >>> sorted(kwargs_to_command_line(dict(some_arg=1, something_else=None)))
     ['--some-arg=1', '--something-else']
@@ -549,8 +547,7 @@ class Simulation(object):
     def __init__(self, root, base_command, job_info=None, **kw):
         """Constructor
 
-        Parameters
-        ----------
+        **Parameters**
 
         root: str
             Path to simulation output directory.
@@ -617,8 +614,7 @@ def compare_runs(sims, method, labels, exact=None):
     compare and an optional method name for an exact solution, this calls the
     methods with the appropriate parameters for each simulation.
 
-    Parameters
-    ----------
+    **Parameters**
 
     sims: sequence
         Sequence of `Simulation` objects.
@@ -645,11 +641,22 @@ def compare_runs(sims, method, labels, exact=None):
             method(s, label=s.get_labels(labels), **next(ls))
 
 
-def filter_cases(runs, **params):
+def filter_cases(runs, predicate=None, **params):
     """Given a sequence of simulations and any additional parameters, filter
     out all the cases having exactly those parameters and return a list of
     them.
+
+    One may also pass a callable to filter the cases using the `predicate`
+    keyword argument. If this is not a callable, it is treated as a parameter.
+    If `predicate` is passed though, the other keyword arguments are ignored.
+
     """
+    if predicate is not None:
+        if callable(predicate):
+            return list(filter(predicate, runs))
+        else:
+            params['predicate'] = predicate
+
     def _check_match(run):
         for param, expected in params.items():
             if param not in run.params or run.params[param] != expected:
@@ -757,8 +764,8 @@ class Automator(object):
                  cluster_manager_factory=None):
         """Constructor.
 
-        Parameters
-        ----------
+        **Parameters**
+
         simulation_dir : str
             Root directory to generate simulation results in.
         output_dir: str
@@ -788,7 +795,8 @@ class Automator(object):
         self._check_positional_arguments(args.problem)
 
         self.cluster_manager = self.cluster_manager_factory(
-            config_fname=args.config
+            config_fname=args.config,
+            exclude_paths=self._get_exclude_paths()
         )
 
         if len(args.host) > 0:
@@ -821,6 +829,16 @@ class Automator(object):
                     print("Valid names are %s" % ', '.join(names))
                     self.parser.exit(1)
 
+    def _get_exclude_paths(self):
+        """Returns a list of exclude paths suitable for passing on to rsync to exclude
+        syncing some directories on remote machines.
+        """
+        paths = []
+        for path in [self.simulation_dir, self.output_dir]:
+            if not path.endswith('/'):
+                paths.append(path + '/')
+        return paths
+
     def _select_problem_classes(self, problems):
         if problems == 'all':
             return self.all_problems
diff --git a/automan/cluster_manager.py b/automan/cluster_manager.py
index d15877e..ba21b31 100644
--- a/automan/cluster_manager.py
+++ b/automan/cluster_manager.py
@@ -4,6 +4,7 @@ worker to help with the automation of tasks.
 This requires ssh/scp and rsync to work on all machines.
 
 This is currently only tested on Linux machines.
+
 """
 
 import json
@@ -35,22 +36,22 @@ class ClusterManager(object):
 
     The general directory structure of a remote worker machine is as follows::
 
-        remote_home/          # Could be ~
-            automan/          # Root of automation directory (configurable)
-                envs/         # python virtual environments for use.
-                pysph/        # the pysph sources.
-                project/      # Current directory for specific project.
-                other_repos/  # other source repos.
+        remote_home/           # Could be ~
+            automan/           # Root of automation directory (configurable)
+                envs/          # python virtual environments for use.
+                my_project/    # Current directory for specific projects.
+
+    The project directories are synced from this machine to the remote worker.
 
-    The respective directories are synced from this machine to the remote
-    worker.
+    The "my_project" is the root of the directory with the automation script
+    and this should contain the required sources that need to be executed. One
+    can use a list of source directories which will be copied over but it is
+    probably most convenient to put it all in the root of the project directory
+    to keep everything self-contained.
 
-    The idea is that this remote directory contains a full installation of
-    PySPH, the PySPH sources, the current project sources and any other
-    optional directories. The `ClusterManager` class manages these remote
-    workers by helping setup the directories, bootstrapping the Python
-    virtualenv and also keeping these up-to-date as the respective directories
-    are changed on the local machine.
+    The `ClusterManager` class manages these remote workers by helping setup
+    the directories, bootstrapping the Python virtualenv and also keeping these
+    up-to-date as project directory is changed on the local machine.
 
     The class therefore has two primary public methods,
 
@@ -80,34 +81,61 @@ class ClusterManager(object):
 
         set -e
         if hash virtualenv 2>/dev/null; then
-            virtualenv --system-site-packages envs/pysph
+            virtualenv --system-site-packages envs/{project_name}
         else
-            python virtualenv.py --system-site-packages envs/pysph
+            python virtualenv.py --system-site-packages envs/{project_name}
+        fi
+        source envs/{project_name}/bin/activate
+
+        pip install automan
+
+        # Run any requirements.txt from the user
+        cd {project_name}
+        if [ -f "requirements.txt" ] ; then
+            pip install -r requirements.txt
         fi
-        source envs/pysph/bin/activate
-        cd pysph
-        pip install -r requirements.txt
-        pip install automan h5py matplotlib
-        python setup.py develop
-        cd ..
         """)
 
     UPDATE = dedent("""\
          #!/bin/bash
 
          set -e
-         source envs/pysph/bin/activate
-         cd pysph
-         python setup.py develop
+         source envs/{project_name}/bin/activate
+         # Run any requirements.txt from the user
+         cd {project_name}
+         if [ -f "requirements.txt" ] ; then
+             pip install -r requirements.txt
+         fi
          """)
     #######################################################
 
     def __init__(self, root='automan', sources=None,
-                 config_fname='config.json'):
+                 config_fname='config.json', exclude_paths=None):
+        """Create a cluster manager instance.
+
+        **Parameters**
+
+        root: str
+           The name of the root directory where all the files on the remote
+           will be created.
+        sources: list
+           A list of source directories to sync.
+        config_fname: str
+           The name of the config file to create.
+        exclude_paths: list
+           A list of paths to exclude while syncing. This is in a form suitable
+           to pass to rsync.
+        """
         self.root = root
         self.workers = []
         self.sources = sources
         self.scripts_dir = os.path.abspath('.' + self.root)
+        self.exclude_paths = exclude_paths if exclude_paths else []
+
+        # This is setup by the config and is the name of
+        # the project directory.
+        self.project_name = None
+
         # The config file will always trump any direct settings
         # unless there is no config file.
         self.config_fname = config_fname
@@ -120,9 +148,11 @@ class ClusterManager(object):
     def _bootstrap(self, host, home):
         venv_script = self._get_virtualenv()
 
-        cmd = "ssh {host} 'cd {home}; mkdir -p {root}/envs'".format(
-            home=home, host=host, root=self.root
-        )
+        cmd = ("ssh {host} 'cd {home}; mkdir -p {root}/envs'; " +
+               "mkdir -p {root}/{project_name}/.{root}").format(
+                   home=home, host=host, root=self.root,
+                   project_name=self.project_name
+               )
         self._run_command(cmd)
 
         root = os.path.join(home, self.root)
@@ -133,9 +163,8 @@ class ClusterManager(object):
 
         self._update_sources(host, home)
 
-        cmd = "ssh {host} 'cd {root}; ./bootstrap.sh'".format(
-            host=host, root=root
-        )
+        cmd = "ssh {host} 'cd {root}; ./{project_name}/.{root}/bootstrap.sh'"
+        cmd = cmd.format(host=host, root=root, project_name=self.project_name)
         try:
             self._run_command(cmd)
         except subprocess.CalledProcessError:
@@ -144,7 +173,13 @@ class ClusterManager(object):
             Bootstrapping of remote host {host} failed.
             All files have been copied to the host.
 
-            Please take a look at {root}/bootstrap.sh and try to fix it.
+            Please take a look at
+               {root}/{project_name}/.{root}/bootstrap.sh
+            and try to fix it.
+
+            You should run it from within the {root} directory as:
+
+               ./{project_name}/.{root}/bootstrap.sh
 
             Once the bootstrap.sh script runs successfully, the worker can be
             used without any further steps.
@@ -154,7 +189,8 @@ class ClusterManager(object):
             and can be edited by you. These will be used for any new hosts
             you add.
             ******************************************************************
-            """.format(root=root, host=host, scripts_dir=self.scripts_dir)
+            """.format(root=root, host=host, scripts_dir=self.scripts_dir,
+                       project_name=self.project_name)
             )
             print(msg)
         else:
@@ -175,30 +211,23 @@ class ClusterManager(object):
             with open(self.config_fname) as f:
                 data = json.load(f)
             self.root = data['root']
+            self.project_name = data['project_name']
             self.sources = data['sources']
             self.workers = data['workers']
         else:
             if self.sources is None or len(self.sources) == 0:
                 project_dir = os.path.abspath(os.getcwd())
-                sources = [project_dir]
-                pysph_dir = os.path.expanduser(
-                    prompt("Enter PySPH source directory (empty for none): ")
-                )
-                if len(pysph_dir) > 0 and os.path.exists(pysph_dir):
-                    sources.append(os.path.abspath(pysph_dir))
-                else:
-                    print("Invalid pysph directory, please edit "
-                          "%s." % self.config_fname)
-                self.sources = sources
+                self.project_name = os.path.basename(project_dir)
+                self.sources = [project_dir]
             self.workers = [dict(host='localhost', home='', nfs=False)]
             self._write_config()
         self.scripts_dir = os.path.abspath('.' + self.root)
 
     def _rebuild(self, host, home):
         root = os.path.join(home, self.root)
-        command = "ssh {host} 'cd {root}; ./update.sh'".format(
-            host=host, root=root
-        )
+        command = "ssh {host} 'cd {root}; ./{project_name}/.{root}/update.sh'"
+        command = command.format(host=host, root=root,
+                                 project_name=self.project_name)
         self._run_command(command)
 
     def _run_command(self, cmd, **kw):
@@ -217,6 +246,9 @@ class ClusterManager(object):
                 stdout=subprocess.PIPE
             )
             kwargs['stdin'] = proc.stdout
+        if self.exclude_paths:
+            for path in self.exclude_paths:
+                options += ' --exclude="%s"' % path
 
         command = "rsync -a {options} {src} {host}:{dest} ".format(
             options=options, src=src, host=host, dest=dest
@@ -229,7 +261,10 @@ class ClusterManager(object):
             self._sync_dir(host, local_dir, remote_dir)
 
         scripts_dir = self.scripts_dir
-        scripts = {'bootstrap.sh': self.BOOTSTRAP, 'update.sh': self.UPDATE}
+        bootstrap_code = self.BOOTSTRAP.format(project_name=self.project_name)
+        update_code = self.UPDATE.format(project_name=self.project_name)
+        scripts = {'bootstrap.sh': bootstrap_code,
+                   'update.sh': update_code}
         for script, code in scripts.items():
             fname = os.path.join(scripts_dir, script)
             if not os.path.exists(fname):
@@ -242,7 +277,8 @@ class ClusterManager(object):
             mode = os.stat(fname).st_mode
             os.chmod(fname, mode | stat.S_IXUSR | stat.S_IXGRP)
 
-        path = os.path.join(home, self.root)
+        path = os.path.join(home, self.root, self.project_name,
+                            '.' + self.root)
         cmd = "scp {script_files} {host}:{path}".format(
             host=host, path=path, script_files=' '.join(script_files)
         )
@@ -251,7 +287,10 @@ class ClusterManager(object):
     def _write_config(self):
         print("Writing %s" % self.config_fname)
         data = dict(
-            root=self.root, sources=self.sources, workers=self.workers
+            project_name=self.project_name,
+            root=self.root,
+            sources=self.sources,
+            workers=self.workers
         )
         with open(self.config_fname, 'w') as f:
             json.dump(data, f, indent=2)
@@ -259,7 +298,26 @@ class ClusterManager(object):
     # ### Public Protocol ########################################
 
     def add_worker(self, host, home, nfs):
-        self.workers.append(dict(host=host, home=home, nfs=nfs))
+        if host == 'localhost':
+            self.workers.append(dict(host=host, home=home, nfs=nfs))
+        else:
+            root = self.root
+            curdir = os.path.basename(os.getcwd())
+            if nfs:
+                python = sys.executable
+                chdir = curdir
+            else:
+                python = os.path.join(
+                    home, root,
+                    'envs/{project_name}/bin/python'.format(
+                        project_name=self.project_name
+                    )
+                )
+                chdir = os.path.join(home, root, curdir)
+            self.workers.append(
+                dict(host=host, home=home, nfs=nfs, python=python, chdir=chdir)
+            )
+
         self._write_config()
         if host != 'localhost' and not nfs:
             self._bootstrap(host, home)
@@ -280,22 +338,14 @@ class ClusterManager(object):
         from .jobs import Scheduler
 
         scheduler = Scheduler(root='.')
-
-        root = self.root
         for worker in self.workers:
             host = worker.get('host')
-            home = worker.get('home')
             nfs = worker.get('nfs', False)
             if host == 'localhost':
                 scheduler.add_worker(dict(host='localhost'))
             else:
-                curdir = os.path.basename(os.getcwd())
-                if nfs:
-                    python = sys.executable
-                    chdir = curdir
-                else:
-                    python = os.path.join(home, root, 'envs/pysph/bin/python')
-                    chdir = os.path.join(home, root, curdir)
+                python = worker.get('python')
+                chdir = worker.get('chdir')
                 scheduler.add_worker(
                     dict(host=host, python=python, chdir=chdir, nfs=nfs)
                 )
diff --git a/automan/jobs.py b/automan/jobs.py
index 2b1588f..cce0997 100644
--- a/automan/jobs.py
+++ b/automan/jobs.py
@@ -15,6 +15,13 @@ import time
 import psutil
 
 
+def _make_command_list(command):
+    if not isinstance(command, (list, tuple)):
+        return shlex.split(command)
+    else:
+        return command
+
+
 class Job(object):
     def __init__(self, command, output_dir, n_core=1, n_thread=1, env=None):
         """Constructor
@@ -23,15 +30,10 @@ class Job(object):
         that many free cores. `n_thread` is used to set the `OMP_NUM_THREADS`.
 
         """
-        if not isinstance(command, (list, tuple)):
-            command = shlex.split(command)
-        args = []
-        for x in command:
-            if os.path.basename(x) == 'python':
-                args.append(sys.executable)
-            else:
-                args.append(x)
-        self.command = args
+        self.command = _make_command_list(command)
+        self.orig_command = self.command
+        self.substitute_in_command('python', sys.executable)
+
         self._given_env = env
         self.env = dict(os.environ)
         if env is not None:
@@ -46,6 +48,23 @@ class Job(object):
         self._info_file = os.path.join(self.output_dir, 'job_info.json')
         self.proc = None
 
+    def substitute_in_command(self, basename, substitute):
+        """Replace occurrence of given basename with the substitute.
+
+        This is useful where the user asks to run ['python', 'script.py'].
+        Here, we need to make sure the right python is used. Typically a remote
+        machine will need to use a particular Python and not just the vanilla
+        Python.
+
+        """
+        args = []
+        for arg in self.command:
+            if os.path.basename(arg) == basename:
+                args.append(substitute)
+            else:
+                args.append(arg)
+        self.commands = args
+
     def to_dict(self):
         state = dict()
         for key in ('command', 'output_dir', 'n_core', 'n_thread'):
@@ -54,7 +73,7 @@ class Job(object):
         return state
 
     def pretty_command(self):
-        return ' '.join(self.command)
+        return ' '.join(self.orig_command)
 
     def get_stderr(self):
         return open(self.stderr).read()
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..1fd87fe
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+SPHINXPROJ    = automan
+SOURCEDIR     = source
+BUILDDIR      = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..b61f1d2
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,36 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+set SPHINXPROJ=automan
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.http://sphinx-doc.org/
+	exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 0000000..7391981
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# automan documentation build configuration file, created by
+# sphinx-quickstart on Thu Aug 23 22:12:56 2018.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+# import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+import os
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = ['sphinx.ext.autodoc',
+    'sphinx.ext.mathjax',
+    'sphinx.ext.viewcode']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = 'automan'
+copyright = '2018, Prabhu Ramachandran'
+author = 'Prabhu Ramachandran'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+_d = {}
+fname = os.path.join(os.pardir, os.pardir, 'automan', '__init__.py')
+exec(compile(open(fname).read(), fname, 'exec'), _d)
+
+# The full version, including alpha/beta/rc tags.
+release = _d['__version__']
+
+# The short X.Y version.
+version = release[:3]
+
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = None
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This patterns also effect to html_static_path and html_extra_path
+exclude_patterns = []
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = False
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'sphinx_rtd_theme'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+
+# -- Options for HTMLHelp output ------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'automandoc'
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+    # The paper size ('letterpaper' or 'a4paper').
+    #
+    # 'papersize': 'letterpaper',
+
+    # The font size ('10pt', '11pt' or '12pt').
+    #
+    # 'pointsize': '10pt',
+
+    # Additional stuff for the LaTeX preamble.
+    #
+    # 'preamble': '',
+
+    # Latex figure (float) alignment
+    #
+    # 'figure_align': 'htbp',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+#  author, documentclass [howto, manual, or own class]).
+latex_documents = [
+    (master_doc, 'automan.tex', 'automan Documentation',
+     'Prabhu Ramachandran', 'manual'),
+]
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    (master_doc, 'automan', 'automan Documentation',
+     [author], 1)
+]
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+    (master_doc, 'automan', 'automan Documentation',
+     author, 'automan', 'One line description of project.',
+     'Miscellaneous'),
+]
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 0000000..84db66c
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,31 @@
+.. automan documentation master file, created by
+   sphinx-quickstart on Thu Aug 23 22:12:56 2018.
+   You can adapt this file completely to your liking, but it should at least
+   contain the root `toctree` directive.
+
+Welcome to automan's documentation!
+===================================
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Contents:
+
+   overview.rst
+   tutorial.rst
+
+************************
+Reference documentation
+************************
+
+
+.. toctree::
+   :maxdepth: 2
+
+   reference/index.rst
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/docs/source/overview.rst b/docs/source/overview.rst
new file mode 100644
index 0000000..1c7efe6
--- /dev/null
+++ b/docs/source/overview.rst
@@ -0,0 +1,61 @@
+Overview
+==========
+
+``automan`` is an open source, Python-based automation framework for numerical
+computing.
+
+It is designed to automate the drudge work of managing many numerical
+simulations. As an automation framework it does the following:
+
+- helps you organize your simulations.
+- helps you orchestrate running simulations and then post-processing the
+  results from these.
+- helps you reuse code for the post processing of your simulation data.
+- execute all your simulations and post-processing with one command.
+- optionally distribute your simulations among other computers on your
+  network.
+
+This greatly facilitates reproducibility. Automan is written in pure Python
+and is easy to install.
+
+This document should help you use automan to improve your productivity. If you
+are interested in a more detailed article about automan see here:
+https://arxiv.org/abs/1712.04786
+
+
+Installation
+-------------
+
+The easiest way to install ``automan`` is with pip_::
+
+   $ pip install automan
+
+If you wish to run the latest version that has not been relesed you may clone
+the git repository::
+
+  $ git clone https://github.com/pypr/automan
+
+  $ cd automan
+
+And then run::
+
+  $ python setup.py develop
+
+.. _pip: https://pip.pypa.io/en/stable/
+
+
+Once this is done, move on to the next section that provides a gentle tutorial
+introduction to using automan.
+
+
+Citing ``automan``
+-------------------
+
+If you find automan useful and wish to cite it you may use the following
+article:
+
+- Prabhu Ramachandran, "automan: a Python-based Automation Framework for
+  Numerical Computing", *IEEE Computing in Science and Engineering*,
+  Accepted, 2018.
+
+You can find a draft of the article here: https://arxiv.org/abs/1712.04786
diff --git a/docs/source/reference/automan.rst b/docs/source/reference/automan.rst
new file mode 100644
index 0000000..9ea7cdf
--- /dev/null
+++ b/docs/source/reference/automan.rst
@@ -0,0 +1,21 @@
+Main automation module
+=======================
+
+.. automodule:: automan.automation
+   :members:
+   :undoc-members:
+
+
+Low-level job management module
+=================================
+
+.. automodule:: automan.jobs
+   :members:
+   :undoc-members:
+
+Cluster management module
+=========================
+
+.. automodule:: automan.cluster_manager
+   :members:
+   :undoc-members:
diff --git a/docs/source/reference/index.rst b/docs/source/reference/index.rst
new file mode 100644
index 0000000..b5be858
--- /dev/null
+++ b/docs/source/reference/index.rst
@@ -0,0 +1,9 @@
+Reference Documentation
+=========================
+
+Autogenerated from doc strings using sphinx’s autodoc feature.
+
+.. toctree::
+   :maxdepth: 3
+
+   automan.rst
diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst
new file mode 100644
index 0000000..dce89aa
--- /dev/null
+++ b/docs/source/tutorial.rst
@@ -0,0 +1,629 @@
+A tutorial on using ``automan``
+================================
+
+Automan is best suited for numerical computations that take a lot of time to
+run. It is most useful when you have to manage the execution of many of these
+numerical computations. Very often one needs to process the output of these
+simulations to either compare the results and then assemble a variety of plots
+or tables for a report or manuscript.
+
+The numerical simulations can be in the form of any executable code, either in
+the form of a binary or a script. In this tutorial we will assume that the
+programs being executed are in the form of Python scripts.
+
+There are three general recommendations we make for your numerical programs to
+be able to use automan effectively.
+
+1. They should be configurable using command line arguments.
+2. They should generate their output files into a directory specified on the
+   command line.
+3. Any post-processed data should be saved into an easy to load datafile to
+   facilitate comparisons with other simulations.
+
+None of these is strictly mandatory but we strongly recommend doing it this
+way.
+
+A simple example
+-----------------
+
+In this tutorial we take a rather trivial program to execute just to
+illustrate the basic concepts. Our basic program is going to simply calculate
+the square of a number passed to it on the command line. Here is how the code
+looks:
+
+.. literalinclude:: ../../examples/tutorial/square.py
+
+
+You can execute this script like so::
+
+  $ python square.py 2.0
+  2.0 4.0
+
+Yay, it works!
+
+.. note::
+
+    If you want to run these examples, they are included along with the
+    example files in the automan source code. The files should be in
+    ``examples/tutorial`` (`Browse online
+    <https://github.com/pypr/automan/tree/master/examples/tutorial>`_).
+
+This example does not produce any output files and doesn't really take any
+configuration arguments. So we don't really need to do much about this.
+
+Now let us say we want to automate the execution of this script with many
+different arguments, let us say 2 different values for now (we can increase it later).
+
+We can now do this with automan. First create a simple script for this, we
+could call it ``automate1.py`` (this is just a convention, you could call it
+anything you want).  The code is as shown below:
+
+.. literalinclude:: ../../examples/tutorial/automate1.py
+   :linenos:
+
+
+Note the following:
+
+- The ``Squares`` class derives from :py:class:`automan.automation.Problem`.
+  This encapsulates all the simulations where we wish to find the square of
+  some number.
+- The ``get_name`` method returns a subdirectory for all the outputs of this
+  problem.
+- The ``get_commands`` returns a list of tuples of the following form,
+  ``(directory_name, command, job_info)``. In this case we don't pass any job
+  information and we'll get to this later. Notice that the two commands
+  specified are essentially what we'd have typed on the terminal.
+- The ``run`` command does nothing much except create a directory. For now let
+  us leave this alone.
+
+
+Let us execute this to see what it does::
+
+  $ python automate1.py
+
+  Writing config.json
+  4 tasks pending and 0 tasks running
+
+  Running task <automan.automation.CommandTask object at 0x10628d978>...
+  Starting worker on localhost.
+  Job run by localhost
+  Running python square.py 2
+
+  Running task <automan.automation.CommandTask object at 0x10628d9b0>...
+  Job run by localhost
+  Running python square.py 1
+  2 tasks pending and 2 tasks running
+  Running task <automan.automation.SolveProblem object at 0x10628d940>...
+
+  Running task <automan.automation.RunAll object at 0x10628d908>...
+  Finished!
+
+So the script executes and seems to have run the requested computations. Let
+us see the output directories::
+
+  $ tree
+  .
+  ├── automate1.py
+  ├── config.json
+  ├── manuscript
+  │   └── figures
+  │       └── squares
+  ├── outputs
+  │   └── squares
+  │       ├── 1
+  │       │   ├── job_info.json
+  │       │   ├── stderr.txt
+  │       │   └── stdout.txt
+  │       └── 2
+  │           ├── job_info.json
+  │           ├── stderr.txt
+  │           └── stdout.txt
+  └── square.py
+
+  7 directories, 9 files
+
+Let us summarize what just happened:
+
+- The two commands we asked for were executed and the respective outputs of
+  each invocation were placed into ``outputs/squares/1`` and
+  ``outputs/squares/2``. Notice that there are ``stdout.txt, stderr.txt`` and
+  a ``job_info.json`` file here too.
+- A manuscript directory called ``manuscript/figures/squares`` is created.
+- There is also a new ``config.json`` that we can safely ignore for now.
+
+Let us see the contents of the files in the outputs directory::
+
+  $ cat outputs/squares/1/stdout.txt
+  1.0 1.0
+
+  $ cat outputs/squares/1/job_info.json
+  {"start": "Fri Aug 24 01:11:46 2018", "end": "Fri Aug 24 01:11:46 2018", "status": "done", "exitcode": 0, "pid": 20381}
+
+As you can see, the standard output has the output of the command. The
+``job_info.json`` has information about the actual execution of the code. This
+is very useful in general.
+
+Thus automan has executed the code, organized the output directories and
+collected the standard output and information about the execution of the
+tasks.
+
+Now, let us run the automation again::
+
+  $ python automate1.py
+  0 tasks pending and 0 tasks running
+  Finished!
+
+It does not re-run the code as it detects that everything is complete.
+
+Adding some post-processing
+----------------------------
+
+Let us say we want to create a plot that either compares the individual runs
+or assembles the runs into a single plot or collects the data into a single
+file. We can easily do this by adding more code into the ``run`` method of our
+``Squares`` class. Let us also add a couple of more computations.
+
+.. literalinclude:: ../../examples/tutorial/automate2.py
+   :linenos:
+
+Let us examine this code a little carefully:
+
+- In ``get_commands``, we have simply added two more cases.
+
+- In ``run``, we have added some simple code to just iterate over the 4
+  directories we should have and then read the standard output into a list and
+  finally we write that out into an ``output.txt`` file.
+
+- We also moved the ``automator`` object creation and execution so we can
+  import our ``automate2.py`` script if we wish to.
+
+The two new methods you see here are ``self.input_path(...)`` which makes it
+easy to access any paths inside the simulation directories and the
+``self.output_path(...)`` which does the same but inside the output path. Let
+us see what these do, inside the directory containing the ``automate2.py`` if
+you start up a Python interpreter (IPython_ would be much nicer), you can do
+the following::
+
+  >>> import automate2
+  >>> squares = automate2.Squares(
+  ...    simulation_dir='outputs',
+  ...    output_dir='manuscript/figures/'
+  ... )
+
+  >>> squares.input_path('1')
+  'outputs/squares/1'
+
+  >>> squares.input_path('1', 'stdout.txt')
+  'outputs/squares/1/stdout.txt'
+
+  >>> squares.output_path('output.txt')
+  'manuscript/figures/squares/output.txt'
+
+.. _IPython: ://ipython.org/
+
+As you can see, these are just conveniences for finding the input file paths
+and the output file paths.  Now let us run this new script::
+
+  $ python automate2.py
+  0 tasks pending and 0 tasks running
+  Finished!
+
+Whoa! That doesn't seem right? What happens is that since the last time we ran
+the automate script, it created the output, it assumes that there is nothing
+more to do as the final result (the ``manuscript/figures/squares``) was
+successfully created so it does not run anything new.  If you do this::
+
+  $ python automate2.py -h
+
+You'll see an option ``-f`` which basically redoes the post-processing by
+removing any old plots, so let us try that::
+
+  $ python automate2.py -f
+  4 tasks pending and 0 tasks running
+  ...
+  Finished!
+
+Now it actually ran just the new simulations (you can see the commands in the
+output it prints), it will not re-run the already executed cases. Now let us
+see if the output is collected::
+
+  $ cat manuscript/figures/squares/output.txt
+  [['1.0', '1.0'], ['2.0', '4.0'], ['3.0', '9.0'], ['4.0', '16.0']]
+
+So what automan did was to execute the newly added cases and then executed our
+post-processing code in the ``run`` method to produce the output.
+
+Building on this we have a slightly improved script, called ``automate3.py``,
+which makes a plot:
+
+.. literalinclude:: ../../examples/tutorial/automate3.py
+   :linenos:
+
+This version simplifies the command generation by using a list-comprehension,
+so reduces several lines of code. It then makes a matplotlib plot with the
+collected data.  Let us run this::
+
+  $ python automate3.py -f
+  5 tasks pending and 0 tasks running
+  ...
+  Finished!
+
+  $ ls manuscript/figures/squares/
+  squares.pdf
+
+
+As you can see, the old ``output.txt`` is gone and our plot is available.
+
+.. note::
+   This example requires that you have matplotlib_ and NumPy_ installed.
+
+
+.. _NumPy: https://www.numpy.org/
+.. _matplotlib: https://matplotlib.org/
+
+
+If you wanted to change the plotting style in any way, you can do so and
+simply re-run ``python automate3.py -f`` and it will only regenerate the final
+plot without re-executing the actual simulations.
+
+So what if you wish to re-run any of these cases? In this case you will need
+to manually remove the particular simulation (or even all of them). Let us try
+this::
+
+  $ rm -rf outputs/squares/3
+
+  $ python automate3.py -f
+  3 tasks pending and 0 tasks running
+  ...
+  Finished!
+
+It will just run the missing case and re-generate the plots.
+
+While this may not seem like much, we've fully automated our simulation and
+analysis.
+
+
+Doing things a bit better
+----------------------------
+
+The previous section demonstrated the basic ideas so you can get started
+quickly. Our example problem was very simple and only produced command line
+output. Our next example is a simple problem in the same directory called
+``powers.py``. This problem is also simple but supports a few command line
+arguments and is as follows:
+
+.. literalinclude:: ../../examples/tutorial/powers.py
+   :linenos:
+
+Again, the example is very simple, bulk of the code is parsing command line
+arguments. There are three arguments the code can take:
+
+- ``--power power`` specifies the power to be computed.
+- ``--max`` specifies the largest integer in sequence whose power is to be
+  computed
+- ``--output-dir`` is the directory where the output should be generated.
+
+When executed, the script will create a ``results.npz`` file which contains
+the results stored as NumPy_ arrays. This example also requires that
+matplotlib_ and NumPy_ be installed.  Let us run the code::
+
+  $ python powers.py
+
+  $ ls results.npz
+  results.npz
+
+  $ python powers.py --power 3.5
+
+On a Python interpreter we can quickly look at the results::
+
+  $ python
+
+  >>> import numpy as np
+  >>> data = np.load('results.npz')
+  >>> data['x']
+  >>> data['y']
+
+This looks about right, so let us move on to see how we can automate running
+several cases of this script in a better way than what we had before. We will
+continue to automate the previous ``squares.py`` script. This shows you how
+you can use automan incrementally as you add more cases. We only show the
+lines that are changed and do not reproduce the ``Squares`` problem class in
+the listing below.
+
+
+.. literalinclude:: ../../examples/tutorial/automate4.py
+   :lines: 31-
+   :lineno-match:
+
+To see the complete file see `automate4.py
+<https://github.com/pypr/automan/tree/master/examples/tutorial>`_. The key
+points to note in the code are the following:
+
+- As before ``get_name()`` simply returns a convenient name where the outputs
+  will be stored.
+- A new ``setup()`` method is used and this creates an instance attribute
+  called ``self.cases`` which is a list of cases we wish to simulate. Instead
+  of using strings in the ``get_commands`` we simply setup the ``cases`` and
+  no longer need to create a ``get_commands``. We discuss ``Simulation``
+  instances in greater detail below.
+- The ``run()`` method is similar except it uses the ``cases`` attribute and
+  some conveniences of the simulation objects for convenience.
+
+
+The :py:class:`automan.automation.Simulation` instances we create are more
+general purpose and are very handy. A simulation instance's first argument is
+the the output directory and the second is a basic command to execute. It
+takes a third optional argument called ``job_info`` which specifies the number
+of cores and threads to use and we discuss this later. For now let us ignore
+it. In addition any keyword arguments one passes to this are automatically
+converted to command line arguments. Let us try to create one of these on an
+interpreter to see what is going on::
+
+  >>> from automan.api import Simulation
+  >>> s = Simulation(root='some_output/dir/blah',
+  ...                base_command='python powers.py', power=3.5)
+  >>> s.name
+  'blah'
+  >>> s.command
+  'python powers.py --power=3.5'
+  >>> s.params
+  {'power': 3.5}
+
+Notice that the name is simply the basename of the root path. You will see
+that additional keyword argument ``power=3.5`` is converted to a suitable
+command line argument. This is done by the
+``Simulation.get_command_line_args`` method and can be overridden if you wish
+to do something different. The ``Simulation.params`` attribute simply stores
+all the keyword arguments so you could use it later while post-processing.
+
+Now we want that each execution of this command produces output into the
+correct directory. We could either roll this into the ``base_command``
+argument by passing the correct output directory or there is a nicer way to do
+this using the magic ``$output_dir`` argument that is automatically set the
+output directory when the command is executed, for example::
+
+  >>> from automan.api import Simulation
+  >>> s = Simulation(root='some_output/dir/blah',
+  ...                base_command='python powers.py --output-dir $output_dir', power=3.5)
+  >>> s.command
+  'python powers.py --output-dir $output_dir --power=3.5'
+
+Note that the magic variable is not substituted at this point but later when
+the program is about to be executed.
+
+Given these details, the code in the ``run`` method should be fairly
+straightforward to understand. Note that this organization of our code has
+made us maximize reuse of our plotting code. The ``case.params`` attribute is
+convenient when post-prprocessing. One can also filter the cases using the
+``filter_cases`` function that is provided by ``automan``. We discuss this
+later.
+
+The last change to note is that we add the ``Powers`` class to the
+``Automator``'s ``all_problems`` and we are done.Let us now run this::
+
+  $ python automate4.py
+  6 tasks pending and 0 tasks running
+  ...
+  Finished!
+
+This only executes the new cases from the ``Powers`` class and makes the plot
+in ``manuscript/figures/powers/powers.pdf``.
+
+Using :py:class:`automan.automation.Simulation` instances allows us to
+parametrize simulations with the keyword arguments. In addition, it is handy
+while post-processing. We can also subclass the ``Simulation`` instance to
+customize various things or share code.
+
+There are a few more conveniences that automan provides that are useful while
+post-processing and these are discussed below.
+
+
+Filtering and comparing cases
+------------------------------
+
+.. py:currentmodule:: automan.automation
+
+``automan`` provides a couple of handy functions to help filter the different
+cases based on the parameters or the name of the cases. One can also make
+plots for a collection of cases and compare them easily.
+
+- The :py:func:`filter_cases` function takes a sequence of cases
+  and any additional keyword arguments with parameter values and filters out
+  the cases having those parameter values. For example from our previous
+  example in the ``Powers`` class, if we do the following::
+
+   filtered_cases = filter_cases(cases, power=2)
+
+  will return a list with a single case which uses ``power=2``. This is very
+  handy. This function can also be passed a callable which returns ``True``
+  for any acceptable case.  For example::
+
+    filter_cases(cases, lambda x: x.params['power'] % 2)
+
+  will return all the cases with odd powers.
+
+- The :py:func:`filter_by_name` function filters the cases whose
+  names match the list of names passed.  For example::
+
+    filter_by_name(cases, ['1', '4'])
+
+  will return the two simulations whose names are equal to ``'1'`` or ``'4'``.
+
+- The :py:func:`compare_runs` function calls a method or callable
+  with the given cases. This is very handy to make comparison plots.
+
+With this information you should be in a position to automate your
+computational simulations and analysis.
+
+Next we look at setting up additional remote computers on which we can execute
+our computations.
+
+
+Using additional computational resources
+----------------------------------------
+
+Wouldn't it be nice if we could easily run part of the simulations on one or
+more remote computers? ``automan`` makes this possible. Let us see how with
+our last example.
+
+Let us first remove all the generated outputs and files so we can try this::
+
+  $ rm -rf outputs/ manuscript/figures config.json
+
+Running the simulations on a remote machine requires a few things:
+
+- the computer should be running either Mac OS or Linux/Unix.
+- you should have an account on the computer, and be able to ``ssh`` into it
+  without a password (see `article on password-less ssh
+  <http://askubuntu.com/questions/46930/how-can-i-set-up-password-less-ssh-login>`_.
+- the computer should have a working basic Python interpreter.
+
+For more complex dependencies, you need to make sure the remote machine has
+the necessary software.
+
+
+Assuming you have these requirements on a computer accessible on your network
+you can do the following::
+
+  $ python automate4.py -a host_name
+  [...]
+
+Where ``host_name`` is either the computer's name or IP address. This will
+print a lot of output and attempt to setup a virtual environment on the remote
+machine. If it fails, it will print out some instructions for you to fix.
+
+If this succeeds, you can now simply use the automation script just as before
+and it will now run some of the code on the remote machine depending on its
+availability.  For example::
+
+   $ python automate4.py
+   14 tasks pending and 0 tasks running
+
+   Running task <automan.automation.CommandTask object at 0x1141da748>...
+   Starting worker on localhost.
+   Job run by localhost
+   Running python powers.py --output-dir outputs/powers/4 --power=4.0
+
+   Running task <automan.automation.CommandTask object at 0x1141da6d8>...
+   Starting worker on 10.1.10.242.
+   Job run by 10.1.10.242
+   Running python powers.py --output-dir outputs/powers/3 --power=3.0
+   ...
+
+
+Note that you can add new machines at any point. For example you may have
+finished running a few simulations already and are simulating a new problem
+that you wish to distribute, you can add a new machine and fire the automation
+script and it will use it for the new simulations.
+
+When you add a new remote host ``automan`` does the following:
+
+- Creates an ``automan`` directory in the remote machine home directory (you
+  can set a different home using ``python automate4.py -a host --home
+  other_home``.)
+- Inside this directory it copies the current project directory, ``tutorial``
+  in the present case.
+- It then copies over a ``bootstrap.sh`` and ``update.sh`` and runs the
+  ``bootstrap.sh`` script. These scripts are inside a ``.automan/`` directory
+  on your localhost and you may edit these if you need to.
+
+The bootstrap code does the following:
+
+- It creates a virtualenv_ called ``tutorial`` on this computer using the
+  system Python and puts this in ``automan/envs/tutorial``.
+- It then activates this environment, installs ``automan`` and also runs any
+  ``requirements.txt`` if they exist in the tutorial directory.
+
+If for some reason this script fails, you may edit it on the remote host and
+re-run it.
+
+When executing the code, automan copies over the files from the remote host to
+your computer once the simulation is completed and also deletes the output
+files on the remote machine.
+
+If your remote computer shares your file-system via nfs or so, you can specify
+this when you add the host as follows::
+
+  $ python automate4.py -a host_sharing_nfs_files --nfs
+
+In this case, files will not be copied back and forth from the remote host.
+
+
+.. _virtualenv: https://virtualenv.pypa.io/
+
+Now lets say you update files inside your project you can update the remote
+hosts using::
+
+   $ python automate4.py -u
+
+This will update all remote workers and also run the ``update.sh`` script on
+all of them. It will also copy your local modifications to the scripts in
+``.automan``. It will then run any simulations.
+
+Lets say you do not want to use a particular host, you can remove the entry
+for this in the ``config.json`` file.
+
+When ``automan`` distributes tasks to machines, local and remote, it needs
+some information about the task and the remote machines. Recall that when we
+created the ``Simulation`` instances we could pass in a ``job_info`` keyword
+argument. The ``job_info`` is an optional dictionary with the following
+optional keys:
+
+- ``'n_core'``: the number of cores that this simulation requires. This is
+  used for scheduling tasks. For example if you set ``n_core=4`` and have a
+  computer with only 2 cores, automan will not be able to run this job on this
+  machine at all. On the other hand if the task does indeed consume more than
+  one core and you set the value to one, then the scheduler will run the job
+  on a computer with only one core available.
+- ``'n_thread'``: the number of threads to use. This is used to set the
+  environment variable ``OMP_NUM_THREADS`` for OpenMP executions.
+
+
+As an example, here is how one would use this::
+
+  Simulation(root=self.input_path('3.5'),
+             base_command='python powers.py',
+             job_info=dict(n_core=1, n_thread=1),
+             power=3.5
+  )
+
+This job requires only a single core. So when automan tries to execute the job
+on a computer it looks at the load on the computer and if one core is free, it
+will execute the job.
+
+If for some reason you are not happy with how the remote computer is managed
+and wish to customize it, you can feel free to subclass the
+:py:class:`automan.cluster_manager.ClusterManager` class. You may pass this in
+to the :py:class:`automan.automation.Automator` class as the
+``cluster_manager_factory`` and it will use it.
+
+
+
+Using docker
+------------
+
+It should be possible to use automan from within a Docker_ container. This can
+be done either by specifying commands to be run within suitable ``docker run``
+invocations. Alternatively, one can install automan and run scripts within the
+docker container and this will work correctly.
+
+Using docker for remote computers is not fully supported at this time.
+
+.. _Docker: https://www.docker.com/
+
+Learning more
+-------------
+
+If you wish to learn more about automan you may find the following useful:
+
+- Read the draft of the paper on ``automan`` here: https://arxiv.org/abs/1712.04786
+
+- The paper mentions another manuscript which was fully automated using
+  automan, the sources for this are at https://gitlab.com/prabhu/edac_sph/ and
+  this demonstrates a complete real-world example of using automan to automate
+  an entire research paper.
+
+- Olivier Mesnard has created a nice example as part of the review of this
+  paper that can be seen here: https://github.com/mesnardo/automan-example the
+  example also nicely shows how automan can be used from within a docker
+  container for a completely reproducible workflow.
diff --git a/examples/tutorial/automate1.py b/examples/tutorial/automate1.py
new file mode 100644
index 0000000..b55c8c5
--- /dev/null
+++ b/examples/tutorial/automate1.py
@@ -0,0 +1,23 @@
+from automan.api import Problem, Automator
+
+
+class Squares(Problem):
+    def get_name(self):
+        return 'squares'
+
+    def get_commands(self):
+        return [
+            ('1', 'python square.py 1', None),
+            ('2', 'python square.py 2', None),
+        ]
+
+    def run(self):
+        self.make_output_dir()
+
+
+automator = Automator(
+    simulation_dir='outputs',
+    output_dir='manuscript/figures',
+    all_problems=[Squares]
+)
+automator.run()
diff --git a/examples/tutorial/automate2.py b/examples/tutorial/automate2.py
new file mode 100644
index 0000000..bc0d39d
--- /dev/null
+++ b/examples/tutorial/automate2.py
@@ -0,0 +1,36 @@
+from automan.api import Problem, Automator
+
+
+class Squares(Problem):
+    def get_name(self):
+        return 'squares'
+
+    def get_commands(self):
+        return [
+            ('1', 'python square.py 1', None),
+            ('2', 'python square.py 2', None),
+            ('3', 'python square.py 3', None),
+            ('4', 'python square.py 4', None),
+        ]
+
+    def run(self):
+        self.make_output_dir()
+        data = []
+        for i in ('1', '2', '3', '4'):
+            stdout = self.input_path(i, 'stdout.txt')
+            with open(stdout) as f:
+                data.append(f.read().split())
+
+        output = self.output_path('output.txt')
+        with open(output, 'w') as o:
+            o.write(str(data))
+
+
+if __name__ == '__main__':
+    automator = Automator(
+        simulation_dir='outputs',
+        output_dir='manuscript/figures',
+        all_problems=[Squares]
+    )
+
+    automator.run()
diff --git a/examples/tutorial/automate3.py b/examples/tutorial/automate3.py
new file mode 100644
index 0000000..4907c6b
--- /dev/null
+++ b/examples/tutorial/automate3.py
@@ -0,0 +1,36 @@
+from automan.api import Problem, Automator
+from matplotlib import pyplot as plt
+import numpy as np
+
+
+class Squares(Problem):
+    def get_name(self):
+        return 'squares'
+
+    def get_commands(self):
+        commands = [(str(i), 'python square.py %d' % i, None)
+                    for i in range(1, 8)]
+        return commands
+
+    def run(self):
+        self.make_output_dir()
+        data = []
+        for i in range(1, 8):
+            stdout = self.input_path(str(i), 'stdout.txt')
+            with open(stdout) as f:
+                values = [float(x) for x in f.read().split()]
+                data.append(values)
+
+        data = np.asarray(data)
+        plt.plot(data[:, 0], data[:, 1], 'o-')
+        plt.xlabel('x')
+        plt.ylabel('y')
+        plt.savefig(self.output_path('squares.pdf'))
+
+
+automator = Automator(
+    simulation_dir='outputs',
+    output_dir='manuscript/figures',
+    all_problems=[Squares]
+)
+automator.run()
diff --git a/examples/tutorial/automate4.py b/examples/tutorial/automate4.py
new file mode 100644
index 0000000..43ba751
--- /dev/null
+++ b/examples/tutorial/automate4.py
@@ -0,0 +1,70 @@
+from automan.api import Problem, Automator
+from matplotlib import pyplot as plt
+import numpy as np
+
+
+class Squares(Problem):
+    def get_name(self):
+        return 'squares'
+
+    def get_commands(self):
+        commands = [(str(i), 'python square.py %d' % i, None)
+                    for i in range(1, 8)]
+        return commands
+
+    def run(self):
+        self.make_output_dir()
+        data = []
+        for i in range(1, 8):
+            stdout = self.input_path(str(i), 'stdout.txt')
+            with open(stdout) as f:
+                values = [float(x) for x in f.read().split()]
+                data.append(values)
+
+        data = np.asarray(data)
+        plt.plot(data[:, 0], data[:, 1], 'o-')
+        plt.xlabel('x')
+        plt.ylabel('y')
+        plt.savefig(self.output_path('squares.pdf'))
+
+
+from automan.api import Simulation
+
+
+class Powers(Problem):
+    def get_name(self):
+        return 'powers'
+
+    def setup(self):
+        base_cmd = 'python powers.py --output-dir $output_dir'
+        self.cases = [
+            Simulation(
+                root=self.input_path(str(i)),
+                base_command=base_cmd,
+                power=float(i)
+            )
+            for i in range(1, 5)
+        ]
+
+    def run(self):
+        self.make_output_dir()
+        for case in self.cases:
+            data = np.load(case.input_path('results.npz'))
+            plt.plot(
+                data['x'], data['y'],
+                label=r'$x^{{%.2f}}$' % case.params['power']
+            )
+        plt.grid()
+        plt.xlabel('x')
+        plt.ylabel('y')
+        plt.legend()
+        plt.savefig(self.output_path('powers.pdf'))
+
+
+if __name__ == '__main__':
+    automator = Automator(
+        simulation_dir='outputs',
+        output_dir='manuscript/figures',
+        all_problems=[Squares, Powers]
+    )
+    automator.run()
diff --git a/examples/tutorial/powers.py b/examples/tutorial/powers.py
new file mode 100644
index 0000000..033e034
--- /dev/null
+++ b/examples/tutorial/powers.py
@@ -0,0 +1,41 @@
+import argparse
+import os
+
+import numpy as np
+
+
+def compute_powers(r_max, power):
+    """Compute the powers of the integers upto r_max and return the result.
+    """
+    result = []
+    for i in range(0, r_max + 1):
+        result.append((i, i**power))
+    x = np.arange(0, r_max + 1)
+    y = np.power(x, power)
+    return x, y
+
+
+def main():
+    p = argparse.ArgumentParser()
+    p.add_argument(
+        '--power', type=float, default=2.0,
+        help='Power to calculate'
+    )
+    p.add_argument(
+        '--max', type=int, default=10,
+        help='Maximum integer that we must raise to the given power'
+    )
+    p.add_argument(
+        '--output-dir', type=str, default='.',
+        help='Output directory to generate file.'
+    )
+    opts = p.parse_args()
+
+    x, y = compute_powers(opts.max, opts.power)
+
+    fname = os.path.join(opts.output_dir, 'results.npz')
+    np.savez(fname, x=x, y=y)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/examples/tutorial/square.py b/examples/tutorial/square.py
new file mode 100644
index 0000000..5c7592e
--- /dev/null
+++ b/examples/tutorial/square.py
@@ -0,0 +1,5 @@
+from __future__ import print_function
+import sys
+x = float(sys.argv[1])
+print(x, x*x)
+


Test patch:
diff --git a/automan/tests/test_automation.py b/automan/tests/test_automation.py
index 8fef103..fefd34a 100644
--- a/automan/tests/test_automation.py
+++ b/automan/tests/test_automation.py
@@ -13,7 +13,7 @@ except ImportError:
 
 from automan.automation import (
     Automator, CommandTask, PySPHProblem, Simulation, SolveProblem,
-    TaskRunner, compare_runs
+    TaskRunner, compare_runs, filter_cases
 )
 try:
     from automan.jobs import Scheduler, RemoteWorker
@@ -356,6 +356,69 @@ def test_compare_runs_works_when_given_callables():
     s0.get_labels.assert_called_once_with(['x'])
 
 
+def test_filter_cases_works_with_params():
+    # Given
+    sims = [Simulation(root='', base_command='python', param1=i, param2=i+1)
+            for i in range(5)]
+    # When
+    result = filter_cases(sims, param1=2)
+
+    # Then
+    assert len(result) == 1
+    assert result[0].params['param1'] == 2
+
+    # When
+    result = filter_cases(sims, param1=2, param2=2)
+
+    # Then
+    assert len(result) == 0
+
+    # When
+    result = filter_cases(sims, param1=3, param2=4)
+
+    # Then
+    assert len(result) == 1
+    assert result[0].params['param1'] == 3
+    assert result[0].params['param2'] == 4
+
+
+def test_filter_cases_works_with_predicate():
+    # Given
+    sims = [Simulation(root='', base_command='python', param1=i, param2=i+1)
+            for i in range(5)]
+
+    # When
+    result = filter_cases(
+        sims, predicate=lambda x: x.params.get('param1', 0) % 2
+    )
+
+    # Then
+    assert len(result) == 2
+    assert result[0].params['param1'] == 1
+    assert result[1].params['param1'] == 3
+
+    # When
+    result = filter_cases(
+        sims, predicate=2
+    )
+
+    # Then
+    assert len(result) == 0
+
+    # Given
+    sims = [Simulation(root='', base_command='python', predicate=i)
+            for i in range(5)]
+
+    # When
+    result = filter_cases(
+        sims, predicate=2
+    )
+
+    # Then
+    assert len(result) == 1
+    assert result[0].params['predicate'] == 2
+
+
 class TestAutomator(TestAutomationBase):
     def setUp(self):
         super(TestAutomator, self).setUp()