Repository: acorg/slurm-pipeline

Issue:
Make it possible to report on current status of a pipeline run
It would be great to be able to do this:

```sh
$ squeue -u tcj25 | slurm-pipeline.py -s status.json
```

and see just the output for the jobs that were started by a given run (job details are in `status.json`, which is printed when a job specification is started).  Without something like this it can be quite hard (or impossible) to find out which job ids for a specification submission are still running, pending, etc.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 307f04c..5ad1055 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,37 @@
 # slurm-pipeline
 
-A Python class for scheduling [SLURM](http://slurm.schedmd.com/)
+A Python class for scheduling and examining
+[SLURM](http://slurm.schedmd.com/)
 ([wikipedia](https://en.wikipedia.org/wiki/Slurm_Workload_Manager)) jobs.
 
-This repo contains a Python script, `bin/slurm-pipeline.py` that can
-schedule programs to be run in an organized pipeline fashion on a Linux
-cluster that uses SLURM as a workload manager. Here "pipeline" means with
-an understanding of possibly complex inter-program dependencies.
+Runs under Python 2.7, 3.5, and [pypy](http://pypy.org/).
 
-A pipeline run is schedule according to a specification file in JSON
-format. A couple of these can be seen in the `examples` directory of the
-repo. Here's an example,  `examples/word-count/specification.json`:
+## Scripts
+
+The `bin` directory of this repo contains two Python scripts:
+
+* `slurm-pipeline.py` schedules programs to be run in an organized pipeline
+  fashion on a Linux cluster that uses SLURM as a workload manager.
+  `slurm-pipeline.py` must be given a JSON pipeline specification (see
+  below). It prints a corresponding JSON status that contains the original
+  specification plus information about the scheduling (times, job ids,
+  etc). By *pipeline*, I mean a collection of programs that are run in
+  order, taking into account (possibly complex) inter-program dependencies.
+* `slurm-pipeline-status.py` must be given a specification status (as
+  produced by `slurm-pipeline.py`) and prints a summary of the status
+  including job status (obtained from `squeue`). It can also be used to
+  print a list of unfinished jobs (useful for canceling the jobs of a
+  running specification) or a list of the final jobs of a scheduled
+  pipeline (useful for making sure that jobs scheduled in a subsequent run
+  of `slurm-pipeline.py` do not begin until the given pipeline has
+  finished). See below for more information.
+
+## Specification
+
+A run is schedule according to a pipeline specification file in JSON format
+which is given to `slurm-pipeline.py`. Several examples of these can be
+found under [`examples`](examples). Here's the one from
+[`examples/word-count/specification.json`](examples/word-count/specification.json):
 
 ```json
 {
@@ -35,14 +56,15 @@ repo. Here's an example,  `examples/word-count/specification.json`:
 ```
 
 The specification above contains almost everything you need to know to set
-up your own pipeline, though you of course still have to write the scripts.
+up your own pipeline. You still have to write the scripts though, of
+course.
 
 ## Steps
 
 A pipeline run is organized into a series of conceptual *steps*. These are
-scheduled in the order they appear in the specification file. The actual
-running of the programs involved will occur later, in an order that will
-depend on how long earlier programs take to complete and on dependencies.
+run in the order they appear in the specification file. Step scripts will
+typically use the SLURM [`sbatch`](https://slurm.schedmd.com/sbatch.html)
+command to schedule the later execution of other programs.
 
 Each step must contain a `name` key. Names must be unique.
 
@@ -58,40 +80,40 @@ Optionally, a script may specify `collect` with a `true` value. This will
 cause the script to be run when all the *tasks* (see below) started by
 earlier dependent steps have completed.
 
-Optionally, a script may specify a `cwd` key with a value that is a
-directory that the script should be invoked from. If no directory is given,
-the script will be run in the directory where you invoke
-`slurm-pipeline.py`.
+The full list of specification directives can be found below.
 
 ## Tasks
 
-A step may choose to emit one or more *task*s. It does this by writing
-lines such as `TASK: xxx 39749 39750` to its standard output (this may
-change). This indicates that a task named `xxx` has been scheduled and that
-two SLURM jobs (with ids `39749` and `39750`) will be run to complete
-whatever work is needed for the task.
+A step script may emit one or more *task*s. It does this by writing lines
+such as `TASK: xxx 39749 39750` to its standard output. This indicates that
+a task named `xxx` has been scheduled and that two SLURM jobs (with ids
+`39749` and `39750`) will be run to complete whatever work is needed for
+the task.
+
+A task name may be emitted multiple times by the same script.
 
-Any other output from a script is ignored (it is however stored in the
+Any other output from a step script is ignored (it is however stored in the
 specification - see TODO, below).
 
 When a step depends on an earlier step (or steps), its `script` will be
 called with a single argument: the name of each task emitted by the earlier
 script (or scripts). If a step depends on multiple earlier steps that each
-emit the same task name, the script will only be called once with that task
-name but after all the tasks from all the dependent steps have finished.
+emit the same task name, the script will only be called once, with that
+task name as an argument, after all the jobs from all the dependent steps
+have finished.
 
-So, for example, a step that starts the processing of 10 FASTA files
-could emit the file names, to trigger ten downstream invocations of a
-dependent step's script.
+So, for example, a step that starts the processing of 10 FASTA files could
+emit the file names (as task names), which will cause ten subsequent
+invocations of any dependent step's script.
 
-If a script emits `TASK: xxx` with no job ids, the named task will be
+If a script emits `TASK: xxx` with no job id(s), the named task will be
 passed along the pipeline but dependent steps will not need to wait for any
 SLURM job to complete before being invoked. This is useful when a script
-can do some work synchronously (i.e., without scheduling via SLURM).
+does its work synchronously (i.e., without scheduling via SLURM).
 
-If a step uses `"collect": true`, its script will only be called once. The
-script's arguments will be the names of all tasks emitted by the steps it
-depends on.
+If a step specification uses `"collect": true`, its script will only be
+called once. The script's arguments will be the names of all tasks emitted
+by the steps it depends on.
 
 Steps that have no dependencies are called with whatever command-line
 arguments are given to `slurm-pipeline.py` (apart from the specification
@@ -118,14 +140,13 @@ The standard input of invoked scripts is closed.
 The file `status.json` produced by the above will contain the original
 specification, updated to contain information about the tasks that have
 been scheduled, their SLURM job ids, script output, timestamps, etc. See
-the `word-count` example below for sample output (and the TODO section for
-plans for using the updated specification).
+the `word-count` example below for sample output.
 
 ## slurm-pipeline.py options
 
 `slurm-pipeline.py` accepts the following options:
 
-* `--specification filename`: as described above.
+* `--specification filename`: as described above. Required.
 * `--force`: Will cause `SP_FORCE` to be set in the environment of step
   scripts with a value of `1`. It is up to the individual scripts to notice
   this and act accordingly. If `--force` is not used, `SP_FORCE` will be
@@ -151,10 +172,28 @@ plans for using the updated specification).
   error. This argument may be repeated to skip multiple steps. See also the
   `skip` directive that can be used in a specification file for more
   permanent disabling of a script step.
+* `--startAfter`: Specify one or more SLURM job ids that must be allowed to
+  complete (in any state - successful or in error) before the initial steps
+  of the current specification are allowed to run. If you have saved the
+  output of a previous `slurm-pipeline.py` run, you can use
+  `slurm-pipeline-status.py` to output the final job ids of that previous
+  run and give those job ids to a subsequent invocation of
+  `slurm-pipeline.py` (see `slurm-pipeline-status.py` below for an
+  example).
 
 Note that all script steps are *always* executed, including when
 `--firstStep` or `--skip` are used. It is up to the scripts to decide what
-to do.
+to do (based on the `SP_*` environment variables) in those cases.
+
+`slurm-pipeline.py` prints an updated specification to `stdout`. You will
+probably always want to save this to a file so you can later pass it to
+`slurm-pipeline-status.py`. If you forget to redirect `stdout`, information
+about the progress of the scheduled pipeline can be very difficult to
+recover (you may have many other jobs already scheduled, so it can be very
+hard to know which jobs were started by which run of `slurm-pipeline.py` or
+by any other method).  So if `stdout` is a terminal, `slurm-pipeline.py`
+tries to help by writing the status specification to a temporary file (as
+well as `stdout`) and prints its location (to `stderr`).
 
 ### Simulating versus skipping
 
@@ -195,7 +234,9 @@ the full list:
 
 * `name`: the name of the step (required).
 * `script`: the script to run (required).
-* `cwd`: the directory to run the script in.
+* `cwd`: the directory to run the script in. If no directory is given, the
+   script will be run in the directory where you invoke
+   `slurm-pipeline.py`.
 * `collect`: for scripts that should run only when all tasks from all their
   prerequisites have completed.
 * `dependencies`: a list of previous steps that a step depends on.
@@ -248,8 +289,6 @@ exectued:
     line like `Submitted batch job 3779695`) and the `cut` in the above pulls
     out just the job id. The task name is then emitted, along with the job id.
 
-    A task name may be emitted multiple times by the same script.
-
 ## Separation of concerns
 
 `slurm-pipeline.py` doesn't interact with SLURM at all. Actually, the
@@ -275,6 +314,145 @@ The scripts in the examples all do their work synchronously (they emit fake
 SLURM job ids using the Bash shell's `RANDOM` variable, just to make it
 look like they are submitting jobs to `sbatch`).
 
+## slurm-pipeline-status.py options
+
+`slurm-pipeline-status.py` accepts the following options:
+
+* `--specification filename`: must contain a status specification, as
+    printed by `slurm-pipeline.py`. Required.
+* `--squeueArgs'`: A list of arguments to pass to squeue (this must include
+    the squeue command itself). If not specified, the user's login name
+    will be appended to `squeue -u`.
+* `--printUnfinished`: If specified, just print a list of job ids that have
+    not yet finished. This can be used to cancel a job, via e.g.,
+    
+    ```sh
+    $ slurm-pipeline-status.py --specification spec.json --printUnfinished  | xargs -r scancel
+    ```
+* `--printFinal`: If specified, just print a list of job ids issued by the
+    final steps of a specification. This can be used with the
+    `--startAfter` option to `slurm-pipeline.py` to make it schedule a
+    different specification to run only after the given specification
+    finishes. E.g.,
+    
+    ```sh
+    # Start a first specification and save its status:
+    $ slurm-pipeline.py --specification spec1.json > spec1-status.json
+
+    # Start a second specification once the first has finished (this assumes your shell is bash):
+    $ slurm-pipeline.py --specification spec2.json \
+        --startAfter $(slurm-pipeline-status.py --specification spec1-status.json --printFinal) \
+        > spec2-status.json
+    ```
+
+If neither `--printUnfinished` nor `--printFinal` is given,
+`slurm-pipeline-status.py` prints a detailed summary of the status of the
+specification it is given.  This will include the current status (obtained
+from [`squeue`](https://slurm.schedmd.com/squeue.html)) of all jobs
+launched.  Example output will resemble the following:
+
+```sh
+$ slurm-pipeline.py --specification spec1.json > status.json
+$ slurm-pipeline-status.py --specification status.json
+Scheduled at: 2016-12-10 14:20:58
+Scheduling arguments:
+  First step: panel
+  Force: False
+  Last step: None
+  Script arguments: <None>
+  Skip: <None>
+  Start after: <None>
+5 jobs emitted in total, of which 4 (80.00%) are finished
+Step summary:
+  start: no jobs emitted
+  split: no jobs emitted
+  blastn: 3 jobs emitted, 3 (100.00%) finished
+  panel: 1 job emitted, 1 (100.00%) finished
+  stop: 1 job emitted, 0 (0.00%) finished
+Step 1: start
+  No dependencies.
+  No tasks emitted by this step
+  Working directory: 00-start
+  Scheduled at: 2016-12-10 14:20:59
+  Script: 00-start/start.sh
+  Simulate: True
+  Skip: False
+Step 2: split
+  1 step dependency: start
+    Dependent on 0 tasks emitted by the dependent step
+  3 tasks emitted by this step
+    0 jobs started by these tasks
+    Tasks:
+      chunk-aaaaa
+      chunk-aaaab
+      chunk-aaaac
+  Working directory: 01-split
+  Scheduled at: 2016-12-10 14:21:04
+  Script: 01-split/sbatch.sh
+  Simulate: True
+  Skip: False
+Step 3: blastn
+  1 step dependency: split
+    Dependent on 3 tasks emitted by the dependent step
+    0 jobs started by the dependent tasks
+    Dependent tasks:
+      chunk-aaaaa
+      chunk-aaaab
+      chunk-aaaac
+  3 tasks emitted by this step
+    3 jobs started by this task, of which 3 (100.00%) are finished
+    Tasks:
+      chunk-aaaaa
+        Job 4416231: Finished
+      chunk-aaaab
+        Job 4416232: Finished
+      chunk-aaaac
+        Job 4416233: Finished
+  Working directory: 02-blastn
+  Scheduled at: 2016-12-10 14:22:02
+  Script: 02-blastn/sbatch.sh
+  Simulate: True
+  Skip: False
+Step 4: panel
+  1 step dependency: blastn
+    Dependent on 3 tasks emitted by the dependent step
+    3 jobs started by the dependent task, of which 3 (100.00%) are finished
+    Dependent tasks:
+      chunk-aaaaa
+        Job 4416231: Finished
+      chunk-aaaab
+        Job 4416232: Finished
+      chunk-aaaac
+        Job 4416233: Finished
+  1 task emitted by this step
+    1 job started by this task, of which 1 (100.00%) are finished
+    Tasks:
+      panel
+        Job 4417615: Finished
+  Working directory: 03-panel
+  Scheduled at: 2016-12-10 14:22:02
+  Script: 03-panel/sbatch.sh
+  Simulate: False
+  Skip: False
+Step 5: stop
+  1 step dependency: panel
+    Dependent on 1 task emitted by the dependent step
+    1 job started by the dependent task, of which 1 (100.00%) are finished
+    Dependent tasks:
+      panel
+        Job 4417615: Finished
+  1 task emitted by this step
+    1 job started by this task, of which 0 (0.00%) are finished
+    Tasks:
+      stop
+        Job 4417616: Status=R Time=4:27 Nodelist=cpu-3
+  Working directory: 04-stop
+  Scheduled at: 2016-12-10 14:22:02
+  Script: 04-stop/sbatch.sh
+  Simulate: False
+  Skip: False
+```
+
 ## Examples
 
 There are some simple examples in the `examples` directory:
@@ -329,23 +507,35 @@ status:
 
 ```json
 {
-  "scheduledAt": 1477302666.246372,
+  "firstStep": null,
+  "force": false,
+  "lastStep": null,
+  "scheduledAt": 1482709202.618517,
+  "scriptArgs": [
+    "texts/1-karamazov.txt",
+    "texts/2-trial.txt",
+    "texts/3-ulysses.txt"
+  ],
+  "skip": [],
+  "startAfter": null,
   "steps": [
     {
       "name": "one-per-line",
-      "scheduledAt": 1477302666.271217,
+      "scheduledAt": 1482709202.65294,
       "script": "scripts/one-word-per-line.sh",
-      "stdout": "TASK: 1-karamazov 20132\nTASK: 2-trial 3894\nTASK: 3-ulysses 13586\n",
+      "simulate": false,
+      "skip": false,
+      "stdout": "TASK: 1-karamazov 22480\nTASK: 2-trial 26912\nTASK: 3-ulysses 25487\n",
       "taskDependencies": {},
       "tasks": {
         "1-karamazov": [
-          20132
+          22480
         ],
         "2-trial": [
-          3894
+          26912
         ],
         "3-ulysses": [
-          13586
+          25487
         ]
       }
     },
@@ -354,29 +544,31 @@ status:
         "one-per-line"
       ],
       "name": "long-words",
-      "scheduledAt": 1477302666.300458,
+      "scheduledAt": 1482709202.68266,
       "script": "scripts/long-words-only.sh",
-      "stdout": "TASK: 3-ulysses 31944\n",
+      "simulate": false,
+      "skip": false,
+      "stdout": "TASK: 3-ulysses 1524\n",
       "taskDependencies": {
         "1-karamazov": [
-          20132
+          22480
         ],
         "2-trial": [
-          3894
+          26912
         ],
         "3-ulysses": [
-          13586
+          25487
         ]
       },
       "tasks": {
         "1-karamazov": [
-          27401
+          29749
         ],
         "2-trial": [
-          13288
+          15636
         ],
         "3-ulysses": [
-          31944
+          1524
         ]
       }
     },
@@ -386,18 +578,20 @@ status:
         "long-words"
       ],
       "name": "summarize",
-      "scheduledAt": 1477302666.319238,
+      "scheduledAt": 1482709202.7016,
       "script": "scripts/summarize.sh",
+      "simulate": false,
+      "skip": false,
       "stdout": "",
       "taskDependencies": {
         "1-karamazov": [
-          27401
+          29749
         ],
         "2-trial": [
-          13288
+          15636
         ],
         "3-ulysses": [
-          31944
+          1524
         ]
       },
       "tasks": {}
@@ -517,20 +711,43 @@ installed. But it may be instructive to look at the specification file and
 the scripts. The scripts use `sbatch`, unlike those (described above) in
 the `examples` directory.
 
+## Limitations
+
+SLURM allows users to submit scripts for later execution. Thus there are
+two distinct phases of operation: the time of scheduling and the later
+time(s) of script excecution. When using `slurm-pipeline.py` it is
+important to understand this distinction.
+
+The reason is that `slurm-pipeline.py` only examines the output of
+scheduling scripts for task names and job ids. If a scheduling script calls
+`sbatch` to execute a later script, the output of that later script cannot
+be checked for `TASK: xxx 97322` style output because `slurm-pipeline.py`
+is completely unaware of the existence of that script. Thus all tasks and
+job dependencies must be established at the time of scheduling.
+
+Normally this is not an issue, as many pipelines fall nicely into the model
+used by `slurm-pipeline.py`. But sometimes it is necessary to write a step
+script that performs a slow synchronous operation in order to emit
+tasks. For example, you might have a very large input file that you want to
+process in smaller pieces. You can use `split` to break the file into
+pieces and emit task names such as `xaa`, `xab` etc, but you must do this
+synchronously (i.e., in the step script, not in a script submitted to
+`sbatch` by the step script to be executed some time later). This is an
+example of when you would not emit a job id for a task, seeing as no
+further processing is needed to complete the tasks (i.e., the `split` has
+already run) and the next step in the pipeline can be run immediately.
+
+In such cases, it may be advisable to allocate a compute node (using
+[`salloc`](https://slurm.schedmd.com/salloc.html)) to run
+`slurm-pipeline.py` on (instead of tying up a SLURM login node), or at
+least to run `slurm-pipeline.py` using `nice`.
+
 ## TODO
 
-* Make `slurm-pipeline.py` able to accept an in-progress specification
-  so it can report on output, tasks, job ids, completion, timing, etc.
 * Make it possible to pass the names (or at least the prefix) of the two
   environment variables (currently hard-coded as `SP_ORIGINAL_ARGS` and
   `SP_DEPENDENCY_ARG`) to the `SlurmPipeline` constructor.
-* Make it possible to pass a regex for matching task name and job id lines
-  in a script's stdout to the `SlurmPipeline` constructor (instead of
-  using the currently hard-coded `TASK: name jobid1 jobid2`).
-* (Possibly) arrange things so that scripts can write their task lines to
-  file descriptor 3 so as not to interfere with whatever they might
-  otherwise produce on `stdout` (or `stderr`).
-* Separate treatment of `stdout` and `stderr` from scripts.
-* Allow for steps to be error-processing ones, that handle failures in
-  their dependencies. Or allow a step to have both success and failure
-  scripts.
+* (Possibly) make it possible to pass a regex for matching task name and
+  job id lines in a script's stdout to the `SlurmPipeline` constructor
+  (instead of using the currently hard-coded `TASK: name
+  [jobid1 [jobid2 [...]]]`).
diff --git a/bin/slurm-pipeline-status.py b/bin/slurm-pipeline-status.py
new file mode 100755
index 0000000..c0392d4
--- /dev/null
+++ b/bin/slurm-pipeline-status.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python
+
+from __future__ import print_function
+import sys
+
+"""
+Uses the SlurmPipelineStatus class to print information about a
+specification that has already been scheduled, given an in-progress job
+specification status, as originally printed by slurm-pipeline.py.
+"""
+
+import argparse
+
+from slurm_pipeline import SlurmPipelineStatus
+
+
+parser = argparse.ArgumentParser(
+    description=('Print information about the execution status of a '
+                 'scheduled SLURM pipeline.'),
+    formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+
+parser.add_argument(
+    '--specification', '-s', metavar='specification.json', required=True,
+    help=('The name of the file containing the pipeline status, '
+          'in JSON format.'))
+
+parser.add_argument(
+    '--squeueArgs', nargs='*', default=None,
+    help=('A list of arguments to pass to squeue (including the squeue '
+          "command itself). If not specified, the user's login name will "
+          'be appended to squeue -u.'))
+
+parser.add_argument(
+    '--printUnfinished', default=False, action='store_true',
+    help=('If specified, print a list of job ids that have not yet finished. '
+          'This can easily be used to cancel a job, via e.g., '
+          '%s --printUnfinished -s spec.json | xargs scancel' % sys.argv[0]))
+
+parser.add_argument(
+    '--printFinal', default=False, action='store_true',
+    help=('If specified, print a list of job ids issued by the final steps '
+          'of a specification. This can be used with the --startAfter option '
+          'to slurm-pipeline.py to make it schedule a different specification '
+          'to run only after the given specification finishes.'))
+
+args = parser.parse_args()
+
+status = SlurmPipelineStatus(args.specification)
+
+if args.printFinal:
+    print('\n'.join(map(str, status.finalJobs())))
+elif args.printUnfinished:
+    print('\n'.join(map(str, status.unfinishedJobs(args.squeueArgs))))
+else:
+    print(status.toStr(args.squeueArgs))
diff --git a/bin/slurm-pipeline.py b/bin/slurm-pipeline.py
index 6f94608..d041de9 100755
--- a/bin/slurm-pipeline.py
+++ b/bin/slurm-pipeline.py
@@ -1,18 +1,30 @@
 #!/usr/bin/env python
 
+"""
+Use the SlurmPipeline class to schedule the running of a pipeline job from its
+specification, printing (to stdout) a detailed specification that includes job
+ids. The status can be saved to a file and later given to
+slurm-pipeline-status.py to monitor job progress.
+"""
+
+from __future__ import print_function
+
+import sys
+import os
 import argparse
+from tempfile import mkstemp
 
 from slurm_pipeline import SlurmPipeline
 
 
 parser = argparse.ArgumentParser(
-    description='Schedule the execution of a pipeline on SLURM',
+    description='Schedule the execution of a pipeline on SLURM.',
     formatter_class=argparse.ArgumentDefaultsHelpFormatter)
 
 parser.add_argument(
-    '--specification', '-s', metavar='specification.json',
+    '--specification', '-s', metavar='specification.json', required=True,
     help=('The name of the file containing the pipeline specification, '
-          'in JSON format'))
+          'in JSON format.'))
 
 parser.add_argument(
     '--force', '-f', action='store_true', default=False,
@@ -20,7 +32,7 @@ parser.add_argument(
           'pre-existing results files.'))
 
 parser.add_argument(
-    '--firstStep', metavar='step-name', default=None,
+    '--firstStep', metavar='step-name',
     help=('The name of the first specification step to execute. Earlier '
           'steps will actually be executed but they will have SP_SIMULATE=1 '
           'in their environment, allowing them to skip doing actual work '
@@ -28,13 +40,13 @@ parser.add_argument(
           'later steps receive the correct tasks to operate on).'))
 
 parser.add_argument(
-    '--lastStep', metavar='step-name', default=None,
+    '--lastStep', metavar='step-name',
     help=('The name of the last specification step to execute. See the '
           'help text for --first-step for how this affects the environment '
           'in which step scripts are executed.'))
 
 parser.add_argument(
-    '--skip', metavar='step-name', default=None, action='append',
+    '--skip', metavar='step-name', action='append',
     help='Name a step that should be skipped. May be repeated.')
 
 parser.add_argument(
@@ -45,12 +57,36 @@ parser.add_argument(
           'scheduled can be seen when used as dependencies in later '
           'invocations of sbatch.'))
 
+parser.add_argument(
+    '--startAfter', nargs='*',
+    help=('Give a list of SLURM job ids that must complete (in any state - '
+          'either successfully or in error) before the initial step(s), '
+          'i.e., those with no dependencies, in the current specification may '
+          'begin.'))
+
 args, scriptArgs = parser.parse_known_args()
 
 sp = SlurmPipeline(args.specification)
 
-status = sp.schedule(force=args.force, firstStep=args.firstStep,
-                     lastStep=args.lastStep, sleep=args.sleep,
-                     scriptArgs=scriptArgs, skip=args.skip)
+startAfter = list(map(int, args.startAfter)) if args.startAfter else None
+
+status = sp.schedule(
+    force=args.force, firstStep=args.firstStep, lastStep=args.lastStep,
+    sleep=args.sleep, scriptArgs=scriptArgs, skip=args.skip,
+    startAfter=startAfter)
+
+statusAsJSON = sp.specificationToJSON(status)
+
+print(statusAsJSON)
 
-print(sp.specificationToJSON(status))
+# If the user forgot to redirect output to a file, save it to a tempfile
+# for them and let them know where it is. We do this because without the
+# status they will have no way to examine the job with
+# slurm-pipeline-status.py
+if os.isatty(1):
+    fd, filename = mkstemp(prefix='slurm-pipeline-status-', suffix='.json')
+    print('WARNING: You did not save stdout to a file, so I have '
+          'saved the specification status to %r for you.' % filename,
+          file=sys.stderr)
+    os.write(fd, statusAsJSON)
+    os.write(fd, '\n')
diff --git a/examples/word-count/scripts/long-words-only.sh b/examples/word-count/scripts/long-words-only.sh
index dd1775c..8d2ee55 100755
--- a/examples/word-count/scripts/long-words-only.sh
+++ b/examples/word-count/scripts/long-words-only.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/bin/bash -e
 
 task=$1
 out=output/long-words-$task.out
diff --git a/examples/word-count/scripts/one-word-per-line.sh b/examples/word-count/scripts/one-word-per-line.sh
index 5146b2e..a0a1d29 100755
--- a/examples/word-count/scripts/one-word-per-line.sh
+++ b/examples/word-count/scripts/one-word-per-line.sh
@@ -1,4 +1,6 @@
-#!/bin/bash
+#!/bin/bash -e
+
+[ -d output ] || mkdir output
 
 out=output/one-word-per-line.out
 
diff --git a/examples/word-count/scripts/summarize.sh b/examples/word-count/scripts/summarize.sh
index 7487bf8..15e1a80 100755
--- a/examples/word-count/scripts/summarize.sh
+++ b/examples/word-count/scripts/summarize.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/bin/bash -e
 
 out=output/summarize.out
 
diff --git a/setup.py b/setup.py
index 2071b15..5b5acd0 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
 from setuptools import setup
 
 setup(name='slurm-pipeline',
-      version='1.0.14',
+      version='1.1.0',
       packages=['slurm_pipeline'],
       include_package_data=True,
       url='https://github.com/acorg/slurm-pipeline',
diff --git a/slurm_pipeline/__init__.py b/slurm_pipeline/__init__.py
index 99f8110..488ee2a 100644
--- a/slurm_pipeline/__init__.py
+++ b/slurm_pipeline/__init__.py
@@ -1,3 +1,4 @@
 from .pipeline import SlurmPipeline
+from .status import SlurmPipelineStatus
 
-__all__ = ['SlurmPipeline']
+__all__ = ['SlurmPipeline', 'SlurmPipelineStatus']
diff --git a/slurm_pipeline/base.py b/slurm_pipeline/base.py
new file mode 100644
index 0000000..83e586b
--- /dev/null
+++ b/slurm_pipeline/base.py
@@ -0,0 +1,181 @@
+from six import string_types
+from json import load, dumps
+from collections import OrderedDict
+
+from .error import SpecificationError
+
+
+class SlurmPipelineBase(object):
+    """
+    Read a pipeline execution specification or status.
+
+    @param specification: Either a C{str} giving the name of a file containing
+        a JSON execution specification, or a C{dict} holding a correctly
+        formatted execution specification. A passed specification C{dict} is
+        not modified. See ../README.md for the expected contents of the
+        specification.
+    """
+    def __init__(self, specification):
+        if isinstance(specification, string_types):
+            specification = self._loadSpecification(specification)
+        self.checkSpecification(specification)
+        self.specification = specification.copy()
+        # Change the 'steps' key in the specification into an ordered dict.
+        # keyed by specification step name, with values that are the passed
+        # specification step dicts. This gives more convenient direct
+        # access to steps by name. The original JSON specification file has
+        # the steps in a list because order is important.
+        self.specification['steps'] = OrderedDict(
+            (step['name'], step) for step in self.specification['steps'])
+
+    @staticmethod
+    def checkSpecification(specification):
+        """
+        Check an execution specification is syntactically as expected.
+
+        @param specification: A C{dict} containing an execution specification.
+        @raise SpecificationError: if there is anything wrong with the
+            specification.
+        """
+        stepNames = set()
+
+        if not isinstance(specification, dict):
+            raise SpecificationError('The specification must be a dict (i.e., '
+                                     'a JSON object when loaded from a file)')
+
+        if 'steps' not in specification:
+            raise SpecificationError(
+                "The specification must have a top-level 'steps' key")
+
+        if not isinstance(specification['steps'], list):
+            raise SpecificationError("The 'steps' key must be a list")
+
+        for count, step in enumerate(specification['steps'], start=1):
+            if not isinstance(step, dict):
+                raise SpecificationError('Step %d is not a dictionary' % count)
+
+            try:
+                stepName = step['name']
+            except KeyError:
+                raise SpecificationError(
+                    "Step %d does not have a 'name' key" % count)
+
+            if not isinstance(stepName, string_types):
+                raise SpecificationError(
+                    "The 'name' key in step %d is not a string" % count)
+
+            if 'script' not in step:
+                raise SpecificationError(
+                    "Step %d (%r) does not have a 'script' key" %
+                    (count, stepName))
+
+            if not isinstance(step['script'], string_types):
+                raise SpecificationError(
+                    "The 'script' key in step %d (%r) is not a string" %
+                    (count, stepName))
+
+            if step['name'] in stepNames:
+                raise SpecificationError(
+                    'The name %r of step %d was already used in '
+                    'an earlier step' % (stepName, count))
+
+            if 'collect' in step and not step.get('dependencies', None):
+                raise SpecificationError(
+                    "Step %d (%r) is a 'collect' step but does not have any "
+                    "dependencies" %
+                    (count, stepName))
+
+            stepNames.add(stepName)
+
+            if 'dependencies' in step:
+                dependencies = step['dependencies']
+                if not isinstance(dependencies, list):
+                    raise SpecificationError(
+                        "Step %d (%r) has a non-list 'dependencies' key" %
+                        (count, stepName))
+
+                # A step cannot depend on itself.
+                if step['name'] in dependencies:
+                    raise SpecificationError(
+                        'Step %d (%r) depends itself' % (count, stepName))
+
+                # All named dependencies must already have been specified.
+                for dependency in dependencies:
+                    if dependency not in stepNames:
+                        raise SpecificationError(
+                            'Step %d (%r) depends on a non-existent (or '
+                            'not-yet-defined) step: %r' %
+                            (count, stepName, dependency))
+
+            else:
+                if 'error step' in step:
+                    raise SpecificationError(
+                        'Step %d (%r) is an error step but has no '
+                        "'dependencies' key" % (count, stepName))
+
+    @staticmethod
+    def _loadSpecification(specificationFile):
+        """
+        Load a JSON execution specification.
+
+        @param specificationFile: A C{str} file name containing a JSON
+            execution specification.
+        @raise ValueError: Will be raised (by L{json.load}) if
+            C{specificationFile} does not contain valid JSON.
+        @return: The parsed JSON specification as a C{dict}.
+        """
+        with open(specificationFile) as fp:
+            return load(fp)
+
+    @staticmethod
+    def specificationToJSON(specification):
+        """
+        Produce a JSON string for a specification.
+
+        @param specification: A specification C{dict}.
+        @return: A C{str} giving C{specification} in JSON form.
+        """
+        specification = specification.copy()
+
+        # Convert sets to lists and the steps ordered dictionary into a list.
+        specification['skip'] = list(specification['skip'])
+        steps = []
+        for step in specification['steps'].values():
+            tasks = step['tasks']
+            for taskName, jobIds in tasks.items():
+                tasks[taskName] = list(sorted(jobIds))
+            taskDependencies = step['taskDependencies']
+            for taskName, jobIds in taskDependencies.items():
+                taskDependencies[taskName] = list(sorted(jobIds))
+            steps.append(step)
+        specification['steps'] = steps
+        return dumps(specification, sort_keys=True, indent=2,
+                     separators=(',', ': '))
+
+    @staticmethod
+    def finalSteps(specification):
+        """
+        Find the specification steps on which nothing depends. These are the
+        the steps that must all finish before a specification has fully
+        finished running.
+
+        @param specification: A specification C{dict}.
+        @return: A C{set} of C{str} step names.
+        """
+        # This implementation is slightly inefficient, but is easy to
+        # understand. It would be faster to just gather all step names that
+        # appear in any step dependency and then return the set of all step
+        # names minus that set.
+        steps = specification['steps']
+        result = set()
+        for stepName in steps:
+            for step in steps.values():
+                try:
+                    if stepName in step['dependencies']:
+                        break
+                except KeyError:
+                    pass
+            else:
+                result.add(stepName)
+
+        return result
diff --git a/slurm_pipeline/error.py b/slurm_pipeline/error.py
new file mode 100644
index 0000000..f5b5c05
--- /dev/null
+++ b/slurm_pipeline/error.py
@@ -0,0 +1,14 @@
+class SlurmPipelineError(Exception):
+    'Base class of all SlurmPipeline exceptions.'
+
+
+class SchedulingError(SlurmPipelineError):
+    'An error in scheduling execution.'
+
+
+class SpecificationError(SlurmPipelineError):
+    'An error was found in a specification.'
+
+
+class SQueueError(SlurmPipelineError):
+    'A problem was found in the output of squeue'
diff --git a/slurm_pipeline/pipeline.py b/slurm_pipeline/pipeline.py
index 8161b2c..8ee6f8d 100644
--- a/slurm_pipeline/pipeline.py
+++ b/slurm_pipeline/pipeline.py
@@ -2,38 +2,22 @@ import os
 from os import path, environ
 import re
 import time
-from six import string_types
-from json import load, dumps
 import subprocess
-from collections import defaultdict, OrderedDict
+from collections import defaultdict
 
 try:
     from subprocess import DEVNULL  # py3k
 except ImportError:
     DEVNULL = open(os.devnull, 'r+b')
 
+from .base import SlurmPipelineBase
+from .error import SchedulingError, SpecificationError
 
-class SlurmPipelineError(Exception):
-    'Base class of all SlurmPipeline exceptions.'
 
-
-class SchedulingError(SlurmPipelineError):
-    'An error in scheduling execution.'
-
-
-class SpecificationError(SlurmPipelineError):
-    'An error was found in a specification.'
-
-
-class SlurmPipeline(object):
+class SlurmPipeline(SlurmPipelineBase):
     """
     Read a pipeline execution specification and make it possible to schedule
     it via SLURM.
-
-    @param specification: Either a C{str} giving the name of a file containing
-        a JSON execution specification, or a C{dict} holding a correctly
-        formatted execution specification. Note that in the latter case the
-        passed specification C{dict} will be modified by this script.
     """
 
     # In script output, look for lines of the form
@@ -42,14 +26,35 @@ class SlurmPipeline(object):
     # job ids. The following regex just matches the first part of that.
     TASK_NAME_LINE = re.compile('^TASK:\s+(\S+)\s*')
 
-    def __init__(self, specification):
-        if isinstance(specification, string_types):
-            specification = self._loadSpecification(specification)
-        self._checkSpecification(specification)
-        self.specification = specification
+    @staticmethod
+    def checkSpecification(specification):
+        """
+        Check an execution specification is syntactically as expected.
+
+        @param specification: A C{dict} containing an execution specification.
+        @raise SpecificationError: if there is anything wrong with the
+            specification.
+        """
+        if 'scheduledAt' in specification:
+            raise SpecificationError(
+                "The specification has a top-level 'scheduledAt' key, "
+                'but was not passed as a status specification')
+
+        for count, step in enumerate(specification['steps'], start=1):
+            if not path.exists(step['script']):
+                raise SpecificationError(
+                    'The script %r in step %d does not exist' %
+                    (step['script'], count))
+
+            if not os.access(step['script'], os.X_OK):
+                raise SpecificationError(
+                    'The script %r in step %d is not executable' %
+                    (step['script'], count))
+
+        SlurmPipelineBase.checkSpecification(specification)
 
     def schedule(self, force=False, firstStep=None, lastStep=None, sleep=0.0,
-                 scriptArgs=None, skip=None):
+                 scriptArgs=None, skip=None, startAfter=None):
         """
         Schedule the running of our execution specification.
 
@@ -77,19 +82,20 @@ class SlurmPipeline(object):
             Those step scripts will still be run, but will have C{SP_SKIP=1}
             in their environment. Steps may also be skipped by using
             C{skip: "true"} in the pipeline specification file.
+        @param startAfter: A C{list} of C{int} job ids that must complete
+            (either successully or unsuccessully, it doesn't matter) before
+            steps in the current specification may start. If C{None}, steps in
+            the current specification may start immediately.
         @raise SchedulingError: If there is a problem with the first, last, or
             skipped steps, as determined by self._checkRuntime.
         @return: A specification C{dict}. This is a copy of the original
             specification, updated with information about this scheduling.
         """
-        specification = self.specification.copy()
-        # steps is keyed by (ordered) specification step name, with values
-        # that are specification step dicts. This provides convenient
-        # direct access to steps by name.
-        steps = OrderedDict((s['name'], s) for s in specification['steps'])
+        specification = self.specification
+        steps = specification['steps']
         nSteps = len(steps)
         if nSteps and lastStep is not None and firstStep is None:
-            firstStep = specification['steps'][0]['name']
+            firstStep = list(specification['steps'])[0]
         skip = set(skip or ())
         self._checkRuntime(steps, firstStep, lastStep, skip)
         specification.update({
@@ -99,6 +105,7 @@ class SlurmPipeline(object):
             'scheduledAt': time.time(),
             'scriptArgs': scriptArgs,
             'skip': skip,
+            'startAfter': startAfter,
             'steps': steps,
         })
 
@@ -129,7 +136,8 @@ class SlurmPipeline(object):
                 simulate = False
 
             self._scheduleStep(stepName, steps, simulate, scriptArgs,
-                               stepName in skip or ('skip' in steps[stepName]))
+                               stepName in skip or ('skip' in steps[stepName]),
+                               startAfter)
 
             # If we're supposed to pause between scheduling steps and this
             # is not the last step, then sleep.
@@ -138,7 +146,8 @@ class SlurmPipeline(object):
 
         return specification
 
-    def _scheduleStep(self, stepName, steps, simulate, scriptArgs, skip):
+    def _scheduleStep(self, stepName, steps, simulate, scriptArgs, skip,
+                      startAfter):
         """
         Schedule a single execution step.
 
@@ -152,6 +161,10 @@ class SlurmPipeline(object):
             indicated to the script by SP_SKIP=1 in its environment. SP_SKIP
             will be 0 in non-skipped steps. It is up to the script, which is
             run in either case, to decide how to behave.
+        @param startAfter: A C{list} of C{int} job ids that must complete
+            (either successully or unsuccessully, it doesn't matter) before
+            steps in the current specification may start. If C{None}, steps in
+            the current specification may start immediately.
         """
         step = steps[stepName]
         step['tasks'] = defaultdict(set)
@@ -189,10 +202,7 @@ class SlurmPipeline(object):
                     sorted(('%s:%d' % (after, jobId))
                            for jobIds in taskDependencies.values()
                            for jobId in jobIds))
-                if dependencies:
-                    env['SP_DEPENDENCY_ARG'] = '--dependency=' + dependencies
-                else:
-                    env.pop('SP_DEPENDENCY_ARG', None)
+                env['SP_DEPENDENCY_ARG'] = '--dependency=' + dependencies
                 self._runStepScript(step, sorted(taskDependencies), env)
             else:
                 # The script for this step gets run once for each task in the
@@ -216,17 +226,28 @@ class SlurmPipeline(object):
             # Either this step has no dependencies or the steps it is
             # dependent on did not start any tasks. If there are no
             # dependencies, run the script with the originally passed
-            # command line arguments. If there were dependencies but no
-            # tasks have been started, run with no command line arguments.
-            if 'dependencies' in step or scriptArgs is None:
+            # command line arguments (if any) and the --startAfter job
+            # dependencies (if any). If there were dependencies but no
+            # tasks have been started, run the step with no command line
+            # arguments.
+
+            env = environ.copy()
+            if 'dependencies' in step:
                 args = []
+                env.pop('SP_DEPENDENCY_ARG', None)
             else:
-                args = list(map(str, scriptArgs))
-            env = environ.copy()
+                args = [] if scriptArgs is None else list(map(str, scriptArgs))
+                if startAfter:
+                    dependencies = ','.join(
+                        sorted(('afterany:%d' % jobId)
+                               for jobId in startAfter))
+                    env['SP_DEPENDENCY_ARG'] = '--dependency=' + dependencies
+                else:
+                    env.pop('SP_DEPENDENCY_ARG', None)
+
             env['SP_ORIGINAL_ARGS'] = scriptArgsStr
             env['SP_SIMULATE'] = str(int(simulate))
             env['SP_SKIP'] = str(int(skip))
-            env.pop('SP_DEPENDENCY_ARG', None)
             self._runStepScript(step, args, env)
 
         step['scheduledAt'] = time.time()
@@ -276,96 +297,8 @@ class SlurmPipeline(object):
                         (taskName, jobIds, script, step['name']))
                 tasks[taskName].update(jobIds)
 
-    def _loadSpecification(self, specificationFile):
-        """
-        Load a JSON execution specification.
-
-        @param specificationFile: A C{str} file name containing a JSON
-            execution specification.
-        @raise ValueError: Will be raised (by L{json.load}) if
-            C{specificationFile} does not contain valid JSON.
-        @return: The parsed JSON specification as a C{dict}.
-        """
-        with open(specificationFile) as fp:
-            return load(fp)
-
-    def _checkSpecification(self, specification):
-        """
-        Check an execution specification is syntactically as expected.
-
-        @param specification: A C{dict} containing an execution specification.
-        @raise SpecificationError: if there is anything wrong with the
-            specification.
-        """
-        stepNames = set()
-
-        if not isinstance(specification, dict):
-            raise SpecificationError('The specification must be a dict (i.e., '
-                                     'a JSON object when loaded from a file)')
-
-        if 'steps' not in specification:
-            raise SpecificationError(
-                'The specification must have a top-level "steps" key')
-
-        if not isinstance(specification['steps'], list):
-            raise SpecificationError('The "steps" key must be a list')
-
-        for count, step in enumerate(specification['steps'], start=1):
-            if not isinstance(step, dict):
-                raise SpecificationError('Step %d is not a dictionary' % count)
-
-            if 'script' not in step:
-                raise SpecificationError(
-                    'Step %d does not have a "script" key' % count)
-
-            if not isinstance(step['script'], string_types):
-                raise SpecificationError(
-                    'The "script" key in step %d is not a string' % count)
-
-            if not path.exists(step['script']):
-                raise SpecificationError(
-                    'The script %r in step %d does not exist' %
-                    (step['script'], count))
-
-            if not os.access(step['script'], os.X_OK):
-                raise SpecificationError(
-                    'The script %r in step %d is not executable' %
-                    (step['script'], count))
-
-            if 'name' not in step:
-                raise SpecificationError(
-                    'Step %d does not have a "name" key' % count)
-
-            if not isinstance(step['name'], string_types):
-                raise SpecificationError(
-                    'The "name" key in step %d is not a string' % count)
-
-            if step['name'] in stepNames:
-                raise SpecificationError(
-                    'The name %r of step %d was already used in '
-                    'an earlier step' % (step['name'], count))
-
-            stepNames.add(step['name'])
-
-            if 'dependencies' in step:
-                dependencies = step['dependencies']
-                if not isinstance(dependencies, list):
-                    raise SpecificationError(
-                        'Step %d has a non-list "dependencies" key' % count)
-
-                # All named dependencies must already have been specified.
-                for dependency in dependencies:
-                    if dependency not in stepNames:
-                        raise SpecificationError(
-                            'Step %d depends on a non-existent (or '
-                            'not-yet-defined) step: %r' % (count, dependency))
-
-            if 'error step' in step and 'dependencies' not in step:
-                    raise SpecificationError(
-                        'Step "%s" is an error step but has no "dependencies" '
-                        'key' % (step['name']))
-
-    def _checkRuntime(self, steps, firstStep=None, lastStep=None, skip=None):
+    @staticmethod
+    def _checkRuntime(steps, firstStep=None, lastStep=None, skip=None):
         """
         Check that a proposed scheduling makes sense.
 
@@ -380,8 +313,8 @@ class SlurmPipeline(object):
             the last or first step are unknown, or if asked to skip a
             non-existent step.
         @return: An C{OrderedDict} keyed by specification step name,
-            with values that are C{self.specification} step dicts. This
-            provides convenient / direct access to steps by name.
+            with values that are step C{dict}s. This provides convenient /
+            direct access to steps by name.
         """
         firstStepFound = False
 
@@ -410,27 +343,3 @@ class SlurmPipeline(object):
                     'Unknown skip step%s (%s) passed to schedule' % (
                         '' if len(unknownSteps) == 1 else 's',
                         ', '.join(sorted(unknownSteps))))
-
-    @staticmethod
-    def specificationToJSON(specification):
-        """
-        Convert a specification to a JSON string.
-
-        @param specification: A specification C{dict}. This parameter is not
-            modified.
-        @return: A C{str} giving C{specification} in JSON form.
-        """
-        specification = specification.copy()
-
-        # Convert sets to lists and the steps ordered dictionary into a list.
-        specification['skip'] = list(specification['skip'])
-        steps = []
-        for step in specification['steps'].values():
-            for taskName, jobIds in step['tasks'].items():
-                step['tasks'][taskName] = list(sorted(jobIds))
-            for taskName, jobIds in step['taskDependencies'].items():
-                step['taskDependencies'][taskName] = list(sorted(jobIds))
-            steps.append(step)
-        specification['steps'] = steps
-        return dumps(specification, sort_keys=True, indent=2,
-                     separators=(',', ': '))
diff --git a/slurm_pipeline/squeue.py b/slurm_pipeline/squeue.py
new file mode 100644
index 0000000..9fe5f5d
--- /dev/null
+++ b/slurm_pipeline/squeue.py
@@ -0,0 +1,109 @@
+from os import getlogin
+import subprocess
+
+from slurm_pipeline.error import SQueueError
+
+
+class SQueue(object):
+    """
+    Fetch information about job id status from squeue.
+
+    @param squeueArgs: A C{list} of C{str} arguments to pass to squeue
+        (including the 'squeue' command itself). If C{None}, the user's
+        login name will be appended to squeue -u.
+    """
+    def __init__(self, squeueArgs=None):
+        args = squeueArgs or ['squeue', '-u', getlogin()]
+        try:
+            out = subprocess.check_output(args, universal_newlines=True)
+        except OSError as e:
+            raise SQueueError("Encountered OSError (%s) when running '%s'" %
+                              (e, ' '.join(args)))
+
+        columnInfo = {
+            'JOBID': {
+                'attr': 'jobid',
+                'offset': None,
+            },
+            'ST': {
+                'attr': 'status',
+                'offset': None,
+            },
+            'TIME': {
+                'attr': 'time',
+                'offset': None,
+            },
+            'NODELIST(REASON)': {
+                'attr': 'nodelistReason',
+                'offset': None,
+            },
+        }
+
+        jobs = {}
+
+        for count, line in enumerate(out.split('\n')):
+            fields = line.split()
+            if count == 0:
+                # Process header line.
+                colsFound = 0
+                for offset, title in enumerate(fields):
+                    if title in columnInfo:
+                        if columnInfo[title]['offset'] is not None:
+                            raise SQueueError(
+                                "Multiple columns with title %r found in '%s' "
+                                'output' % (title, ' '.join(args)))
+                        else:
+                            columnInfo[title]['offset'] = offset
+                            colsFound += 1
+                if colsFound != len(columnInfo):
+                    notFound = [title for title in columnInfo
+                                if columnInfo[title]['offset'] is None]
+                    raise SQueueError(
+                        "Required column%s (%s) not found in '%s' "
+                        'output' % ('' if len(notFound) == 1 else 's',
+                                    ', '.join(sorted(notFound)),
+                                    ' '.join(args)))
+            elif line:
+                jobId = int(fields[columnInfo['JOBID']['offset']])
+                if jobId in jobs:
+                    raise SQueueError(
+                        "Job id %d found more than once in '%s' "
+                        'output' % (jobId, ' '.join(args)))
+                jobInfo = {}
+                for title in columnInfo:
+                    if title != 'JOBID':
+                        jobInfo[columnInfo[title]['attr']] = fields[
+                            columnInfo[title]['offset']]
+                jobs[jobId] = jobInfo
+
+        self.jobs = jobs
+
+    def finished(self, jobId):
+        """
+        Has a job finished?
+
+        @param jobId: An C{int} job id.
+        @return: a C{bool}, C{True} if the job has finished, C{False} if not.
+        """
+        return jobId not in self.jobs
+
+    def summarize(self, jobId):
+        """
+        Summarize a job's status.
+
+        @param jobId: An C{int} job id.
+        @raise KeyError: If the job has already finished.
+        @return: a C{str} describing the job's status.
+        """
+        try:
+            jobInfo = self.jobs[jobId]
+        except KeyError:
+            return 'Finished'
+        else:
+            nodelistReason = jobInfo['nodelistReason']
+            if nodelistReason.startswith('('):
+                extra = 'Reason=%s' % nodelistReason[1:-1]
+            else:
+                extra = 'Nodelist=%s' % nodelistReason
+            return 'Status=%s Time=%s %s' % (
+                jobInfo['status'], jobInfo['time'], extra)
diff --git a/slurm_pipeline/status.py b/slurm_pipeline/status.py
new file mode 100644
index 0000000..2b2e0d7
--- /dev/null
+++ b/slurm_pipeline/status.py
@@ -0,0 +1,294 @@
+from __future__ import division
+
+from time import gmtime, strftime
+
+from .base import SlurmPipelineBase
+from .error import SpecificationError
+from .squeue import SQueue
+
+
+def secondsToTime(seconds):
+    """
+    Convert a number of seconds to a time string.
+
+    @param seconds: A C{float} number of seconds since the epoch, in UTC.
+    @return: A C{str} giving the date/time corresponding to C{seconds}.
+    """
+    return strftime('%Y-%m-%d %H:%M:%S', gmtime(seconds))
+
+
+class SlurmPipelineStatus(SlurmPipelineBase):
+    """
+    Read a pipeline execution status specification and supply methods for
+    examining job ids, step status, etc.
+    """
+
+    @staticmethod
+    def checkSpecification(specification):
+        """
+        Check an execution specification is syntactically as expected.
+
+        @param specification: A C{dict} containing an execution specification.
+        @raise SpecificationError: if there is anything wrong with the
+            specification.
+        """
+        if 'scheduledAt' not in specification:
+            raise SpecificationError(
+                "The specification status has no top-level 'scheduledAt' key")
+
+        SlurmPipelineBase.checkSpecification(specification)
+
+    def finalJobs(self, specification):
+        """
+        Get the job ids emitted by the final steps of a specification.
+
+        @param specification: A C{dict} containing an execution specification.
+        @return: A C{set} of C{int} job ids.
+        """
+        steps = specification['steps']
+        result = set()
+        for stepName in self.finalSteps(specification):
+            for jobIds in steps[stepName]['tasks'].values():
+                result.update(jobIds)
+        return result
+
+    def unfinishedJobs(self, specification, squeueArgs=None):
+        """
+        Get the ids of unfinished jobs emitted by a specification.
+
+        @param specification: A C{dict} containing an execution specification.
+        @param squeueArgs: A C{list} of C{str} arguments to pass to squeue
+            (including the 'squeue' command itself). If C{None}, the user's
+            login name will be appended to squeue -u.
+        @return: A C{set} of C{int} unifinished job ids.
+        """
+        squeue = SQueue(squeueArgs)
+        result = set()
+        for stepName in specification['steps']:
+            jobIds, jobIdsFinished = self.stepJobIdSummary(stepName, squeue)
+            result.update(jobIds - jobIdsFinished)
+        return result
+
+    def stepDependentJobIdSummary(self, stepName, squeue):
+        """
+        Which dependent jobs must a step wait on and which are finished?
+
+        @param stepName: The C{str} name of a step.
+        @param squeue: An C{SQueue} instance.
+        @return: A C{tuple} of two C{set}s, holding the emitted and finished
+            C{int} job ids.
+        """
+        step = self.specification['steps'][stepName]
+
+        # Tasks depended on by this step.
+        taskCount = len(step['taskDependencies'])
+
+        jobIdsCompleted = set()
+        jobIds = set()
+        if taskCount:
+            for taskJobIds in step['taskDependencies'].values():
+                jobIds.update(taskJobIds)
+            for jobId in jobIds:
+                if squeue.finished(jobId):
+                    jobIdsCompleted.add(jobId)
+        return jobIds, jobIdsCompleted
+
+    def stepJobIdSummary(self, stepName, squeue):
+        """
+        Which jobs did a step emit and which are finished?
+
+        @param stepName: The C{str} name of a step.
+        @param squeue: An C{SQueue} instance.
+        @return: A C{tuple} of two C{set}s, holding the emitted and finished
+            C{int} job ids.
+        """
+        step = self.specification['steps'][stepName]
+
+        # Tasks launched by this step.
+        taskCount = len(step['tasks'])
+
+        jobIdsCompleted = set()
+        jobIds = set()
+        if taskCount:
+            for taskJobIds in step['tasks'].values():
+                jobIds.update(taskJobIds)
+            for jobId in jobIds:
+                if squeue.finished(jobId):
+                    jobIdsCompleted.add(jobId)
+        return jobIds, jobIdsCompleted
+
+    def toStr(self, squeueArgs=None):
+        """
+        Get a printable summary of a status specification, including job
+        status.
+
+        @param squeueArgs: A C{list} of C{str} arguments to pass to squeue
+            (including the 'squeue' command itself). If C{None}, the user's
+            login name will be appended to squeue -u.
+        @raises SQueueError: If job status information cannot be read from
+            squeue.
+        @return: A C{str} representation of the status specification.
+        """
+        specification = self.specification
+        result = [
+            'Scheduled at: %s' % secondsToTime(specification['scheduledAt']),
+            'Scheduling arguments:',
+            '  First step: %s' % specification['firstStep'],
+            '  Force: %s' % specification['force'],
+            '  Last step: %s' % specification['lastStep'],
+        ]
+        append = result.append
+
+        if specification['scriptArgs']:
+            append('  Script arguments: %s' %
+                   ' '.join(specification['scriptArgs']))
+        else:
+            append('  Script arguments: <None>')
+
+        if specification['skip']:
+            append('  Skip: %s' % ', '.join(specification['skip']))
+        else:
+            append('  Skip: <None>')
+
+        if specification['startAfter']:
+            append('  Start after: %s' % ', '.join(
+                specification['startAfter']))
+        else:
+            append('  Start after: <None>')
+
+        squeue = SQueue(squeueArgs)
+        steps = specification['steps']
+
+        stepSummary = ['Step summary:']
+        totalJobIdsEmitted = 0
+        totalJobIdsFinished = 0
+        for count, stepName in enumerate(steps, start=1):
+            jobIdsEmitted, jobIdsFinished = map(
+                len, self.stepJobIdSummary(stepName, squeue))
+            totalJobIdsEmitted += jobIdsEmitted
+            totalJobIdsFinished += jobIdsFinished
+
+            percent = (0.0 if jobIdsEmitted == 0 else
+                       jobIdsFinished / jobIdsEmitted * 100.0)
+            if jobIdsEmitted:
+                stepSummary.append(
+                    '  %s: %d job%s emitted, %d (%.2f%%) finished' %
+                    (stepName, jobIdsEmitted,
+                     '' if jobIdsEmitted == 1 else 's', jobIdsFinished,
+                     percent))
+            else:
+                stepSummary.append('  %s: no jobs emitted' % stepName)
+
+        percent = (100.0 if totalJobIdsEmitted == 0 else
+                   totalJobIdsFinished / totalJobIdsEmitted * 100.0)
+
+        append('%d job%s emitted in total, of which %d (%.2f%%) are finished' %
+               (totalJobIdsEmitted, '' if totalJobIdsEmitted == 1 else 's',
+                totalJobIdsFinished, percent))
+
+        result.extend(stepSummary)
+
+        for count, stepName in enumerate(steps, start=1):
+            step = steps[stepName]
+            append('Step %d: %s' % (count, stepName))
+
+            try:
+                dependencyCount = len(step['dependencies'])
+            except KeyError:
+                dependencyCount = 0
+
+            if dependencyCount:
+                append(
+                    '  %d step %s: %s' %
+                    (dependencyCount,
+                     'dependency' if dependencyCount == 1 else 'dependencies',
+                     ', '.join(step['dependencies'])))
+
+                taskDependencyCount = len(step['taskDependencies'])
+
+                jobIds, jobIdsFinished = self.stepDependentJobIdSummary(
+                    stepName, squeue)
+
+                jobIdCount = len(jobIds)
+                jobIdCompletedCount = len(jobIdsFinished)
+
+                append(
+                    '    Dependent on %d task%s emitted by the dependent '
+                    'step%s' %
+                    (taskDependencyCount,
+                     '' if taskDependencyCount == 1 else 's',
+                     '' if dependencyCount == 1 else 's'))
+
+                if jobIdCount:
+                    append(
+                        '    %d job%s started by the dependent task%s, of '
+                        'which %d (%.2f%%) are finished' %
+                        (jobIdCount, '' if jobIdCount == 1 else 's',
+                         '' if dependencyCount else 's',
+                         jobIdCompletedCount,
+                         100.0 if jobIdCount == 0 else
+                         (jobIdCompletedCount / jobIdCount * 100.0)))
+                elif taskDependencyCount:
+                    append('    0 jobs started by the dependent task%s' % (
+                        '' if taskDependencyCount == 1 else 's'))
+
+                if taskDependencyCount:
+                    append('    Dependent tasks:')
+                    for taskName in sorted(step['taskDependencies']):
+                        jobIds = step['taskDependencies'][taskName]
+                        append('      %s' % taskName)
+                        for jobId in sorted(jobIds):
+                            append('        Job %d: %s' %
+                                   (jobId, squeue.summarize(jobId)))
+            else:
+                assert len(step['taskDependencies']) == 0
+                append('  No dependencies.')
+
+            # Tasks launched by this step.
+            taskCount = len(step['tasks'])
+
+            if taskCount:
+                append(
+                    '  %d task%s emitted by this step' %
+                    (taskCount, '' if taskCount == 1 else 's'))
+
+                jobIds, jobIdsCompleted = self.stepJobIdSummary(stepName,
+                                                                squeue)
+
+                jobIdCount = len(jobIds)
+                jobIdCompletedCount = len(jobIdsCompleted)
+
+                if jobIdCount:
+                    append(
+                        '    %d job%s started by %s, of which %d (%.2f%%) '
+                        'are finished' %
+                        (jobIdCount, '' if jobIdCount == 1 else 's',
+                         'this task' if taskCount else 'these tasks',
+                         jobIdCompletedCount,
+                         100.0 if jobIdCount == 0 else
+                         jobIdCompletedCount / jobIdCount * 100.0))
+                else:
+                    append('    0 jobs started by %s' %
+                           ('this task' if taskCount == 1 else 'these tasks'))
+
+                if taskCount:
+                    append('    Tasks:')
+                    for taskName in sorted(step['tasks']):
+                        jobIds = step['tasks'][taskName]
+                        append('      %s' % taskName)
+                        for jobId in sorted(jobIds):
+                            append('        Job %d: %s' %
+                                   (jobId, squeue.summarize(jobId)))
+            else:
+                assert len(step['tasks']) == 0
+                append('  No tasks emitted by this step')
+
+            result.extend([
+                '  Working directory: %s' % step['cwd'],
+                '  Scheduled at: %s' % secondsToTime(step['scheduledAt']),
+                '  Script: %s' % step['script'],
+                '  Simulate: %s' % step['simulate'],
+                '  Skip: %s' % step['skip'],
+            ])
+
+        return '\n'.join(result)
diff --git a/tox.ini b/tox.ini
index 4915321..671b4e5 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,4 @@
 [tox]
-# Adding pypy to the envlist doesn't work yet.
 envlist = py27,py35,pypy
 skip_missing_interpreters = True
 


Test patch:
diff --git a/test/test_base.py b/test/test_base.py
new file mode 100644
index 0000000..5c66433
--- /dev/null
+++ b/test/test_base.py
@@ -0,0 +1,449 @@
+from unittest import TestCase
+from six.moves import builtins
+from six import assertRaisesRegex, PY3
+from json import dumps
+import platform
+
+from slurm_pipeline.base import SlurmPipelineBase
+from slurm_pipeline.error import SpecificationError
+
+try:
+    from unittest.mock import patch
+except ImportError:
+    from mock import patch
+
+from .mocking import mockOpen
+
+PYPY = platform.python_implementation() == 'PyPy'
+
+
+class TestSlurmPipelineBase(TestCase):
+    """
+    Tests for the slurm_pipeline.pipeline.SlurmPipelineBase class.
+    """
+
+    def testEmptyJSON(self):
+        """
+        If the specification file is empty, a ValueError must be raised.
+        """
+        data = ''
+        mockOpener = mockOpen(read_data=data)
+        with patch.object(builtins, 'open', mockOpener):
+            if PY3:
+                error = '^Expecting value: line 1 column 1 \(char 0\)$'
+            elif PYPY:
+                # The error message in the exception has a NUL in it.
+                error = ("^No JSON object could be decoded: unexpected "
+                         "'\\000' at char 0$")
+            else:
+                error = '^No JSON object could be decoded$'
+            assertRaisesRegex(self, ValueError, error, SlurmPipelineBase,
+                              'file')
+
+    def testInvalidJSON(self):
+        """
+        If the specification file does not contain valid JSON, a ValueError
+        must be raised.
+        """
+        data = '{'
+        mockOpener = mockOpen(read_data=data)
+        with patch.object(builtins, 'open', mockOpener):
+            if PY3:
+                error = ('^Expecting property name enclosed in double '
+                         'quotes: line 1 column 2 \(char 1\)$')
+            elif PYPY:
+                # The error message in the exception has a NUL in it.
+                error = ("^No JSON object could be decoded: unexpected "
+                         "'\\000' at char 0$")
+            else:
+                error = '^Expecting object: line 1 column 1 \(char 0\)$'
+            assertRaisesRegex(self, ValueError, error, SlurmPipelineBase,
+                              'file')
+
+    def testJSONList(self):
+        """
+        If the specification file contains valid JSON but is a list instead
+        of a JSON object, a SpecificationError must be raised.
+        """
+        data = '[]'
+        mockOpener = mockOpen(read_data=data)
+        with patch.object(builtins, 'open', mockOpener):
+            error = ('^The specification must be a dict \(i\.e\., a JSON '
+                     'object when loaded from a file\)$')
+            assertRaisesRegex(self, SpecificationError, error,
+                              SlurmPipelineBase, 'file')
+
+    def testList(self):
+        """
+        If the specification is passed a list directly instead of a dict, a
+        SpecificationError must be raised.
+        """
+        error = ('^The specification must be a dict \(i\.e\., a JSON '
+                 'object when loaded from a file\)$')
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          [])
+
+    def testNoSteps(self):
+        """
+        If the specification dict does not contain a 'steps' key, a
+        SpecificationError must be raised.
+        """
+        error = "^The specification must have a top-level 'steps' key$"
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {})
+
+    def testStepsMustBeAList(self):
+        """
+        If the specification dict does not contain a 'steps' key whose value
+        is a list, a SpecificationError must be raised.
+        """
+        error = "^The 'steps' key must be a list$"
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {'steps': None})
+
+    def testNonDictionaryStep(self):
+        """
+        If the specification steps contains a step that is not a dict, a
+        SpecificationError must be raised.
+        """
+        error = '^Step 1 is not a dictionary$'
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {
+                              'steps': [
+                                  None,
+                              ]
+                          })
+
+    def testStepWithoutScript(self):
+        """
+        If the specification steps contains a step that does not have a
+        'script' key, a SpecificationError must be raised.
+        """
+        error = "^Step 2 \('name2'\) does not have a 'script' key$"
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {
+                              'steps': [
+                                  {
+                                      'name': 'name1',
+                                      'script': 'script',
+                                  },
+                                  {
+                                      'name': 'name2',
+                                  },
+                              ]
+                          })
+
+    def testNonStringScript(self):
+        """
+        If a step has a 'script' key that is not a string, a SpecificationError
+        must be raised.
+        """
+        error = "^The 'script' key in step 1 \('name'\) is not a string$"
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {
+                              'steps': [
+                                  {
+                                      'name': 'name',
+                                      'script': None,
+                                  },
+                              ]
+                          })
+
+    def testStepWithoutName(self):
+        """
+        If the specification steps contains a step that does not have a
+        'name' key, a SpecificationError must be raised.
+        """
+        error = "^Step 1 does not have a 'name' key$"
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {
+                              'steps': [
+                                  {
+                                      'script': 'script',
+                                  },
+                              ]
+                          })
+
+    def testNonStringName(self):
+        """
+        If a step has a 'name' key that is not a string, a SpecificationError
+        must be raised.
+        """
+        error = "^The 'name' key in step 1 is not a string$"
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {
+                              'steps': [
+                                  {
+                                      'name': None,
+                                      'script': 'script',
+                                  },
+                              ]
+                          })
+
+    def testDuplicateName(self):
+        """
+        If two steps have the same name, a SpecificationError must be raised.
+        specification.
+        """
+        error = ("^The name 'name' of step 2 was already used in an "
+                 "earlier step$")
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {
+                              'steps': [
+                                  {
+                                      'name': 'name',
+                                      'script': 'script1',
+                                  },
+                                  {
+                                      'name': 'name',
+                                      'script': 'script2',
+                                  },
+                              ],
+                          })
+
+    def testNonListDependencies(self):
+        """
+        If a step has a 'dependencies' key that is not a list, a
+        SpecificationError must be raised.
+        """
+        error = "^Step 1 \('name'\) has a non-list 'dependencies' key$"
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {
+                              'steps': [
+                                  {
+                                      'dependencies': None,
+                                      'name': 'name',
+                                      'script': 'script',
+                                  },
+                              ]
+                          })
+
+    def testNonExistentDependency(self):
+        """
+        If a step has a 'dependencies' key that mentions an unknown step,
+        a SpecificationError must be raised.
+        """
+        error = ("^Step 2 \('name2'\) depends on a non-existent \(or "
+                 "not-yet-defined\) step: 'unknown'$")
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {
+                              'steps': [
+                                  {
+                                      'name': 'name1',
+                                      'script': 'script',
+                                  },
+                                  {
+                                      'dependencies': ['unknown'],
+                                      'name': 'name2',
+                                      'script': 'script',
+                                  },
+                              ]
+                          })
+
+    def testStepDependentOnItself(self):
+        """
+        If a step has a 'dependencies' key that mentions that same step,
+        a SpecificationError must be raised.
+        """
+        error = ("^Step 1 \('name'\) depends itself$")
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {
+                              'steps': [
+                                  {
+                                      'dependencies': ['name'],
+                                      'name': 'name',
+                                      'script': 'script',
+                                  },
+                              ]
+                          })
+
+    def testNonYetDefinedDependency(self):
+        """
+        If a step has a 'dependencies' key that mentions a step that exists
+        but that has not yet been defined in the specification steps, a
+        SpecificationError must be raised.
+        """
+        error = ("^Step 1 \('name1'\) depends on a non-existent \(or "
+                 "not-yet-defined\) step: 'name2'$")
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineBase,
+                          {
+                              'steps': [
+                                  {
+                                      'dependencies': ['name2'],
+                                      'name': 'name1',
+                                      'script': 'script',
+                                  },
+                                  {
+                                      'name': 'name2',
+                                      'script': 'script',
+                                  },
+                              ]
+                          })
+
+    def testSpecificationIsStored(self):
+        """
+        If well-formed JSON is passed, it must be read and stored as the
+        specification. The 'steps' list must be converted to a dict.
+        """
+        specification = {
+            'steps': [
+                {
+                    'name': 'name1',
+                    'script': 'script1',
+                },
+                {
+                    'name': 'name2',
+                    'script': 'script2',
+                },
+            ],
+        }
+        data = dumps(specification)
+        mockOpener = mockOpen(read_data=data)
+        with patch.object(builtins, 'open', mockOpener):
+            spb = SlurmPipelineBase('file')
+            expected = {
+                'steps': {
+                    'name1':
+                        {
+                            'name': 'name1',
+                            'script': 'script1',
+                        },
+                    'name2':
+                        {
+                            'name': 'name2',
+                            'script': 'script2',
+                        },
+                },
+            }
+            self.assertEqual(expected, spb.specification)
+
+    def testFinalStepsWithOneStep(self):
+        """
+        If a specification has only one step, finalSteps must return that step.
+        """
+        specification = {
+            'steps': [
+                {
+                    'name': 'name1',
+                    'script': 'script1',
+                },
+            ],
+        }
+        spb = SlurmPipelineBase(specification)
+        self.assertEqual(set(('name1',)), spb.finalSteps(spb.specification))
+
+    def testFinalStepsWithTwoSteps(self):
+        """
+        If a specification has two steps with no dependencies, finalSteps must
+        return both steps.
+        """
+        specification = {
+            'steps': [
+                {
+                    'name': 'name1',
+                    'script': 'script1',
+                },
+                {
+                    'name': 'name2',
+                    'script': 'script2',
+                },
+            ],
+        }
+        spb = SlurmPipelineBase(specification)
+        self.assertEqual(set(('name1', 'name2')),
+                         spb.finalSteps(spb.specification))
+
+    def testFinalStepsWithTwoStepsOneDependency(self):
+        """
+        If a specification has two steps and the second depends on the first,
+        finalSteps must return just the second.
+        """
+        specification = {
+            'steps': [
+                {
+                    'name': 'name1',
+                    'script': 'script1',
+                },
+                {
+                    'dependencies': ['name1'],
+                    'name': 'name2',
+                    'script': 'script2',
+                },
+            ],
+        }
+        spb = SlurmPipelineBase(specification)
+        self.assertEqual(set(('name2',)), spb.finalSteps(spb.specification))
+
+    def testFinalStepsWithFiveSteps(self):
+        """
+        If a specification has 5 steps and two of them are not depended on
+        finalSteps must return those two.
+        """
+        specification = {
+            'steps': [
+                {
+                    'name': 'name1',
+                    'script': 'script1',
+                },
+                {
+                    'dependencies': ['name1'],
+                    'name': 'name2',
+                    'script': 'script2',
+                },
+                {
+                    'name': 'name3',
+                    'script': 'script3',
+                },
+                {
+                    'name': 'name4',
+                    'script': 'script4',
+                },
+                {
+                    'dependencies': ['name3', 'name4'],
+                    'name': 'name5',
+                    'script': 'script5',
+                },
+            ],
+        }
+        spb = SlurmPipelineBase(specification)
+        self.assertEqual(set(('name2', 'name5')),
+                         spb.finalSteps(spb.specification))
+
+    def testFinalStepsWithSixSteps(self):
+        """
+        If a specification has 6 steps and one of them is not depended on
+        finalSteps must return that one.
+        """
+        specification = {
+            'steps': [
+                {
+                    'name': 'name1',
+                    'script': 'script1',
+                },
+                {
+                    'dependencies': ['name1'],
+                    'name': 'name2',
+                    'script': 'script2',
+                },
+                {
+                    'name': 'name3',
+                    'script': 'script3',
+                },
+                {
+                    'name': 'name4',
+                    'script': 'script4',
+                },
+                {
+                    'dependencies': ['name3', 'name4'],
+                    'name': 'name5',
+                    'script': 'script5',
+                },
+                {
+                    'dependencies': ['name2', 'name5'],
+                    'name': 'name6',
+                    'script': 'script6',
+                },
+            ],
+        }
+        spb = SlurmPipelineBase(specification)
+        self.assertEqual(set(('name6',)), spb.finalSteps(spb.specification))
diff --git a/test/test_pipeline.py b/test/test_pipeline.py
index 0057a04..560145c 100644
--- a/test/test_pipeline.py
+++ b/test/test_pipeline.py
@@ -1,113 +1,41 @@
 from os import X_OK
 from unittest import TestCase
-from six.moves import builtins
-from six import assertRaisesRegex, PY3
+from six import assertRaisesRegex
 from json import dumps
 import platform
 
-from slurm_pipeline.pipeline import (
-    SlurmPipeline, SpecificationError, SchedulingError, DEVNULL)
+from slurm_pipeline.pipeline import SlurmPipeline, DEVNULL
+from slurm_pipeline.error import SchedulingError, SpecificationError
 
 try:
     from unittest.mock import ANY, call, patch
 except ImportError:
     from mock import ANY, call, patch
 
-from .mocking import mockOpen
-
 PYPY = platform.python_implementation() == 'PyPy'
 
 
-class TestRunner(TestCase):
+class TestSlurmPipeline(TestCase):
     """
-    Tests for the pipeline.runner.SlurmPipeline class.
+    Tests for the slurm_pipeline.pipeline.SlurmPipeline class.
     """
 
-    def testEmptyJSON(self):
-        """
-        If the specification file is empty, a ValueError must be raised.
-        """
-        data = ''
-        mockOpener = mockOpen(read_data=data)
-        with patch.object(builtins, 'open', mockOpener):
-            if PY3:
-                error = '^Expecting value: line 1 column 1 \(char 0\)$'
-            elif PYPY:
-                # The error message in the exception has a NUL in it.
-                error = ("^No JSON object could be decoded: unexpected "
-                         "'\\000' at char 0$")
-            else:
-                error = '^No JSON object could be decoded$'
-            assertRaisesRegex(self, ValueError, error, SlurmPipeline, 'file')
-
-    def testInvalidJSON(self):
-        """
-        If the specification file does not contain valid JSON, a ValueError
-        must be raised.
-        """
-        data = '{'
-        mockOpener = mockOpen(read_data=data)
-        with patch.object(builtins, 'open', mockOpener):
-            if PY3:
-                error = ('^Expecting property name enclosed in double '
-                         'quotes: line 1 column 2 \(char 1\)$')
-            elif PYPY:
-                # The error message in the exception has a NUL in it.
-                error = ("^No JSON object could be decoded: unexpected "
-                         "'\\000' at char 0$")
-            else:
-                error = '^Expecting object: line 1 column 1 \(char 0\)$'
-            assertRaisesRegex(self, ValueError, error, SlurmPipeline, 'file')
-
-    def testJSONList(self):
-        """
-        If the specification file contains valid JSON but is a list instead
-        of a JSON object, a SpecificationError must be raised.
-        """
-        data = '[]'
-        mockOpener = mockOpen(read_data=data)
-        with patch.object(builtins, 'open', mockOpener):
-            error = ('^The specification must be a dict \(i\.e\., a JSON '
-                     'object when loaded from a file\)$')
-            assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
-                              'file')
-
-    def testList(self):
-        """
-        If the specification is passed a list directly instead of a dict, a
-        SpecificationError must be raised.
-        """
-        error = ('^The specification must be a dict \(i\.e\., a JSON '
-                 'object when loaded from a file\)$')
-        assertRaisesRegex(self, SpecificationError, error, SlurmPipeline, [])
-
-    def testNoSteps(self):
-        """
-        If the specification dict does not contain a 'steps' key, a
-        SpecificationError must be raised.
-        """
-        error = '^The specification must have a top-level "steps" key$'
-        assertRaisesRegex(self, SpecificationError, error, SlurmPipeline, {})
-
-    def testStepsMustBeAList(self):
+    @patch('os.access')
+    @patch('os.path.exists')
+    def testAccessFails(self, existsMock, accessMock):
         """
-        If the specification dict does not contain a 'steps' key whose value
-        is a list, a SpecificationError must be raised.
+        If os.access fails, a SpecificationError must be raised.
         """
-        error = '^The "steps" key must be a list$'
-        assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
-                          {'steps': None})
+        accessMock.return_value = False
 
-    def testNonDictionaryStep(self):
-        """
-        If the specification steps contains a step that is not a dict, a
-        SpecificationError must be raised.
-        """
-        error = '^Step 1 is not a dictionary$'
+        error = "^The script 'script' in step 1 is not executable$"
         assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
                           {
                               'steps': [
-                                  None,
+                                  {
+                                      'name': 'name',
+                                      'script': 'script',
+                                  },
                               ]
                           })
 
@@ -130,41 +58,6 @@ class TestRunner(TestCase):
         existsMock.assert_called_once_with('script')
         accessMock.assert_called_once_with('script', X_OK)
 
-    @patch('os.access')
-    @patch('os.path.exists')
-    def testStepWithoutScript(self, existsMock, accessMock):
-        """
-        If the specification steps contains a step that does not have a
-        'script' key, a SpecificationError must be raised.
-        """
-        error = '^Step 2 does not have a "script" key$'
-        assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
-                          {
-                              'steps': [
-                                  {
-                                      'name': 'name',
-                                      'script': 'script',
-                                  },
-                                  {
-                                  },
-                              ]
-                          })
-
-    def testNonStringScript(self):
-        """
-        If a step has a 'script' key that is not a string, a SpecificationError
-        must be raised.
-        """
-        error = '^The "script" key in step 1 is not a string$'
-        assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
-                          {
-                              'steps': [
-                                  {
-                                      'script': None,
-                                  },
-                              ]
-                          })
-
     def testNonexistentScript(self):
         """
         If a step has a 'script' key that mentions a non-existent file, a
@@ -182,126 +75,49 @@ class TestRunner(TestCase):
 
     @patch('os.access')
     @patch('os.path.exists')
-    def testStepWithoutName(self, existsMock, accessMock):
+    def testCollectStepWithNoDependencies(self, existsMock, accessMock):
         """
-        If the specification steps contains a step that does not have a
-        'name' key, a SpecificationError must be raised.
-        """
-        error = '^Step 1 does not have a "name" key$'
-        assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
-                          {
-                              'steps': [
-                                  {
-                                      'script': 'script',
-                                  },
-                              ]
-                          })
-
-    @patch('os.access')
-    @patch('os.path.exists')
-    def testNonStringName(self, existsMock, accessMock):
-        """
-        If a step has a 'name' key that is not a string, a SpecificationError
-        must be raised.
-        """
-        error = '^The "name" key in step 1 is not a string$'
-        assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
-                          {
-                              'steps': [
-                                  {
-                                      'name': None,
-                                      'script': 'script',
-                                  },
-                              ]
-                          })
-
-    @patch('os.access')
-    @patch('os.path.exists')
-    def testDuplicateName(self, existsMock, accessMock):
-        """
-        If two steps have the same name, a SpecificationError must be raised.
-        specification.
-        """
-        error = ("^The name 'name' of step 2 was already used in an "
-                 "earlier step$")
-        assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
-                          {
-                              'steps': [
-                                  {
-                                      'name': 'name',
-                                      'script': 'script1',
-                                  },
-                                  {
-                                      'name': 'name',
-                                      'script': 'script2',
-                                  },
-                              ],
-                          })
-
-    @patch('os.access')
-    @patch('os.path.exists')
-    def testNonListDependencies(self, existsMock, accessMock):
-        """
-        If a step has a 'dependencies' key that is not a list, a
-        SpecificationError must be raised.
-        """
-        error = '^Step 1 has a non-list "dependencies" key$'
-        assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
-                          {
-                              'steps': [
-                                  {
-                                      'dependencies': None,
-                                      'name': 'name',
-                                      'script': 'script',
-                                  },
-                              ]
-                          })
-
-    @patch('os.access')
-    @patch('os.path.exists')
-    def testNonExistentDependency(self, existsMock, accessMock):
-        """
-        If a step has a 'dependencies' key that mentions an unknown step,
+        If a specification has a 'collect' step with no dependencies,
         a SpecificationError must be raised.
         """
-        error = ("^Step 2 depends on a non-existent \(or not-yet-"
-                 "defined\) step: 'unknown'$")
+        error = ("^Step 2 \('name2'\) is a 'collect' step but does not have "
+                 "any dependencies$")
         assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
                           {
                               'steps': [
                                   {
                                       'name': 'name1',
-                                      'script': 'script',
+                                      'script': 'script1',
                                   },
                                   {
-                                      'dependencies': ['unknown'],
+                                      'collect': True,
                                       'name': 'name2',
-                                      'script': 'script',
+                                      'script': 'script2',
                                   },
                               ]
                           })
 
     @patch('os.access')
     @patch('os.path.exists')
-    def testNonYetDefinedDependency(self, existsMock, accessMock):
+    def testCollectStepWithEmptyDependencies(self, existsMock, accessMock):
         """
-        If a step has a 'dependencies' key that mentions a step that exists
-        but that has not yet been defined in the specification steps, a
-        SpecificationError must be raised.
+        If a specification has a 'collect' step with a 'dependencies' key
+        whose value is the empty list, a SpecificationError must be raised.
         """
-        error = ("^Step 1 depends on a non-existent \(or not-yet-"
-                 "defined\) step: 'name2'$")
+        error = ("^Step 2 \('name2'\) is a 'collect' step but does not have "
+                 "any dependencies$")
         assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
                           {
                               'steps': [
                                   {
-                                      'dependencies': ['name2'],
                                       'name': 'name1',
-                                      'script': 'script',
+                                      'script': 'script1',
                                   },
                                   {
+                                      'collect': True,
+                                      'dependencies': [],
                                       'name': 'name2',
-                                      'script': 'script',
+                                      'script': 'script2',
                                   },
                               ]
                           })
@@ -313,7 +129,7 @@ class TestRunner(TestCase):
         If SlurmPipeline is passed a firstStep value that doesn't match
         any of the specification steps, a SpecificationError must be raised.
         """
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -323,7 +139,7 @@ class TestRunner(TestCase):
                 ]
             })
         error = "^First step 'xxx' not found in specification$"
-        assertRaisesRegex(self, SchedulingError, error, runner.schedule,
+        assertRaisesRegex(self, SchedulingError, error, sp.schedule,
                           firstStep='xxx')
 
     @patch('os.access')
@@ -333,7 +149,7 @@ class TestRunner(TestCase):
         If SlurmPipeline is passed a lastStep value that doesn't match
         any of the specification steps, a SpecificationError must be raised.
         """
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -343,7 +159,7 @@ class TestRunner(TestCase):
                 ]
             })
         error = "^Last step 'xxx' not found in specification$"
-        assertRaisesRegex(self, SchedulingError, error, runner.schedule,
+        assertRaisesRegex(self, SchedulingError, error, sp.schedule,
                           lastStep='xxx')
 
     @patch('os.access')
@@ -353,7 +169,7 @@ class TestRunner(TestCase):
         If SlurmPipeline is passed a lastStep value that occurs before the
         first step, a SpecificationError must be raised.
         """
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -368,47 +184,34 @@ class TestRunner(TestCase):
             })
         error = ("^Last step \('name1'\) occurs before first step \('name2'\) "
                  "in the specification$")
-        assertRaisesRegex(self, SchedulingError, error, runner.schedule,
+        assertRaisesRegex(self, SchedulingError, error, sp.schedule,
                           firstStep='name2', lastStep='name1')
 
-    @patch('os.access')
-    @patch('os.path.exists')
-    def testSpecificationIsStored(self, existsMock, accessMock):
-        """
-        If well-formed JSON is passed, it must be read and stored as the
-        specification.
-        """
-        specification = {
-            'steps': [
-                {
-                    'name': 'name1',
-                    'script': 'script1',
-                },
-                {
-                    'name': 'name2',
-                    'script': 'script2',
-                },
-            ],
-        }
-        data = dumps(specification)
-        mockOpener = mockOpen(read_data=data)
-        with patch.object(builtins, 'open', mockOpener):
-            runner = SlurmPipeline('file')
-            self.assertEqual(specification, runner.specification)
-
     def testScheduledTime(self):
         """
         After a specification is scheduled, a float 'scheduledAt' key must be
         added to the specification.
         """
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [],
             })
-        self.assertNotIn('scheduledAt', runner.specification)
-        specification = runner.schedule()
+        self.assertNotIn('scheduledAt', sp.specification)
+        specification = sp.schedule()
         self.assertIsInstance(specification['scheduledAt'], float)
 
+    def testAlreadyScheduled(self):
+        """
+        If a specification with a top-level 'scheduledAt' key is passed to
+        SlurmPipeline, a SpecificationError must be raised.
+        """
+        error = ("^The specification has a top-level 'scheduledAt' key, but "
+                 'was not passed as a status specification$')
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipeline, {
+            'scheduledAt': None,
+            'steps': [],
+        })
+
     @patch('subprocess.check_output')
     @patch('os.access')
     @patch('os.path.exists')
@@ -419,7 +222,7 @@ class TestRunner(TestCase):
         """
         subprocessMock.return_value = ('TASK: xxx 123 456\n'
                                        'TASK: xxx 123 567\n')
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -428,7 +231,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        specification = runner.schedule()
+        specification = sp.schedule()
         self.assertEqual(
             {
                 'xxx': {123, 456, 567},
@@ -444,7 +247,7 @@ class TestRunner(TestCase):
         SchedulingError must be raised.
         """
         subprocessMock.return_value = 'TASK: xxx 123 123\n'
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -456,7 +259,7 @@ class TestRunner(TestCase):
         error = ("^Task name 'xxx' was output with a duplicate in "
                  "its job ids \[123, 123\] by 'script1' script in "
                  "step named 'name1'$")
-        assertRaisesRegex(self, SchedulingError, error, runner.schedule)
+        assertRaisesRegex(self, SchedulingError, error, sp.schedule)
 
     @patch('subprocess.check_output')
     @patch('os.access')
@@ -469,7 +272,7 @@ class TestRunner(TestCase):
         """
         subprocessMock.return_value = ('TASK: xxx 123 456\n'
                                        'TASK: yyy 234 567\n')
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -478,7 +281,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        specification = runner.schedule()
+        specification = sp.schedule()
         self.assertEqual(
             {
                 'xxx': {123, 456},
@@ -499,7 +302,7 @@ class TestRunner(TestCase):
         timeMock.return_value = 10.0
         subprocessMock.return_value = ('TASK: xxx 123 456\n'
                                        'TASK: yyy 234 567\n')
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -508,7 +311,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        specification = runner.schedule()
+        specification = sp.schedule()
         self.assertEqual(
             specification['steps']['name1']['scheduledAt'], 10.0)
 
@@ -522,7 +325,7 @@ class TestRunner(TestCase):
         subprocessMock.return_value = ('TASK: xxx 123 456\n'
                                        'Hello\n'
                                        'TASK: yyy 234 567\n')
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -531,7 +334,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        specification = runner.schedule()
+        specification = sp.schedule()
         self.assertEqual(
             'TASK: xxx 123 456\nHello\nTASK: yyy 234 567\n',
             specification['steps']['name1']['stdout'])
@@ -547,7 +350,7 @@ class TestRunner(TestCase):
         subprocessMock.return_value = 'output'
         timeMock.return_value = 10.0
 
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -560,7 +363,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        specification = runner.schedule()
+        specification = sp.schedule()
         self.assertEqual(
             {
                 'name1': {
@@ -612,7 +415,7 @@ class TestRunner(TestCase):
 
         subprocessMock.side_effect = SideEffect().sideEffect
 
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -626,7 +429,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        specification = runner.schedule()
+        specification = sp.schedule()
 
         # Step 1 tasks and dependencies.
         self.assertEqual(
@@ -700,7 +503,7 @@ class TestRunner(TestCase):
 
         subprocessMock.side_effect = SideEffect().sideEffect
 
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -714,7 +517,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        specification = runner.schedule()
+        specification = sp.schedule()
 
         # Step 1 tasks and dependencies. Two tasks were emitted, but
         # they did not have job ids.
@@ -787,7 +590,7 @@ class TestRunner(TestCase):
 
         subprocessMock.side_effect = SideEffect().sideEffect
 
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -806,7 +609,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        specification = runner.schedule()
+        specification = sp.schedule()
 
         # Step 1 tasks and dependencies.
         self.assertEqual(
@@ -866,6 +669,68 @@ class TestRunner(TestCase):
             env3['SP_DEPENDENCY_ARG'])
         self.assertEqual('', env3['SP_ORIGINAL_ARGS'])
 
+    @patch('subprocess.check_output')
+    @patch('os.access')
+    @patch('os.path.exists')
+    def testSingleCollectorDependencyNoJobIds(
+            self, existsMock, accessMock, subprocessMock):
+        """
+        If a collect step has a dependency on two other steps but those steps
+        emit no jobs then the script for the step must be run with no
+        SP_DEPENDENCY_ARG value.
+        """
+
+        subprocessMock.return_value = ''
+
+        sp = SlurmPipeline(
+            {
+                'steps': [
+                    {
+                        'name': 'name1',
+                        'script': 'script1',
+                    },
+                    {
+                        'name': 'name2',
+                        'script': 'script2',
+                    },
+                    {
+                        'collect': True,
+                        'dependencies': ['name1', 'name2'],
+                        'name': 'name3',
+                        'script': 'script3',
+                    },
+                ],
+            })
+        sp.schedule()
+        env = subprocessMock.mock_calls[2][2]['env']
+        self.assertNotIn('SP_DEPENDENCY_ARG', env)
+
+    @patch('subprocess.check_output')
+    @patch('os.access')
+    @patch('os.path.exists')
+    def testStartAfter(
+            self, existsMock, accessMock, subprocessMock):
+        """
+        If a step has no dependencies but a startAfter values is passed to
+        schedule, it must have the expected SP_DEPENDENCY_ARG value set
+        in its environment.
+        """
+        sp = SlurmPipeline(
+            {
+                'steps': [
+                    {
+                        'name': 'name',
+                        'script': 'script',
+                    },
+                ],
+            })
+        sp.schedule(startAfter=[35, 36])
+
+        # Check that the dependency environment variable is correct.
+        env = subprocessMock.mock_calls[0][2]['env']
+        self.assertEqual('--dependency=afterany:35,afterany:36',
+                         env['SP_DEPENDENCY_ARG'])
+
     @patch('subprocess.check_output')
     @patch('os.path.abspath')
     @patch('os.access')
@@ -880,7 +745,7 @@ class TestRunner(TestCase):
         abspathMock.return_value = '/fictional/path'
         subprocessMock.return_value = ''
 
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -890,7 +755,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule()
+        sp.schedule()
 
         subprocessMock.assert_has_calls([
             call(['/fictional/path'], cwd='/tmp', universal_newlines=True,
@@ -922,7 +787,7 @@ class TestRunner(TestCase):
 
         subprocessMock.side_effect = SideEffect().sideEffect
 
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -940,7 +805,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule(scriptArgs=['hey', 3])
+        sp.schedule(scriptArgs=['hey', 3])
 
         subprocessMock.assert_has_calls([
             call(['script1', 'hey', '3'], cwd='.', universal_newlines=True,
@@ -987,7 +852,7 @@ class TestRunner(TestCase):
         """
         subprocessMock.return_value = ''
 
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -996,7 +861,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule(force=True)
+        sp.schedule(force=True)
 
         subprocessMock.assert_has_calls([
             call(['script1'], cwd='.', universal_newlines=True,
@@ -1015,7 +880,7 @@ class TestRunner(TestCase):
         correct SP_SIMULATE value must be set in the environment.
         """
         subprocessMock.return_value = ''
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1028,7 +893,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule(firstStep='name2')
+        sp.schedule(firstStep='name2')
 
         subprocessMock.assert_has_calls([
             call(['script1'], cwd='.', universal_newlines=True, stdin=DEVNULL,
@@ -1054,7 +919,7 @@ class TestRunner(TestCase):
         correct SP_SIMULATE value must be set in the environment.
         """
         subprocessMock.return_value = ''
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1071,7 +936,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule(lastStep='name2')
+        sp.schedule(lastStep='name2')
 
         subprocessMock.assert_has_calls([
             call(['script1'], cwd='.', universal_newlines=True, stdin=DEVNULL,
@@ -1104,7 +969,7 @@ class TestRunner(TestCase):
         correctly in the environment.
         """
         subprocessMock.return_value = ''
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1125,7 +990,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule(firstStep='name2', lastStep='name3')
+        sp.schedule(firstStep='name2', lastStep='name3')
 
         subprocessMock.assert_has_calls([
             call(['script1'], cwd='.', universal_newlines=True, stdin=DEVNULL,
@@ -1163,7 +1028,7 @@ class TestRunner(TestCase):
         correctly in the environment.
         """
         subprocessMock.return_value = ''
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1180,7 +1045,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule(firstStep='name2', lastStep='name2')
+        sp.schedule(firstStep='name2', lastStep='name2')
 
         subprocessMock.assert_has_calls([
             call(['script1'], cwd='.', universal_newlines=True, stdin=DEVNULL,
@@ -1202,6 +1067,55 @@ class TestRunner(TestCase):
         env3 = subprocessMock.mock_calls[2][2]['env']
         self.assertEqual('1', env3['SP_SIMULATE'])
 
+    @patch('subprocess.check_output')
+    @patch('os.access')
+    @patch('os.path.exists')
+    def testFirstStepAndNoLastStep(self, existsMock, accessMock,
+                                   subprocessMock):
+        """
+        If firstStep is specified and lastStep is not, the correct SP_SIMULATE
+        value must be set correctly in the environment.
+        """
+        subprocessMock.return_value = ''
+        sp = SlurmPipeline(
+            {
+                'steps': [
+                    {
+                        'name': 'name1',
+                        'script': 'script1',
+                    },
+                    {
+                        'name': 'name2',
+                        'script': 'script2',
+                    },
+                    {
+                        'name': 'name3',
+                        'script': 'script3',
+                    },
+                ],
+            })
+        sp.schedule(firstStep='name2')
+
+        subprocessMock.assert_has_calls([
+            call(['script1'], cwd='.', universal_newlines=True, stdin=DEVNULL,
+                 env=ANY),
+            call(['script2'], cwd='.', universal_newlines=True, stdin=DEVNULL,
+                 env=ANY),
+            call(['script3'], cwd='.', universal_newlines=True, stdin=DEVNULL,
+                 env=ANY),
+        ])
+
+        # Check that the SP_SIMULATE environment variable is correct in
+        # all calls.
+        env1 = subprocessMock.mock_calls[0][2]['env']
+        self.assertEqual('1', env1['SP_SIMULATE'])
+
+        env2 = subprocessMock.mock_calls[1][2]['env']
+        self.assertEqual('0', env2['SP_SIMULATE'])
+
+        env3 = subprocessMock.mock_calls[2][2]['env']
+        self.assertEqual('0', env3['SP_SIMULATE'])
+
     @patch('subprocess.check_output')
     @patch('time.sleep')
     @patch('os.access')
@@ -1211,7 +1125,7 @@ class TestRunner(TestCase):
         If a sleep argument is given to SlurmPipeline, sleep must be called
         between steps with the expected number of seconds.
         """
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1229,7 +1143,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule(sleep=1.0)
+        sp.schedule(sleep=1.0)
 
         sleepMock.assert_has_calls([call(1.0), call(1.0)])
 
@@ -1243,7 +1157,7 @@ class TestRunner(TestCase):
         If no sleep argument is given to SlurmPipeline, sleep must not be
         called.
         """
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1261,7 +1175,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule()
+        sp.schedule()
 
         self.assertFalse(sleepMock.called)
 
@@ -1275,7 +1189,7 @@ class TestRunner(TestCase):
         If a sleep argument of 0.0 is given to SlurmPipeline, sleep must not be
         called.
         """
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1293,7 +1207,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule(sleep=0.0)
+        sp.schedule(sleep=0.0)
 
         self.assertFalse(sleepMock.called)
 
@@ -1306,7 +1220,7 @@ class TestRunner(TestCase):
         SchedulingError must be raised.
         """
         error = '^Unknown skip step \(xxx\) passed to schedule$'
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1315,7 +1229,7 @@ class TestRunner(TestCase):
                     },
                 ]
             })
-        assertRaisesRegex(self, SchedulingError, error, runner.schedule,
+        assertRaisesRegex(self, SchedulingError, error, sp.schedule,
                           skip={'xxx'})
 
     @patch('subprocess.check_output')
@@ -1327,7 +1241,7 @@ class TestRunner(TestCase):
         SchedulingError must be raised.
         """
         error = '^Unknown skip steps \(xxx, yyy\) passed to schedule$'
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1336,7 +1250,7 @@ class TestRunner(TestCase):
                     },
                 ]
             })
-        assertRaisesRegex(self, SchedulingError, error, runner.schedule,
+        assertRaisesRegex(self, SchedulingError, error, sp.schedule,
                           skip={'xxx', 'yyy'})
 
     @patch('subprocess.check_output')
@@ -1348,7 +1262,7 @@ class TestRunner(TestCase):
         in each step script.
         """
         subprocessMock.return_value = ''
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1365,7 +1279,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule()
+        sp.schedule()
 
         subprocessMock.assert_has_calls([
             call(['script1'], cwd='.', universal_newlines=True, stdin=DEVNULL,
@@ -1395,7 +1309,7 @@ class TestRunner(TestCase):
         must be set to 1.
         """
         subprocessMock.return_value = ''
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1412,7 +1326,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule(skip={'name2', 'name3'})
+        sp.schedule(skip={'name2', 'name3'})
 
         subprocessMock.assert_has_calls([
             call(['script1'], cwd='.', universal_newlines=True, stdin=DEVNULL,
@@ -1441,10 +1355,11 @@ class TestRunner(TestCase):
         """
         It must be possible to convert a scheduled specification to JSON.
         """
-        subprocessMock.return_value = 'output'
+        subprocessMock.return_value = 'TASK: xxx 123\n'
         timeMock.return_value = 10.0
+        self.maxDiff = None
 
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1452,12 +1367,13 @@ class TestRunner(TestCase):
                         'script': 'script1',
                     },
                     {
+                        'dependencies': ['name1'],
                         'name': 'name2',
                         'script': 'script2',
                     },
                 ]
             })
-        specification = runner.schedule(firstStep='name2', force=True)
+        specification = sp.schedule(firstStep='name2', force=True)
         expected = dumps(
             {
                 'firstStep': 'name2',
@@ -1466,6 +1382,7 @@ class TestRunner(TestCase):
                 'scheduledAt': 10.0,
                 'scriptArgs': None,
                 'skip': [],
+                'startAfter': None,
                 'steps': [
                     {
                         'name': 'name1',
@@ -1473,24 +1390,37 @@ class TestRunner(TestCase):
                         'script': 'script1',
                         'simulate': True,
                         'skip': False,
-                        'stdout': 'output',
+                        'stdout': 'TASK: xxx 123\n',
                         'taskDependencies': {},
-                        'tasks': {},
+                        "tasks": {
+                            "xxx": [
+                                123,
+                            ],
+                        },
                     },
                     {
+                        'dependencies': ['name1'],
                         'name': 'name2',
                         'scheduledAt': 10.0,
                         'script': 'script2',
                         'simulate': False,
                         'skip': False,
-                        'stdout': 'output',
-                        'taskDependencies': {},
-                        'tasks': {},
+                        'stdout': 'TASK: xxx 123\n',
+                        "taskDependencies": {
+                            "xxx": [
+                                123,
+                            ],
+                        },
+                        "tasks": {
+                            "xxx": [
+                                123,
+                            ],
+                        },
                     },
                 ],
             },
             sort_keys=True, indent=2, separators=(',', ': '))
-        self.assertEqual(expected, runner.specificationToJSON(specification))
+        self.assertEqual(expected, sp.specificationToJSON(specification))
 
     @patch('os.access')
     @patch('os.path.exists')
@@ -1500,7 +1430,8 @@ class TestRunner(TestCase):
         any dependencies, a SpecificationError must be raised because error
         steps only exist for the purposed of catching failed dependencies.
         """
-        error = '^Step "xx" is an error step but has no "dependencies" key$'
+        error = (
+            "^Step 1 \('xx'\) is an error step but has no 'dependencies' key$")
         assertRaisesRegex(self, SpecificationError, error, SlurmPipeline,
                           {
                               'steps': [
@@ -1535,7 +1466,7 @@ class TestRunner(TestCase):
 
         subprocessMock.side_effect = SideEffect().sideEffect
 
-        runner = SlurmPipeline(
+        sp = SlurmPipeline(
             {
                 'steps': [
                     {
@@ -1550,7 +1481,7 @@ class TestRunner(TestCase):
                     },
                 ],
             })
-        runner.schedule()
+        sp.schedule()
 
         # Check that the dependency environment variable is correct in
         # all calls.
diff --git a/test/test_squeue.py b/test/test_squeue.py
new file mode 100644
index 0000000..e4ea8d6
--- /dev/null
+++ b/test/test_squeue.py
@@ -0,0 +1,173 @@
+from unittest import TestCase
+from six import assertRaisesRegex
+from os import getlogin
+
+try:
+    from unittest.mock import patch
+except ImportError:
+    from mock import patch
+
+from slurm_pipeline.error import SQueueError
+from slurm_pipeline.squeue import SQueue
+
+
+class TestSQueue(TestCase):
+    """
+    Tests for the slurm_pipeline.squeue.SQueue class.
+    """
+    @patch('subprocess.check_output')
+    def testSQueueNotFound(self, subprocessMock):
+        """
+        When an attempt to run squeue fails due to an OSError, an SQueueError
+        must be raised.
+        """
+        subprocessMock.side_effect = OSError('No such file or directory')
+        error = (
+            "^Encountered OSError \(No such file or directory\) when running "
+            "'squeue -u %s'$" % getlogin())
+        assertRaisesRegex(self, SQueueError, error,  SQueue)
+
+    @patch('subprocess.check_output')
+    def testAllColumnsMissing(self, subprocessMock):
+        """
+        When the squeue output does not contain any of the right column
+        headings an SQueueError must be raised.
+        """
+        subprocessMock.return_value = 'Bad header\n'
+        error = (
+            '^Required columns \(JOBID, NODELIST\(REASON\), ST, TIME\) not '
+            "found in 'squeue -u %s' output$" % getlogin())
+        assertRaisesRegex(self, SQueueError, error,  SQueue)
+
+    @patch('subprocess.check_output')
+    def testSomeColumnsMissing(self, subprocessMock):
+        """
+        When the squeue output contains only some of the right column headings
+        an SQueueError must be raised, indicating which columns were not found.
+        """
+        subprocessMock.return_value = 'JOBID ST\n'
+        error = (
+            '^Required columns \(NODELIST\(REASON\), TIME\) not found in '
+            "'squeue -u %s' output$" % getlogin())
+        assertRaisesRegex(self, SQueueError, error,  SQueue)
+
+    @patch('subprocess.check_output')
+    def testRepeatColumnName(self, subprocessMock):
+        """
+        When the squeue output contains a repeated column heading (among the
+        headings we're interested in) an SQueueError must be raised.
+        """
+        subprocessMock.return_value = 'JOBID ST JOBID\n'
+        error = (
+            "^Multiple columns with title 'JOBID' found in 'squeue -u %s' "
+            "output$" % getlogin())
+        assertRaisesRegex(self, SQueueError, error,  SQueue)
+
+    @patch('subprocess.check_output')
+    def testSQueueCalledAsExpectedWhenNoArgsPassed(self, subprocessMock):
+        """
+        When no squeue arguments are passed, it must be called as expected
+        (with -u LOGIN_NAME).
+        """
+        subprocessMock.return_value = 'JOBID ST TIME NODELIST(REASON)\n'
+        SQueue()
+        subprocessMock.assert_called_once_with(['squeue', '-u', getlogin()],
+                                               universal_newlines=True)
+
+    @patch('subprocess.check_output')
+    def testSQueueCalledAsExpectedWhenArgsPassed(self, subprocessMock):
+        """
+        When squeue arguments are passed, it must be called as specified.
+        """
+        subprocessMock.return_value = 'JOBID ST TIME NODELIST(REASON)\n'
+        SQueue(squeueArgs=['my-squeue', '--xxx', '--yyy'])
+        subprocessMock.assert_called_once_with(
+            ['my-squeue', '--xxx', '--yyy'], universal_newlines=True)
+
+    @patch('subprocess.check_output')
+    def testRepeatJobId(self, subprocessMock):
+        """
+        When the squeue output contains a repeated job id, an SQueueError must
+        be raised.
+        """
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+            '1 R 4:32 (None)\n'
+            '1 R 5:37 (None)\n'
+        )
+        error = ("^Job id 1 found more than once in 'squeue -u %s' output$" %
+                 getlogin())
+        assertRaisesRegex(self, SQueueError, error,  SQueue)
+
+    @patch('subprocess.check_output')
+    def testRepeatJobIdWithSqueueArgs(self, subprocessMock):
+        """
+        When the squeue output contains a repeated job id, an SQueueError must
+        be raised with an error message showing how squeue was called,
+        including when squeueArgs are given.
+        """
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+            '1 R 4:32 (None)\n'
+            '1 R 5:37 (None)\n'
+        )
+        error = "^Job id 1 found more than once in 'my-squeue x y' output$"
+        assertRaisesRegex(self, SQueueError, error,  SQueue,
+                          squeueArgs=['my-squeue', 'x', 'y'])
+
+    @patch('subprocess.check_output')
+    def testJobsDict(self, subprocessMock):
+        """
+        The jobs dict on an SQueue instance must be set correctly if there
+        is no error in the input.
+        """
+        subprocessMock.return_value = (
+            'JOBID ST IGNORE TIME NODELIST(REASON)\n'
+            '1     R  xxx    4:32 (None)\n'
+            '2     S  yyy    5:37 cpu-1-b\n'
+        )
+        sq = SQueue()
+        self.assertEqual(
+            {
+                1: {
+                    'time': '4:32',
+                    'status': 'R',
+                    'nodelistReason': '(None)',
+                },
+                2: {
+                    'time': '5:37',
+                    'status': 'S',
+                    'nodelistReason': 'cpu-1-b',
+                },
+            },
+            sq.jobs
+        )
+
+    @patch('subprocess.check_output')
+    def testFinished(self, subprocessMock):
+        """
+        It must be possible to tell whether a job has finished.
+        """
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+            '1     R  4:32 (None)\n'
+            '2     S  5:37 cpu-1-b\n'
+        )
+        sq = SQueue()
+        self.assertFalse(sq.finished(1))
+        self.assertTrue(sq.finished(3))
+
+    @patch('subprocess.check_output')
+    def testSummarize(self, subprocessMock):
+        """
+        It must be possible to get a summary of a job's status.
+        """
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+            '1     R  4:32 (None)\n'
+            '2     S  5:37 cpu-1\n'
+        )
+        sq = SQueue()
+        self.assertEqual('Status=R Time=4:32 Reason=None', sq.summarize(1))
+        self.assertEqual('Status=S Time=5:37 Nodelist=cpu-1', sq.summarize(2))
+        self.assertEqual('Finished', sq.summarize(3))
diff --git a/test/test_status.py b/test/test_status.py
new file mode 100644
index 0000000..a0a53d2
--- /dev/null
+++ b/test/test_status.py
@@ -0,0 +1,737 @@
+from unittest import TestCase
+from six import assertRaisesRegex
+
+try:
+    from unittest.mock import patch
+except ImportError:
+    from mock import patch
+
+from slurm_pipeline.error import SpecificationError
+from slurm_pipeline.squeue import SQueue
+from slurm_pipeline.status import SlurmPipelineStatus, secondsToTime
+
+
+class TestSlurmPipelineStatus(TestCase):
+    """
+    Tests for the slurm_pipeline.status.SlurmPipelineStatus class.
+    """
+
+    def testNotScheduled(self):
+        """
+        If a specification without a top-level 'scheduledAt' key is passed to
+        SlurmPipelineStatus, a SpecificationError must be raised.
+        """
+        error = "^The specification status has no top-level 'scheduledAt' key$"
+        assertRaisesRegex(self, SpecificationError, error, SlurmPipelineStatus,
+                          {})
+
+    def testFinalJobsWithNoJobs(self):
+        """
+        The finalJobs method should produce an empty set of job ids if the
+        final step of a specification emits no jobs.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        # Two tasks were emitted, but without job ids.
+                        'task1': [],
+                        'task2': [],
+                    }
+                },
+                {
+                    'name': 'end',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'end.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                    }
+                },
+            ]
+        }
+
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual(set(), sps.finalJobs(sps.specification))
+
+    def testFinalJobsWithoutDependencies(self):
+        """
+        The finalJobs method should produce the correct set of job ids emitted
+        by the final steps of a specification.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'task1': [0, 1],
+                        'task2': [2],
+                    }
+                },
+                {
+                    'name': 'end',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'end.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'task1': [3, 4, 5],
+                        'task2': [7, 8],
+                    }
+                },
+            ]
+        }
+
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual(set((0, 1, 2, 3, 4, 5, 7, 8)),
+                         sps.finalJobs(sps.specification))
+
+    def testFinalJobsWithDependencies(self):
+        """
+        The finalJobs method should produce the correct set of job ids emitted
+        by the final step of a specification that contains a dependency.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'task1': [0, 1],
+                        'task2': [2],
+                    }
+                },
+                {
+                    'dependencies': ['start'],
+                    'name': 'end',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'end.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'task1': [3, 4, 5],
+                        'task2': [7, 8],
+                    }
+                },
+            ]
+        }
+
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual(set((3, 4, 5, 7, 8)),
+                         sps.finalJobs(sps.specification))
+
+    @patch('subprocess.check_output')
+    def testStepJobIdSummaryNoJobs(self, subprocessMock):
+        """
+        The stepJobIdSummary method should return two empty sets
+        if a step emitted no jobs.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                    },
+                },
+            ],
+        }
+
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+        )
+
+        sq = SQueue()
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual((set(), set()), sps.stepJobIdSummary('start', sq))
+
+    @patch('subprocess.check_output')
+    def testStepJobIdSummaryTwoJobsNeitherFinished(self, subprocessMock):
+        """
+        The stepJobIdSummary method should return the expected sets
+        if a step emitted two jobs, neither of which is finished.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'fun': [34, 35],
+                    },
+                },
+            ],
+        }
+
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+            '34 R 4:33, cpu-3\n'
+            '35 R 4:35, cpu-4\n'
+        )
+
+        sq = SQueue()
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual((set([34, 35]), set()),
+                         sps.stepJobIdSummary('start', sq))
+
+    @patch('subprocess.check_output')
+    def testStepJobIdSummaryTwoJobsOneFinished(self, subprocessMock):
+        """
+        The stepJobIdSummary method should return the expected sets
+        if a step emitted two jobs, one of which is finished.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'fun': [34, 35],
+                    },
+                },
+            ],
+        }
+
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+            '34 R 4:33, cpu-3\n'
+        )
+
+        sq = SQueue()
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual((set([34, 35]), set([35])),
+                         sps.stepJobIdSummary('start', sq))
+
+    @patch('subprocess.check_output')
+    def testStepJobIdSummaryTwoJobsBothFinished(self, subprocessMock):
+        """
+        The stepJobIdSummary method should return the expected sets
+        if a step emitted two jobs, both of which are finished.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'fun': [34, 35],
+                    },
+                },
+            ],
+        }
+
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+        )
+
+        sq = SQueue()
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual((set([34, 35]), set([34, 35])),
+                         sps.stepJobIdSummary('start', sq))
+
+    @patch('subprocess.check_output')
+    def testUnfinishedJobsNoJobs(self, subprocessMock):
+        """
+        The unfinishedJobs method should return an empty set if a
+        specification emitted no jobs.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {},
+                },
+            ],
+        }
+
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+        )
+
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual(set(), sps.unfinishedJobs(sps.specification))
+
+    @patch('subprocess.check_output')
+    def testUnfinishedJobsOneUnfinishedJob(self, subprocessMock):
+        """
+        The unfinishedJobs method should return a set with the expected job id
+        if the specification emitted one job that is not finished.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'xxx': [123],
+                    },
+                },
+            ],
+        }
+
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+            '123 R 45:33 (None)\n'
+        )
+
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual(set([123]), sps.unfinishedJobs(sps.specification))
+
+    @patch('subprocess.check_output')
+    def testUnfinishedJobsOneFinishedJob(self, subprocessMock):
+        """
+        The unfinishedJobs method should return an empty set if the
+        specification emitted one job that is finished.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'xxx': [123],
+                    },
+                },
+            ],
+        }
+
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+        )
+
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual(set(), sps.unfinishedJobs(sps.specification))
+
+    @patch('subprocess.check_output')
+    def testUnfinishedJobsMultipleSteps(self, subprocessMock):
+        """
+        The unfinishedJobs method should return the expected job ids if the
+        specification has multiple steps.
+        """
+        status = {
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'steps': [
+                {
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'start.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'xxx': [12, 34],
+                    },
+                },
+                {
+                    'name': 'end',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': 'end.sh',
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'yyy': [56, 78, 90],
+                    },
+                },
+            ],
+        }
+
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+            '12 R 45:33 (None)\n'
+            '56 R 27:01 (None)\n'
+            '90 R 23:07 (None)\n'
+        )
+
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual(set([12, 56, 90]),
+                         sps.unfinishedJobs(sps.specification))
+
+    @patch('subprocess.check_output')
+    def testToStr(self, subprocessMock):
+        """
+        The toStr method must return a complete summary of the status
+        specification.
+        """
+        status = {
+            'firstStep': 'panel',
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': [],
+            'skip': [],
+            'startAfter': None,
+            'steps': [
+                {
+                    'cwd': '00-start',
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': '00-start/start.sh',
+                    'simulate': True,
+                    'skip': False,
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {}
+                },
+                {
+                    'cwd': '01-split',
+                    'dependencies': [
+                        'start'
+                    ],
+                    'name': 'split',
+                    'scheduledAt': 1481379664.184737,
+                    'script': '01-split/sbatch.sh',
+                    'simulate': True,
+                    'skip': False,
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {
+                        'chunk-aaaaa': [],
+                        'chunk-aaaab': [],
+                        'chunk-aaaac': [],
+                    }
+                },
+                {
+                    'cwd': '02-blastn',
+                    'dependencies': [
+                        'split'
+                    ],
+                    'name': 'blastn',
+                    'scheduledAt': 1481379722.3996398,
+                    'script': '02-blastn/sbatch.sh',
+                    'simulate': True,
+                    'skip': False,
+                    'stdout': '',
+                    'taskDependencies': {
+                        'chunk-aaaaa': [],
+                        'chunk-aaaab': [],
+                        'chunk-aaaac': [],
+                    },
+                    "tasks": {
+                        "chunk-aaaaa": [
+                            4416231
+                        ],
+                        "chunk-aaaab": [
+                            4416232
+                        ],
+                        "chunk-aaaac": [
+                            4416233
+                        ],
+                    },
+                },
+                {
+                    'collect': True,
+                    'cwd': '03-panel',
+                    'dependencies': [
+                        'blastn'
+                    ],
+                    'name': 'panel',
+                    'scheduledAt': 1481379722.5036008,
+                    'script': '03-panel/sbatch.sh',
+                    'simulate': False,
+                    'skip': False,
+                    'stdout': 'TASK: panel 4417615\n',
+                    'taskDependencies': {
+                        'chunk-aaaaa': [
+                            4416231
+                        ],
+                        'chunk-aaaab': [
+                            4416232
+                        ],
+                        'chunk-aaaac': [
+                            4416233
+                        ],
+                    },
+                    'tasks': {
+                        'panel': [
+                            4417615
+                        ]
+                    }
+                },
+                {
+                    'cwd': '04-stop',
+                    'dependencies': [
+                        'panel'
+                    ],
+                    'name': 'stop',
+                    'scheduledAt': 1481379722.5428307,
+                    'script': '04-stop/sbatch.sh',
+                    'simulate': False,
+                    'skip': False,
+                    'stdout': 'TASK: stop 4417616\n',
+                    'taskDependencies': {
+                        'panel': [
+                            4417615
+                        ]
+                    },
+                    'tasks': {
+                        'stop': [
+                            4417616
+                        ]
+                    }
+                }
+            ]
+        }
+
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+            '4417616 R 4:27 cpu-3\n'
+        )
+
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual(
+            '''\
+Scheduled at: 2016-12-10 14:20:58
+Scheduling arguments:
+  First step: panel
+  Force: False
+  Last step: None
+  Script arguments: <None>
+  Skip: <None>
+  Start after: <None>
+5 jobs emitted in total, of which 4 (80.00%) are finished
+Step summary:
+  start: no jobs emitted
+  split: no jobs emitted
+  blastn: 3 jobs emitted, 3 (100.00%) finished
+  panel: 1 job emitted, 1 (100.00%) finished
+  stop: 1 job emitted, 0 (0.00%) finished
+Step 1: start
+  No dependencies.
+  No tasks emitted by this step
+  Working directory: 00-start
+  Scheduled at: 2016-12-10 14:20:59
+  Script: 00-start/start.sh
+  Simulate: True
+  Skip: False
+Step 2: split
+  1 step dependency: start
+    Dependent on 0 tasks emitted by the dependent step
+  3 tasks emitted by this step
+    0 jobs started by these tasks
+    Tasks:
+      chunk-aaaaa
+      chunk-aaaab
+      chunk-aaaac
+  Working directory: 01-split
+  Scheduled at: 2016-12-10 14:21:04
+  Script: 01-split/sbatch.sh
+  Simulate: True
+  Skip: False
+Step 3: blastn
+  1 step dependency: split
+    Dependent on 3 tasks emitted by the dependent step
+    0 jobs started by the dependent tasks
+    Dependent tasks:
+      chunk-aaaaa
+      chunk-aaaab
+      chunk-aaaac
+  3 tasks emitted by this step
+    3 jobs started by this task, of which 3 (100.00%) are finished
+    Tasks:
+      chunk-aaaaa
+        Job 4416231: Finished
+      chunk-aaaab
+        Job 4416232: Finished
+      chunk-aaaac
+        Job 4416233: Finished
+  Working directory: 02-blastn
+  Scheduled at: 2016-12-10 14:22:02
+  Script: 02-blastn/sbatch.sh
+  Simulate: True
+  Skip: False
+Step 4: panel
+  1 step dependency: blastn
+    Dependent on 3 tasks emitted by the dependent step
+    3 jobs started by the dependent task, of which 3 (100.00%) are finished
+    Dependent tasks:
+      chunk-aaaaa
+        Job 4416231: Finished
+      chunk-aaaab
+        Job 4416232: Finished
+      chunk-aaaac
+        Job 4416233: Finished
+  1 task emitted by this step
+    1 job started by this task, of which 1 (100.00%) are finished
+    Tasks:
+      panel
+        Job 4417615: Finished
+  Working directory: 03-panel
+  Scheduled at: 2016-12-10 14:22:02
+  Script: 03-panel/sbatch.sh
+  Simulate: False
+  Skip: False
+Step 5: stop
+  1 step dependency: panel
+    Dependent on 1 task emitted by the dependent step
+    1 job started by the dependent task, of which 1 (100.00%) are finished
+    Dependent tasks:
+      panel
+        Job 4417615: Finished
+  1 task emitted by this step
+    1 job started by this task, of which 0 (0.00%) are finished
+    Tasks:
+      stop
+        Job 4417616: Status=R Time=4:27 Nodelist=cpu-3
+  Working directory: 04-stop
+  Scheduled at: 2016-12-10 14:22:02
+  Script: 04-stop/sbatch.sh
+  Simulate: False
+  Skip: False''',
+            sps.toStr())
+
+    @patch('subprocess.check_output')
+    def testToStrWithScriptArgsSkipAndStartAfter(self, subprocessMock):
+        """
+        The toStr method must return a complete summary of the status
+        specification when scriptArgs, skip, and startAfter are specified.
+        """
+        status = {
+            'firstStep': None,
+            'force': False,
+            'lastStep': None,
+            'scheduledAt': 1481379658.5455897,
+            'scriptArgs': ['hey', 'you'],
+            'skip': ['skip', 'this'],
+            'startAfter': ['34', '56'],
+            'steps': [
+                {
+                    'cwd': '00-start',
+                    'name': 'start',
+                    'scheduledAt': 1481379659.1530972,
+                    'script': '00-start/start.sh',
+                    'simulate': True,
+                    'skip': False,
+                    'stdout': '',
+                    'taskDependencies': {},
+                    'tasks': {}
+                },
+            ]
+        }
+
+        subprocessMock.return_value = (
+            'JOBID ST TIME NODELIST(REASON)\n'
+            '4417616 R 4:27 cpu-3\n'
+        )
+
+        sps = SlurmPipelineStatus(status)
+        self.assertEqual(
+            '''\
+Scheduled at: 2016-12-10 14:20:58
+Scheduling arguments:
+  First step: None
+  Force: False
+  Last step: None
+  Script arguments: hey you
+  Skip: skip, this
+  Start after: 34, 56
+0 jobs emitted in total, of which 0 (100.00%) are finished
+Step summary:
+  start: no jobs emitted
+Step 1: start
+  No dependencies.
+  No tasks emitted by this step
+  Working directory: 00-start
+  Scheduled at: 2016-12-10 14:20:59
+  Script: 00-start/start.sh
+  Simulate: True
+  Skip: False''',
+            sps.toStr())
+
+
+class TestSecondsToTime(TestCase):
+    """
+    Tests for the secondsToTime function.
+    """
+    def testArbitraryTime(self):
+        """
+        The secondsToTime function must return the expected value.
+        """
+        self.assertEqual('2016-12-10 14:20:58',
+                         secondsToTime(1481379658.5455897))