Repository: ExaWorks/psij-python (language: python)

Issue:
Find and use tool to automatically check docstrings
It may be useful to have a tool that ensures that:
- all parameters of a function/method are documented
- changes to parameters are matched by updates to doctstrings
- docstring links are valid


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index f4fecd2d..74dab345 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -29,6 +29,9 @@ jobs:
       run: |
         make typecheck
         make stylecheck
+    - name: Build docs
+      run: |
+        make docs
 
   pytest:
     runs-on: ubuntu-latest
diff --git a/Makefile b/Makefile
index 245e51c0..653ba65f 100644
--- a/Makefile
+++ b/Makefile
@@ -39,7 +39,8 @@ checks: typecheck stylecheck
 .PHONY: docs
 docs:
 	rm -rf docs/.generated
-	sphinx-build -W -b html docs docs/.build/
+	rm -rf docs/.build
+	sphinx-build --keep-going -n -W -b html docs docs/.build/
 
 
 .PHONY: web-docs
diff --git a/docs/conf.py b/docs/conf.py
index 25ae2957..2f261080 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,4 +1,6 @@
 import os
+from typing import List, Tuple
+
 import pkg_resources
 import subprocess
 import sys
@@ -23,14 +25,18 @@
 add_module_names = False
 python_use_unqualified_type_names = True
 autodoc_mock_imports = ['flux']
+nitpick_ignore = [
+    ('py:class', 'distutils.version.StrictVersion'),
+    ('py:class', 'distutils.version.Version')
+]
 
 # Setup Sphinx extensions (and associated variables)
 extensions = [
+    'sphinx.ext.napoleon',
     'sphinx.ext.autodoc',
     'sphinx.ext.autodoc.typehints',
     'sphinx.ext.intersphinx',
     'sphinx.ext.viewcode',
-    'sphinx.ext.napoleon',
 ]
 
 autodoc_typehints = "description"
@@ -64,3 +70,21 @@ def run_apidoc(_):
 # launch setup
 def setup(app):
     app.connect('builder-inited', run_apidoc)
+
+
+# The following is a hack to allow returns in numpy style doctstrings to
+# not duplicate the return type specified by the normal type hints.
+# This was taken from https://github.com/svenevs/elba
+# It was submitted as a patch to the napoleon extension. Unfortunately,
+# it was rejected by the maintainers of napoleon
+# (see https://github.com/sphinx-doc/sphinx/issues/7077).
+def _consume_returns_section(self) -> List[Tuple[str, str, List[str]]]:
+    self._consume_empty()
+    desc_lines = []
+    while not self._is_section_break():
+        desc_lines.append(next(self._line_iter))
+
+    return [("", "", desc_lines)]
+
+from sphinx.ext.napoleon.docstring import NumpyDocstring
+NumpyDocstring._consume_returns_section = _consume_returns_section
diff --git a/docs/index.rst b/docs/index.rst
index bbdeb50e..eed8ff87 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,8 +1,11 @@
 Portable Submission Interface for Jobs (PSI/J) - Python Library
 ===============================================================
 
-This is a Python library that contains the core implementation of  `the PSI/J specification
-<a href="https://exaworks.org/job-api-spec/specification>`_ as well as a number of executors and launchers, which are the components that allow PSI/J to communicate with specific job schedulers, such as Slurm or LSF.
+This is a Python library that contains the core implementation of  `the
+PSI/J specification 
+<a href="https://exaworks.org/job-api-spec/specification>`_ as well as a
+number of executors and launchers, which are the components that allow
+PSI/J to communicate with specific job schedulers, such as Slurm or LSF.
 
 .. toctree::
     :maxdepth: 2
diff --git a/docs/programming.rst b/docs/programming.rst
index 63718a56..a4113a06 100644
--- a/docs/programming.rst
+++ b/docs/programming.rst
@@ -5,35 +5,46 @@ The PSI/J API
 The PSI/J core
 ~~~~~~~~~~~~~~
 
-The core API contains the central components of PSI/J. The core API classes are independent of executor implementations. User code should only program against the core API. The major classes are:
+The core API contains the central components of PSI/J. The core API
+classes are independent of executor implementations. User code should
+only program against the core API. The major classes are:
 
 :class:`~psij.job.Job`
     Represents a scheduler job, including all its details and state.
 
 :class:`~psij.job_spec.JobSpec`
-    Contains the static description of a job at a level that would be supported by the POSIX interface, such as path to the executable, arguments, etc.
+    Contains the static description of a job at a level that would be
+    supported by the POSIX interface, such as path to the executable,
+    arguments, etc.
 
 :class:`~psij.resource_spec.ResourceSpec`
 
-    Describes resource requirements for the job, such as number of nodes, etc.
+    Describes resource requirements for the job, such as number of nodes,
+    etc.
 
 :class:`~psij.job_attributes.JobAttributes`
-    Contains information about a job that goes beyond POSIX and is specific to batch schedulers, such as a queue name, walltime limit, etc.
+    Contains information about a job that goes beyond POSIX and is
+    specific to batch schedulers, such as a queue name, walltime limit,
+    etc.
 
 :class:`~psij.job_state.JobState`
     An enumeration containing all possible states of a job.
 
 :class:`~psij.job_status.JobStatus`
-    An object that represents the transition of a job to a new :class:`~psij.job_state.JobState`.
+    An object that represents the transition of a job to a new
+    :class:`~psij.job_state.JobState`.
 
 :class:`~psij.job_executor.JobExecutor`
-    The base class for a concrete method of executing a job, such as Slurm, or PBS.
+    The base class for a concrete method of executing a job, such as
+    Slurm, or PBS.
 
-:class:`~psij.job_launcher.Launcher`
-    A base class representing a method of actually launching a job once resources are allocated for it, such as `mpirun`, `srun`, etc.
+:class:`~psij.launcher.Launcher`
+    A base class representing a method of actually launching a job once
+    resources are allocated for it, such as `mpirun`, `srun`, etc.
 
 :py:mod:`~psij.exceptions`
-    A module that contanins exception classes for the PSI/J Python library.
+    A module that contanins exception classes for the PSI/J Python
+    library.
 
 
 .. _executors:
@@ -41,17 +52,27 @@ The core API contains the central components of PSI/J. The core API classes are
 Executors
 ~~~~~~~~~
 
-Executors are concrete implementations of mechanisms that execute jobs. To get an instance of a specific executor, call :meth:`JobExecutor.get_instance(name) <psij.job_executor.JobExecutor.get_instance>`, with ``name`` being one of the installed executor names. Executors can be installed from multiple sources, so the precise list of executors avaiable to a specific installation of the PSI/J Python library can vary. In order to get a list of available executors, you can run, in a terminal:
+Executors are concrete implementations of mechanisms that execute jobs.
+To get an instance of a specific executor, call
+:meth:`JobExecutor.get_instance(name) <psij.job_executor.JobExecutor.get_instance>`, 
+with ``name`` being one of the installed executor names. Executors can be
+installed from multiple sources, so the precise list of executors
+avaiable to a specific installation of the PSI/J Python library can vary.
+In order to get a list of available executors, you can run, in a
+terminal:
 
 .. code-block:: shell
 
     $ python -m psij plugins
 
 
-The executor implementations provided by this version of PSI/J Python are:
+The executor implementations provided by this version of PSI/J Python
+are:
 
 :class:`cobalt <psij.executors.batch.cobalt.CobaltJobExecutor>`
-    A job executor that can interact with the `Cobalt HPC Job Scheduler <https://xgitlab.cels.anl.gov/aig-public/cobalt>`_, which is used by `Argonne's <www.anl.gov>`_ `ALCF <www.alcf.anl.gov>`_ systems.
+    A job executor that can interact with the 
+    `Cobalt HPC Job Scheduler <https://xgitlab.cels.anl.gov/aig-public/cobalt>`_, 
+    which is used by `Argonne's <www.anl.gov>`_ `ALCF <www.alcf.anl.gov>`_ systems.
 
 :class:`flux <psij.executors.flux.FluxJobExecutor>`
     Job executor for the `Flux scheduler <http://flux-framework.org/>`_.
@@ -60,16 +81,21 @@ The executor implementations provided by this version of PSI/J Python are:
     A job executor that runs jobs locally by forking a subprocess.
 
 :class:`lsf <psij.executors.batch.lsf.LsfJobExecutor>`
-    An implementation of a job executor for the `IBM Spectrum LSF workload manager <https://www.ibm.com/docs/en/spectrum-lsf>`_.
+    An implementation of a job executor for the 
+    `IBM Spectrum LSF workload manager <https://www.ibm.com/docs/en/spectrum-lsf>`_.
 
 :class:`rp <psij.executors.rp.RPJobExecutor>`
-    Job executor for the `RADICAL Pilot system <https://radical-cybertools.github.io/radical-pilot/>`_.
+    Job executor for the `RADICAL Pilot system
+    <https://radical-cybertools.github.io/radical-pilot/>`_.
 
 :class:`saga <psij.executors.saga.SagaExecutor>`
-    An executor that delegates job execution to the `RADICAL-SAGA <https://radical-cybertools.github.io/saga-python/index.html>`_ library.
+    An executor that delegates job execution to the 
+    `RADICAL-SAGA <https://radical-cybertools.github.io/saga-python/index.html>`_
+    library.
 
 :class:`slurm <psij.executors.batch.slurm.SlurmJobExecutor>`
-    An executor for the `Slurm Workload Manager <https://slurm.schedmd.com/overview.html>`_.
+    An executor for the 
+    `Slurm Workload Manager <https://slurm.schedmd.com/overview.html>`_.
 
 
 .. _launchers:
@@ -77,7 +103,15 @@ The executor implementations provided by this version of PSI/J Python are:
 Launchers
 ~~~~~~~~~
 
-Launchers are mechanisms to start the actual jobs on batch schedulers once a set of nodes has been allocated for the job. In essence, launchers are wrappers around the job executable which can provide additional features, such as setting up an MPI environment, starting a copy of the job executable on each allocated node, etc. To get a launcher instance, call :meth:`Launcher.get_instance(name) <psij.job_launcher.Launcher.get_instance>` with ``name`` being the name of a launcher. Like job executors, above, launchers are plugins and can come from various places. To obtain a list of launchers, you can run:
+Launchers are mechanisms to start the actual jobs on batch schedulers
+once a set of nodes has been allocated for the job. In essence, launchers
+are wrappers around the job executable which can provide additional
+features, such as setting up an MPI environment, starting a copy of the
+job executable on each allocated node, etc. To get a launcher instance,
+call :meth:`Launcher.get_instance(name) <psij.launcher.Launcher.get_instance>` 
+with ``name`` being the name of a launcher. Like job executors, above,
+launchers are plugins and can come from various places. To obtain a list
+of launchers, you can run:
 
 .. code-block:: shell
 
@@ -86,22 +120,29 @@ Launchers are mechanisms to start the actual jobs on batch schedulers once a set
 The PSI/J Python library comes with a core set of launchers, which are:
 
 :class:`aprun <psij.launchers.aprun.AprunLauncher>`
-    Launches jobs using `Cobalt's <https://xgitlab.cels.anl.gov/aig-public/cobalt>`_ ``aprun``.
+    Launches jobs using 
+    `Cobalt's <https://xgitlab.cels.anl.gov/aig-public/cobalt>`_ ``aprun``.
 
 :class:`jsrun <psij.launchers.jsrun.JsrunLauncher>`
-    Starts jobs using ``jsrun`` provided by the `IBM Spectrum LSF workload manager <https://www.ibm.com/docs/en/spectrum-lsf>`_.
+    Starts jobs using ``jsrun`` provided by the 
+    `IBM Spectrum LSF workload manager <https://www.ibm.com/docs/en/spectrum-lsf>`_.
 
 :class:`mpirun <psij.launchers.mpirun.MPILauncher>`
-    Launches jobs using ``mpirun``, which is a tool provided by `MPI <https://www.mpi-forum.org/>`_ implementations, such as `Open MPI <https://www.open-mpi.org/>`_.
+    Launches jobs using ``mpirun``, which is a tool provided by 
+    `MPI <https://www.mpi-forum.org/>`_ implementations, such as 
+    `Open MPI <https://www.open-mpi.org/>`_.
 
 :class:`multiple <psij.launchers.multiple.MultipleLauncher>`
-    Starts multiple identical instances of the same process on the same machine.
+    Starts multiple identical instances of the same process on the same
+    machine.
 
 :class:`single <psij.launchers.single.SingleLauncher>`
-    Starts a single instance of the job executable locally. This is the default launcher, which is used if nothing is explicitly specified.
+    Starts a single instance of the job executable locally. This is the
+    default launcher, which is used if nothing is explicitly specified.
 
 :class:`srun <psij.launchers.srun.SrunLauncher>`
-    Starts job using ``srun``, which is part of the `Slurm Workload Manager <https://slurm.schedmd.com/overview.html>`_.
+    Starts job using ``srun``, which is part of the 
+    `Slurm Workload Manager <https://slurm.schedmd.com/overview.html>`_.
 
 
 API Reference
diff --git a/docs/tutorial_add_executor.rst b/docs/tutorial_add_executor.rst
index df405fe3..19b1cafd 100644
--- a/docs/tutorial_add_executor.rst
+++ b/docs/tutorial_add_executor.rst
@@ -21,7 +21,7 @@ such a batch scheduler, so that it can provide a common interface to application
 A PSI/J executor needs to implement the abstract methods defined on the :class:`psij.job_executor.JobExecutor` base class.
 The documentation for that class has reference material for each of the methods that won't be repeated here.
 
-For batch scheduler systems, the :class:`psij.executors.batch.BatchSchedulerExecutor` subclass provides further useful structure to help implement JobExecutor.
+For batch scheduler systems, the :class:`.BatchSchedulerExecutor` subclass provides further useful structure to help implement JobExecutor.
 This tutorial will focus on using BatchSchedulerExecutor as a base, rather than implementing JobExecutor directly.
 
 The batch scheduler executor is based around a model where interactions with a local resource manager happen via command line invocations.
@@ -183,7 +183,7 @@ which can be used later when cancelling a job or getting job status.
 
 So let's implement those.
 
-In line with other PSI/J executors, we're going to delegate script generation to a template based helper. So add a line to initialise a :py:class:`TemplatedScriptGenerator` in the
+In line with other PSI/J executors, we're going to delegate script generation to a template based helper. So add a line to initialise a :py:class:`.TemplatedScriptGenerator` in the
 executor initializer, pointing at a (as yet non-existent) template file, and replace ``generate_submit_script`` with a delegated call to `TemplatedScriptGenerator`::
 
     from pathlib import Path
@@ -212,7 +212,7 @@ Now the test will fail with an error like this, because that template doesn't ex
     E       FileNotFoundError: [Errno 2] No such file or directory: '/home/you/project/psijpbs/pbspro.mustache'
 
 
-Here is a very simple template for PBS, lacking a lot of features. A full template should use all of the fields in the supplied :py:class:`Job` object, as well as any executor-specific parameters supplied in the ``config`` parameter to ``__init__``::
+Here is a very simple template for PBS, lacking a lot of features. A full template should use all of the fields in the supplied :py:class:`psij.Job` object, as well as any executor-specific parameters supplied in the ``config`` parameter to ``__init__``::
 
   #!/bin/bash
 
@@ -280,9 +280,9 @@ Implementing status
 
 PSI/J needs to ask the batch scheduler for status about jobs that it has submitted. This can be done with ``BatchSchedulerExecutor`` by overriding these two methods, which we stubbed out as not-implemented earlier on:
 
-* :py:meth:`get_status_command` - like ``get_submit_command``, this should return a batch scheduler specific commandline, this time to output job status.
+* :py:meth:`.BatchSchedulerExecutor.get_status_command` - like ``get_submit_command``, this should return a batch scheduler specific commandline, this time to output job status.
 
-* :py:meth:`parse_status_output` - this will interpret the output of the above status command, a bit like ``job_id_from_submit_output``.
+* :py:meth:`.BatchSchedulerExecutor.parse_status_output` - this will interpret the output of the above status command, a bit like ``job_id_from_submit_output``.
 
 Here's an implementation for ``get_status_command``::
 
@@ -340,9 +340,9 @@ Here is an implementation for ``parse_status_output``, as well as a helper dicti
 
             return r
 
-``parse_status_output`` is given both the stdout and the exit code of ``qstat`` and must either transcribe that into a dictionary of :py:class:`JobStatus` objects describing the state of each job, or raise an exception.
+``parse_status_output`` is given both the stdout and the exit code of ``qstat`` and must either transcribe that into a dictionary of :py:class:`psij.JobStatus` objects describing the state of each job, or raise an exception.
 
-This implementation uses a helper, :py:meth:`check_status_exit_code`, which will raise an exception if ``qstat`` exited with a non-zero exit code. Then, it assumes that the ``qstat`` output is JSON and deserialises, and for each job in the JSON, it uses two fields to create a ``JobStatus`` object: a human readable message is taken from the PBS ``comment`` field, and a machine readable status is converted from a single letter PBS status (such as F for finished, or Q for queued) into a PSI/J :py:class:`JobState` via the ``_STATE_MAP`` dictionary.
+This implementation uses a helper, :py:meth:`psij.executors.batch.batch_scheduler_executor.check_status_exit_code`, which will raise an exception if ``qstat`` exited with a non-zero exit code. Then, it assumes that the ``qstat`` output is JSON and deserialises, and for each job in the JSON, it uses two fields to create a ``psij.JobStatus`` object: a human readable message is taken from the PBS ``comment`` field, and a machine readable status is converted from a single letter PBS status (such as F for finished, or Q for queued) into a PSI/J :py:class:`psij.JobState` via the ``_STATE_MAP`` dictionary.
 
 With these status methods in place, the ``pytest`` command from before should execute to completion.
 
@@ -359,8 +359,8 @@ Implementing cancel
 
 The two methods to implement for cancellation follow the same pattern as for submission and status:
 
-* :py:meth:`get_cancel_command` - this should form a command for cancelling a job.
-* :py:meth:`process_cancel_command_output` - this should interpret the output from the cancel command.
+* :py:meth:`.BatchSchedulerExecutor.get_cancel_command` - this should form a command for cancelling a job.
+* :py:meth:`.BatchSchedulerExecutor.process_cancel_command_output` - this should interpret the output from the cancel command.
 
 It looks like you don't actually need to implement process_cancel_command_output beyond the stub we already have, to make the abstract class mechanism happy. Maybe that's something that should change in psi/j?
 
@@ -402,7 +402,7 @@ This isn't necessarily the right thing to do: some PBS installs will use 128+9 =
 What's missing?
 ===============
 
-The biggest thing that was omitted was in the mustache template. A :py:class:`Job` object contains lots of options which could be transcribed into the template (otherwise they will be ignored). Have a look at the docstrings for ``Job`` and at other templates in the PSI/J source code for examples.
+The biggest thing that was omitted was in the mustache template. A :py:class:`psij.Job` object contains lots of options which could be transcribed into the template (otherwise they will be ignored). Have a look at the docstrings for ``Job`` and at other templates in the PSI/J source code for examples.
 
 The _STATE_MAP given here is also not exhaustive: if PBS Pro qstat returns a different state for a job than what is in it, this will break. So make sure you deal with all the states of your batch scheduler, not just a few that seem obvious.
 
diff --git a/src/psij/descriptor.py b/src/psij/descriptor.py
index 30df0997..edb81964 100644
--- a/src/psij/descriptor.py
+++ b/src/psij/descriptor.py
@@ -81,13 +81,12 @@ class name that implements the executor or launcher such as
     `psij.executors.local.LocalJobExecutor`.
     """
 
-    def __init__(self, name: str, version: StrictVersion, cls: str):
+    def __init__(self, name: str, version: StrictVersion, cls: str) -> None:
         """
         Initializes a descriptor.
 
         Parameters
-        ==========
-
+        ----------
         name
             The name of the executor or launcher. The automatic registration system will register
             the executor or launcher using this name. That is, the executor or launcher represented
diff --git a/src/psij/executors/batch/batch_scheduler_executor.py b/src/psij/executors/batch/batch_scheduler_executor.py
index 35b2c574..09c75f64 100644
--- a/src/psij/executors/batch/batch_scheduler_executor.py
+++ b/src/psij/executors/batch/batch_scheduler_executor.py
@@ -61,9 +61,9 @@ def __init__(self, launcher_log_file: Optional[Path] = None,
         Parameters
         ----------
         launcher_log_file
-            See :func:`~JobExecutorConfig.__init__`.
+            See :class:`~psij.JobExecutorConfig`.
         work_directory
-            See :func:`~JobExecutorConfig.__init__`.
+            See :class:`~psij.JobExecutorConfig`.
         queue_polling_interval
             an interval, in seconds, at which the batch scheduler queue will be polled for updates
             to jobs.
@@ -85,27 +85,29 @@ def __init__(self, launcher_log_file: Optional[Path] = None,
         self.keep_files = keep_files
 
 
-class _InvalidJobStateError(Exception):
+class InvalidJobStateError(Exception):
+    """An exception that signals that a job cannot be cancelled due to it being already done."""
+
     pass
 
 
 class BatchSchedulerExecutor(JobExecutor):
     """A base class for batch scheduler executors.
 
-    This class implements a generic :class:`~JobExecutor` that interacts with batch schedulers.
+    This class implements a generic :class:`~psij.JobExecutor` that interacts with batch schedulers.
     There are two main components to the executor: job submission and queue polling. Submission
     is implemented by generating a submit script which is then fed to the queuing system submit
     command.
 
     The submit script is generated using a :func:`~generate_submit_script`. An implementation of
     this functionality based on Mustache/Pystache (see https://mustache.github.io/ and
-    https://pypi.org/project/pystache/) exists in :class:`~TemplatedScriptGenerator`. This class
+    https://pypi.org/project/pystache/) exists in :class:`~.TemplatedScriptGenerator`. This class
     can be instantiated by concrete implementations of a batch scheduler executor and the submit
     script generation can be delegated to that instance, which has a method whose signature matches
     that of :func:`~generate_submit_script`. Besides an opened file which points to where the
     contents of the submit script are to be written, the parameters to
-    :func:`~generate_submit_script` are the :class:`~Job` that is being submitted and a `context`,
-    which is a dictionary with the following structure::
+    :func:`~generate_submit_script` are the :class:`~psij.Job` that is being submitted and a
+    `context`, which is a dictionary with the following structure::
 
         {
             'job': <the job being submitted>
@@ -121,15 +123,17 @@ class BatchSchedulerExecutor(JobExecutor):
     *script output file*.
 
     The *launch command* is a list of strings which the script generator should render as the
-    command to execute. It wraps the job executable in the proper :class:`~Launcher`.
+    command to execute. It wraps the job executable in the proper :class:`~psij.Launcher`.
 
     The function library is a dictionary mapping function names to functions for all public
-    functions in the :mod:`~template_function_library` module.
+    functions in the :mod:`~.template_function_library` module.
 
     The submit script *must* perform two essential actions:
+
         1. redirect the output of the executable part of the script to the *script output file*,
         which is a file in `<script_dir>` named `<native_id>.out`, where `<native_id>` is the id
         given to the job by the queuing system.
+
         2. store the exit code of the launch command in the *exit code file* named
         `<native_id>.ec`, also inside `<script_dir>`.
 
@@ -172,7 +176,7 @@ def _ensure_work_dir(self) -> None:
         self.work_directory.mkdir(parents=True, exist_ok=True)
 
     def submit(self, job: Job) -> None:
-        """See :func:`~JobExecutor.submit`."""
+        """See :func:`~psij.JobExecutor.submit`."""
         logger.info('Job %s: submitting', job.id)
         self._ensure_work_dir()
         assert (job.spec)
@@ -207,7 +211,7 @@ def cancel(self, job: Job) -> None:
         """Cancels a job if it has not otherwise completed.
 
         A command is constructed using :func:`~get_cancel_command` and executed in order to cancel
-        the job. Also see :func:`~JobExecutor.cancel`.
+        the job. Also see :func:`~psij.JobExecutor.cancel`.
         """
         if job.native_id is None:
             raise SubmitException('Job does not have a native ID.')
@@ -218,7 +222,7 @@ def cancel(self, job: Job) -> None:
         except subprocess.CalledProcessError as ex:
             try:
                 self.process_cancel_command_output(ex.returncode, ex.output)
-            except _InvalidJobStateError:
+            except InvalidJobStateError:
                 # do nothing; the job has completed anyway
                 pass
             except SubmitException:
@@ -350,7 +354,7 @@ def process_cancel_command_output(self, exit_code: int, out: str) -> None:
 
         Raises
         ------
-        _InvalidJobStateError
+        InvalidJobStateError
             Raised if the job cancellation has failed because the job was in a completed or failed
             state at the time when the cancellation command was invoked.
         psij.SubmitException
@@ -393,9 +397,9 @@ def parse_status_output(self, exit_code: int, out: str) -> Dict[str, JobStatus]:
             The string output of the status command as prescribed by :func:`~get_status_command`.
         Returns
         -------
-        A dictionary mapping native job ids to :class:`~JobStatus` objects. The implementation of
-        this method need not process the *exit code file* or the *script output file* since it is
-        done by the base `BatchSchedulerExecutor` implementation.
+        A dictionary mapping native job ids to :class:`~psij.JobStatus` objects. The
+        implementation of this method need not process the *exit code file* or the *script
+        output file* since it is done by the base `BatchSchedulerExecutor` implementation.
         """
         pass
 
@@ -521,7 +525,7 @@ def _delete_aux_file(self, job: Optional[Job] = None, suffix: Optional[str] = No
     def list(self) -> List[str]:
         """Returns a list of jobs known to the underlying implementation.
 
-        See :func:`~JobExecutor.list`.
+        See :func:`~psij.JobExecutor.list`.
         The returned list is a list of `native_id` strings representing jobs known to the
         underlying batch scheduler implementation, whether submitted through this executor or not.
         Implementations are encouraged to restrict the results to jobs accessible by the current
diff --git a/src/psij/executors/batch/cobalt.py b/src/psij/executors/batch/cobalt.py
index d56012c3..b53697e4 100644
--- a/src/psij/executors/batch/cobalt.py
+++ b/src/psij/executors/batch/cobalt.py
@@ -52,21 +52,21 @@ def __init__(
     def generate_submit_script(
         self, job: Job, context: Dict[str, object], submit_file: TextIO
     ) -> None:
-        """See :meth:`~BatchSchedulerExecutor.generate_submit_script`."""
+        """See :meth:`~.BatchSchedulerExecutor.generate_submit_script`."""
         self.generator.generate_submit_script(job, context, submit_file)
 
     def get_submit_command(self, job: Job, submit_file_path: Path) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_submit_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_submit_command`."""
         str_path = str(submit_file_path.absolute())
         os.chmod(str_path, os.stat(str_path).st_mode | stat.S_IEXEC)
         return ["qsub", str_path]
 
     def get_cancel_command(self, native_id: str) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_cancel_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_cancel_command`."""
         return ["qdel", native_id]
 
     def process_cancel_command_output(self, exit_code: int, out: str) -> None:
-        """See :meth:`~BatchSchedulerExecutor.process_cancel_command_output`.
+        """See :meth:`~.BatchSchedulerExecutor.process_cancel_command_output`.
 
         This should be unnecessary because `qdel` only seems to fail on
         non-integer job IDs.
@@ -74,11 +74,11 @@ def process_cancel_command_output(self, exit_code: int, out: str) -> None:
         raise SubmitException("Failed job cancel job: %s" % out)
 
     def get_status_command(self, native_ids: Collection[str]) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_status_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_status_command`."""
         return [_QSTAT_COMMAND, "-l", "--header=Jobid:State", *native_ids]
 
     def parse_status_output(self, exit_code: int, out: str) -> Dict[str, JobStatus]:
-        """See :meth:`~BatchSchedulerExecutor.parse_status_output`."""
+        """See :meth:`~.BatchSchedulerExecutor.parse_status_output`."""
         # if none of the job ID passed to Cobalt are recognized, qstat returns 1,
         # but we shouldn't treat that as an error
         if exit_code != 0 and out == UNKNOWN_ERROR:
@@ -103,7 +103,7 @@ def parse_status_output(self, exit_code: int, out: str) -> Dict[str, JobStatus]:
         return job_statuses
 
     def job_id_from_submit_output(self, out: str) -> str:
-        """See :meth:`~BatchSchedulerExecutor.job_id_from_submit_output`."""
+        """See :meth:`~.BatchSchedulerExecutor.job_id_from_submit_output`."""
         match = _QSUB_REGEX.search(out)
         if match is None:
             raise SubmitException(out)
diff --git a/src/psij/executors/batch/lsf.py b/src/psij/executors/batch/lsf.py
index e55242bd..3842af4e 100644
--- a/src/psij/executors/batch/lsf.py
+++ b/src/psij/executors/batch/lsf.py
@@ -55,17 +55,17 @@ def __init__(self, url: Optional[str], config: Optional[LsfExecutorConfig] = Non
     def generate_submit_script(
         self, job: Job, context: Dict[str, object], submit_file: TextIO
     ) -> None:
-        """See :meth:`~BatchSchedulerExecutor.generate_submit_script`."""
+        """See :meth:`~.BatchSchedulerExecutor.generate_submit_script`."""
         assert(job.spec is not None)
         context["job_duration"] = int(job.spec.attributes.duration.total_seconds() // 60)
         self.generator.generate_submit_script(job, context, submit_file)
 
     def get_submit_command(self, job: Job, submit_file_path: Path) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_submit_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_submit_command`."""
         return ["bsub", str(submit_file_path.absolute())]
 
     def get_cancel_command(self, native_id: str) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_cancel_command`.
+        """See :meth:`~.BatchSchedulerExecutor.get_cancel_command`.
 
         ``bkill`` will exit with an error set if the job does not exist
         or has already finished.
@@ -73,7 +73,7 @@ def get_cancel_command(self, native_id: str) -> List[str]:
         return ["bkill", native_id]
 
     def process_cancel_command_output(self, exit_code: int, out: str) -> None:
-        """See :meth:`~BatchSchedulerExecutor.process_cancel_command_output`.
+        """See :meth:`~.BatchSchedulerExecutor.process_cancel_command_output`.
 
         Check if the error was raised only because a job already exited.
         """
@@ -81,7 +81,7 @@ def process_cancel_command_output(self, exit_code: int, out: str) -> None:
             raise SubmitException(out)
 
     def get_status_command(self, native_ids: Collection[str]) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_status_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_status_command`."""
         return [
             _BJOBS_COMMAND,
             "-o",
@@ -92,7 +92,7 @@ def get_status_command(self, native_ids: Collection[str]) -> List[str]:
         ]
 
     def parse_status_output(self, exit_code: int, out: str) -> Dict[str, JobStatus]:
-        """See :meth:`~BatchSchedulerExecutor.parse_status_output`.
+        """See :meth:`~.BatchSchedulerExecutor.parse_status_output`.
 
         Iterate through the RECORDS entry, grabbing JOBID and STAT entries, as well
         as any state-change reasons if present.
@@ -113,7 +113,7 @@ def parse_status_output(self, exit_code: int, out: str) -> Dict[str, JobStatus]:
         return status_map
 
     def job_id_from_submit_output(self, out: str) -> str:
-        """See :meth:`~BatchSchedulerExecutor.job_id_from_submit_output`."""
+        """See :meth:`~.BatchSchedulerExecutor.job_id_from_submit_output`."""
         match = _BSUB_REGEX.search(out)
         if match is None:
             raise SubmitException(out)
diff --git a/src/psij/executors/batch/pbspro.py b/src/psij/executors/batch/pbspro.py
index 825c78f8..224b1d94 100644
--- a/src/psij/executors/batch/pbspro.py
+++ b/src/psij/executors/batch/pbspro.py
@@ -63,21 +63,21 @@ def __init__(self, url: Optional[str] = None, config: Optional[PBSProExecutorCon
 
     def generate_submit_script(self, job: Job, context: Dict[str, object],
                                submit_file: TextIO) -> None:
-        """See :meth:`~BatchSchedulerExecutor.generate_submit_script`."""
+        """See :meth:`~.BatchSchedulerExecutor.generate_submit_script`."""
         self.generator.generate_submit_script(job, context, submit_file)
 
     def get_submit_command(self, job: Job, submit_file_path: Path) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_submit_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_submit_command`."""
         return ['qsub', str(submit_file_path.absolute())]
 
     def job_id_from_submit_output(self, out: str) -> str:
-        """See :meth:`~BatchSchedulerExecutor.job_id_from_submit_output`."""
+        """See :meth:`~.BatchSchedulerExecutor.job_id_from_submit_output`."""
         return out.strip().split()[-1]
 
     # Cancel methods
 
     def get_cancel_command(self, native_id: str) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_cancel_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_cancel_command`."""
         # the slurm cancel command had a -Q parameter
         # which does not report an error if the job is already
         # completed.
@@ -88,13 +88,13 @@ def get_cancel_command(self, native_id: str) -> List[str]:
         return ['qdel', native_id]
 
     def process_cancel_command_output(self, exit_code: int, out: str) -> None:
-        """See :meth:`~BatchSchedulerExecutor.process_cancel_command_output`."""
+        """See :meth:`~.BatchSchedulerExecutor.process_cancel_command_output`."""
         raise SubmitException('Failed job cancel job: %s' % out)
 
     # Status methods
 
     def get_status_command(self, native_ids: Collection[str]) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_status_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_status_command`."""
         # -x will include finished jobs
         # -f -F json will give json status output that is more mechanically
         # parseable that the default human readable output. Most importantly,
@@ -103,7 +103,7 @@ def get_status_command(self, native_ids: Collection[str]) -> List[str]:
         return [_QSTAT_COMMAND, '-f', '-F', 'json', '-x'] + list(native_ids)
 
     def parse_status_output(self, exit_code: int, out: str) -> Dict[str, JobStatus]:
-        """See :meth:`~BatchSchedulerExecutor.parse_status_output`."""
+        """See :meth:`~.BatchSchedulerExecutor.parse_status_output`."""
         check_status_exit_code(_QSTAT_COMMAND, exit_code, out)
         r = {}
 
diff --git a/src/psij/executors/batch/script_generator.py b/src/psij/executors/batch/script_generator.py
index a086673b..96cbb818 100644
--- a/src/psij/executors/batch/script_generator.py
+++ b/src/psij/executors/batch/script_generator.py
@@ -11,9 +11,9 @@
 class SubmitScriptGenerator(ABC):
     """A base class representing a submit script generator.
 
-    A submit script generator is used to render a :class:`~Job` (together with all its properties,
-    including :class:`~JobSpec`, :class:`~ResourceSpec`, etc.) into a submit script specific to
-    a certain batch scheduler.
+    A submit script generator is used to render a :class:`~psij.Job` (together with all its
+    properties, including :class:`~psij.JobSpec`, :class:`~psij.ResourceSpec`, etc.) into a submit
+    script specific to a certain batch scheduler.
     """
 
     def __init__(self, config: JobExecutorConfig) -> None:
@@ -34,7 +34,7 @@ def generate_submit_script(self, job: Job, context: Dict[str, object], out: Text
 
         Concerete implementations of submit script generators must implement this method. Its
         purpose is to generate the content of the submit script. For an extensive explanation of
-        the mechanism behind this process, see :class:`~BatchSchedulerExecutor`.
+        the mechanism behind this process, see :class:`~.BatchSchedulerExecutor`.
 
         Parameters
         ----------
@@ -42,7 +42,7 @@ def generate_submit_script(self, job: Job, context: Dict[str, object], out: Text
             The job for which the submit script is to be generated.
         context
             A dictionary containing information about the context in which the job is being
-            submitted. For details, see :class:`~BatchSchedulerExecutor`.
+            submitted. For details, see :class:`~.BatchSchedulerExecutor`.
         out
             An opened file-like object to which the contents of the submit script should be
             written.
diff --git a/src/psij/executors/batch/slurm.py b/src/psij/executors/batch/slurm.py
index 4b1fa805..513c51a2 100644
--- a/src/psij/executors/batch/slurm.py
+++ b/src/psij/executors/batch/slurm.py
@@ -107,23 +107,23 @@ def __init__(self, url: Optional[str] = None, config: Optional[SlurmExecutorConf
 
     def generate_submit_script(self, job: Job, context: Dict[str, object],
                                submit_file: TextIO) -> None:
-        """See :meth:`~BatchSchedulerExecutor.generate_submit_script`."""
+        """See :meth:`~.BatchSchedulerExecutor.generate_submit_script`."""
         self.generator.generate_submit_script(job, context, submit_file)
 
     def get_submit_command(self, job: Job, submit_file_path: Path) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_submit_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_submit_command`."""
         return ['sbatch', str(submit_file_path.absolute())]
 
     def get_cancel_command(self, native_id: str) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_cancel_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_cancel_command`."""
         return ['scancel', '-Q', native_id]
 
     def process_cancel_command_output(self, exit_code: int, out: str) -> None:
-        """See :meth:`~BatchSchedulerExecutor.process_cancel_command_output`."""
+        """See :meth:`~.BatchSchedulerExecutor.process_cancel_command_output`."""
         raise SubmitException('Failed job cancel job: %s' % out)
 
     def get_status_command(self, native_ids: Collection[str]) -> List[str]:
-        """See :meth:`~BatchSchedulerExecutor.get_status_command`."""
+        """See :meth:`~.BatchSchedulerExecutor.get_status_command`."""
         ids = ','.join(native_ids)
 
         # we're not really using job arrays, so this is equivalent to the job ID. However, if
@@ -132,7 +132,7 @@ def get_status_command(self, native_ids: Collection[str]) -> List[str]:
         return [_SQUEUE_COMMAND, '-O', 'JobArrayID,StateCompact,Reason', '-t', 'all', '-j', ids]
 
     def parse_status_output(self, exit_code: int, out: str) -> Dict[str, JobStatus]:
-        """See :meth:`~BatchSchedulerExecutor.parse_status_output`."""
+        """See :meth:`~.BatchSchedulerExecutor.parse_status_output`."""
         check_status_exit_code(_SQUEUE_COMMAND, exit_code, out)
         r = {}
         lines = iter(out.split('\n'))
@@ -159,5 +159,5 @@ def _get_message(self, reason: str) -> str:
         return SlurmJobExecutor._REASONS_MAP[reason]
 
     def job_id_from_submit_output(self, out: str) -> str:
-        """See :meth:`~BatchSchedulerExecutor.job_id_from_submit_output`."""
+        """See :meth:`~.BatchSchedulerExecutor.job_id_from_submit_output`."""
         return out.strip().split()[-1]
diff --git a/src/psij/executors/flux.py b/src/psij/executors/flux.py
index c92d3789..4dccab97 100644
--- a/src/psij/executors/flux.py
+++ b/src/psij/executors/flux.py
@@ -117,7 +117,7 @@ def _add_flux_callbacks(self, job: Job, fut: flux.job.FluxExecutorFuture) -> Non
         self._futures[job] = fut
 
     def submit(self, job: Job) -> None:
-        """See :func:`~JobExecutor.submit`."""
+        """See :func:`~psij.job_executor.JobExecutor.submit`."""
         assert job.spec
         assert job.spec.attributes
         if isinstance(job.spec.resources, ResourceSpecV1):
@@ -157,13 +157,13 @@ def submit(self, job: Job) -> None:
         self._add_flux_callbacks(job, fut)
 
     def cancel(self, job: Job) -> None:
-        """See :func:`~JobExecutor.cancel`."""
+        """See :func:`~psij.job_executor.JobExecutor.cancel`."""
         fut = self._futures[job]
         if not fut.cancel():
             flux.job.cancel_async(self._fh, fut.jobid())
 
     def list(self) -> List[str]:
-        """See :func:`~JobExecutor.submit`.
+        """See :func:`~psij.job_executor.JobExecutor.list`.
 
         Return a list of ids representing jobs that are running on the
         underlying implementation - in this case Flux job IDs.
@@ -180,7 +180,7 @@ def attach(self, job: Job, native_id: str) -> None:
 
         :param job: The job to attach.
         :param native_id: The native ID of the process to attached to, as
-          obtained through :func:`~psij.executors.RPJobExecutor.list` method.
+          obtained through :func:`~psij.executors.flux.FluxJobExecutor.list` method.
         """
         self._add_flux_callbacks(job, self._flux_executor.attach(native_id))
 
diff --git a/src/psij/executors/local.py b/src/psij/executors/local.py
index 78803f0c..98646bf5 100644
--- a/src/psij/executors/local.py
+++ b/src/psij/executors/local.py
@@ -160,7 +160,7 @@ def cancel(self, job: Job) -> None:
 
 class LocalJobExecutor(JobExecutor):
     """
-    A job executor that runs jobs locally using :func:`popen`.
+    A job executor that runs jobs locally using :class:`subprocess.Popen`.
 
     Limitations: in Linux, attached jobs always appear to complete with a zero exit code regardless
     of the actual exit code.
diff --git a/src/psij/executors/rp.py b/src/psij/executors/rp.py
index 6a484d7a..8824e151 100644
--- a/src/psij/executors/rp.py
+++ b/src/psij/executors/rp.py
@@ -153,7 +153,7 @@ def cancel(self, job: Job) -> None:
         self._tmgr.cancel_tasks(uids=task.uid)
 
     def list(self) -> List[str]:
-        """See :func:`~JobExecutor.list`.
+        """See :func:`~psij.job_executor.JobExecutor.list`.
 
         Return a list of ids representing jobs that are running on the
         underlying implementation - in this case RP task IDs.
@@ -166,11 +166,11 @@ def attach(self, job: Job, native_id: str) -> None:
         """
         Attaches a job to a process.
 
-        The job must be in the :attr:`~psij.JobState.NEW` state.
+        The job must be in the :attr:`~psij.job_state.JobState.NEW` state.
 
         :param job: The job to attach.
         :param native_id: The native ID of the process to attached to, as
-            obtained through :func:`~psij.executors.RPJobExecutor.list` method.
+            obtained through :func:`~list` method.
         """
         if job.status.state != JobState.NEW:
             raise InvalidJobException('Job must be in the NEW state')
diff --git a/src/psij/launcher.py b/src/psij/launcher.py
new file mode 100644
index 00000000..61ea380b
--- /dev/null
+++ b/src/psij/launcher.py
@@ -0,0 +1,120 @@
+"""This module contains the core classes of the launchers infrastructure."""
+
+from abc import ABC, abstractmethod
+from typing import Optional, List, Dict
+
+from psij.descriptor import Descriptor, _VersionEntry
+from psij._plugins import _register_plugin, _get_plugin_class, _print_plugin_status
+from psij.job_executor_config import JobExecutorConfig
+from psij.job import Job
+
+
+class Launcher(ABC):
+    """An abstract base class for all launchers."""
+
+    _launchers = {}  # type: Dict[str, List[_VersionEntry['Launcher']]]
+    DEFAULT_LAUNCHER_NAME = 'single'
+
+    def __init__(self, config: Optional[JobExecutorConfig] = None) -> None:
+        """
+        Base constructors for launchers.
+
+        :param config: An optional configuration. If not specified,
+            :attr:`~psij.JobExecutorConfig.DEFAULT` is used.
+        """
+        if config is None:
+            config = JobExecutorConfig.DEFAULT
+        self.config = config
+
+    @abstractmethod
+    def get_launch_command(self, job: Job) -> List[str]:
+        """
+        Constructs a command to launch a job given a job specification.
+
+        :param job: The job to launch.
+        :return: A list of strings representing the launch command and all of its arguments.
+        """
+        pass
+
+    @abstractmethod
+    def is_launcher_failure(self, output: str) -> bool:
+        """
+        Determines whether the launcher invocation output contains a launcher failure or not.
+
+        Parameters
+        ----------
+        output
+            The output (combined stdout/stderr) from an invocation of the launcher command
+
+        Returns
+        -------
+        Returns `True` if the output of the launcher indicates that it has exited with a
+        non-zero exit code due to an error occurring in the launcher.
+
+        """
+        pass
+
+    @abstractmethod
+    def get_launcher_failure_message(self, output: str) -> str:
+        """
+        Extracts the launcher error message from the output of this launcher's invocation.
+
+        It is understood that the output is such that
+        :func:`~psij.launcher.Launcher.is_launcher_failure` returns `True` on it.
+
+        Parameters
+        ----------
+        output
+            The output (combined stdout/stderr) from an invocation of the launcher command.
+
+        Returns
+        -------
+        A string representing the part of the launcher output that describes the launcher
+        error.
+        """
+        pass
+
+    @staticmethod
+    def get_instance(name: str, version_constraint: Optional[str] = None,
+                     config: Optional[JobExecutorConfig] = None) -> 'Launcher':
+        """
+        Returns an instance of a launcher optionally configured using a certain configuration.
+
+        The returned instance may or may not be a singleton object.
+
+        :param name: The name of the launcher to return an instance of.
+        :param config: An optional configuration.
+        :return: A launcher instance.
+        """
+        selected = _get_plugin_class(name, version_constraint, 'launcher', Launcher._launchers)
+
+        assert selected.ecls is not None
+        assert issubclass(selected.ecls, Launcher)
+        setattr(selected.ecls, '_NAME_', name)
+        setattr(selected.ecls, '_VERSION_', selected.version)
+        instance = selected.ecls(config=config)
+        return instance
+
+    @staticmethod
+    def register_launcher(desc: Descriptor, root: str) -> None:
+        """
+        Registers a launcher class.
+
+        The registered class can then be instantiated using :func:`~psij.Launcher.get_instance`.
+
+        Parameters
+        ----------
+        desc
+            A :class:`~psij.descriptor.Descriptor` with information about the launcher to
+            register.
+        root
+            A filesystem path under which the implementation of the launcher is to be loaded from.
+            Launchers from other locations, even if under the correct package, will not be
+            registered by this method. If a launcher implementation is only available under a
+            different root path, this method will throw an exception.
+        """
+        _register_plugin(desc, root, 'launcher', Launcher._launchers)
+
+    @staticmethod
+    def _print_plugin_status() -> None:
+        _print_plugin_status(Launcher._launchers, 'launcher')
diff --git a/src/psij/launchers/multiple.py b/src/psij/launchers/multiple.py
index 75952aa9..6be30863 100644
--- a/src/psij/launchers/multiple.py
+++ b/src/psij/launchers/multiple.py
@@ -28,7 +28,9 @@ def __init__(self, script_path: Path = Path(__file__).parent / 'scripts' / 'mult
         """
         super().__init__(script_path=script_path, config=config)
 
-    def _get_additional_args(self, job: Job) -> List[str]:
+    def get_additional_args(self, job: Job) -> List[str]:
+        """See \
+        :func:`~psij.launchers.script_based_launcher.ScriptBasedLauncher.get_additional_args`."""
         spec = job.spec
         assert spec is not None
         return [str(self._get_count(spec.resources))]
diff --git a/src/psij/launchers/script_based_launcher.py b/src/psij/launchers/script_based_launcher.py
index 47dd8960..92de221c 100644
--- a/src/psij/launchers/script_based_launcher.py
+++ b/src/psij/launchers/script_based_launcher.py
@@ -40,8 +40,7 @@ class ScriptBasedLauncher(Launcher):
     * the pre- and post- launcher scripts, or empty strings if they are not specified
 
     Additional positional arguments to the script can be specified by subclasses by overriding
-    the :func:`~psij.launchers.script_based_launcher.ScriptBasedLauncher._get_additional_args`
-    function.
+    the :meth:`~.get_additional_args` method.
 
     The remaining arguments to the script are the job executable and arguments.
 
@@ -139,7 +138,7 @@ def get_launch_command(self, job: Job, log_file: Optional[str] = None) -> List[s
         args = ['/bin/bash', str(self._deployed_script_path), job.id, _str(log_file),
                 _str(spec.pre_launch), _str(spec.post_launch), _path(spec.stdin_path),
                 _path(spec.stdout_path), _path(spec.stderr_path)]
-        args += self._get_additional_args(job)
+        args += self.get_additional_args(job)
         assert spec.executable is not None
         args += [spec.executable]
         if spec.arguments is not None:
@@ -147,7 +146,7 @@ def get_launch_command(self, job: Job, log_file: Optional[str] = None) -> List[s
 
         return args
 
-    def _get_additional_args(self, job: Job) -> List[str]:
+    def get_additional_args(self, job: Job) -> List[str]:
         """
         Returns any additional arguments, after first mandatory four, to be passed to the script.
 
diff --git a/src/psij/resource_spec.py b/src/psij/resource_spec.py
index 1c79fff6..9f51a547 100644
--- a/src/psij/resource_spec.py
+++ b/src/psij/resource_spec.py
@@ -79,8 +79,8 @@ def computed_node_count(self) -> int:
         """
         Returns or calculates a node count.
 
-        If the :attr:`node_count` property is specified, this method returns it. If not, a node
-        count is calculated from the :attr:`process_count` and :attr:`processes_per_node`.
+        If the `node_count` property is specified, this method returns it. If not, a node
+        count is calculated from `process_count` and `processes_per_node`.
 
         :return: An integer value with the specified or calculated node count.
         """
@@ -95,10 +95,10 @@ def computed_process_count(self) -> int:
         """
         Returns or calculates a process count.
 
-        If the :attr:`process_count` property is specified, this method returns it, otherwise it
+        If the `process_count` property is specified, this method returns it, otherwise it
         returns 1.
 
-        :return: An integer value with either the value of :attr:`process_count` or one if the
+        :return: An integer value with either the value of `process_count` or one if the
             former is not specified.
         """
         self._check_constraints()


Test patch:
diff --git a/tests/plugins1/_batch_test/_batch_test.py b/tests/plugins1/_batch_test/_batch_test.py
index 7fdaf330..25772d29 100644
--- a/tests/plugins1/_batch_test/_batch_test.py
+++ b/tests/plugins1/_batch_test/_batch_test.py
@@ -4,7 +4,7 @@
 
 from psij import Job, JobStatus, JobState, SubmitException
 from psij.executors.batch.batch_scheduler_executor import BatchSchedulerExecutor, \
-    BatchSchedulerExecutorConfig, _InvalidJobStateError, check_status_exit_code
+    BatchSchedulerExecutorConfig, InvalidJobStateError, check_status_exit_code
 from psij.executors.batch.script_generator import TemplatedScriptGenerator
 
 
@@ -57,7 +57,7 @@ def get_cancel_command(self, native_id: str) -> List[str]:
 
     def process_cancel_command_output(self, exit_code: int, out: str) -> None:
         if exit_code == 16:
-            raise _InvalidJobStateError()
+            raise InvalidJobStateError()
         else:
             raise SubmitException(out)