Writing a custom Spawner

Each single-user server is started by a Spawner. The Spawner represents an abstract interface to a process, and a custom Spawner needs to be able to take three actions:

  1. start the process
  2. poll whether the process is still running
  3. stop the process

See a list of custom Spawners on the wiki.

Spawner.start

Spawner.start should start the single-user server for a single user. Information about the user can be retrieved from self.user, an object encapsulating the user’s name, authentication, and server info.

When Spawner.start returns, it should have stored the IP and port of the single-user server in self.user.server.

NOTE: when writing coroutines, never yield in between a db change and a commit. Most Spawner.starts should have something looking like:

def start(self):
    self.user.server.ip = 'localhost' # or other host or IP address, as seen by the Hub
    self.user.server.port = 1234 # port selected somehow
    self.db.commit() # always commit before yield, if modifying db values
    yield self._actually_start_server_somehow()

When Spawner.start returns, the single-user server process should actually be running, not just requested. JupyterHub can handle Spawner.start being very slow (such as PBS-style batch queues, or instantiating whole AWS instances) via relaxing the Spawner.start_timeout config value.

Spawner.poll

Spawner.poll should check if the spawner is still running. It should return None if it is still running, and an integer exit status, otherwise.

For the local process case, this uses os.kill(PID, 0) to check if the process is still around.

Spawner.stop

Spawner.stop should stop the process. It must be a tornado coroutine, and should return when the process has finished exiting.

Spawner state

JupyterHub should be able to stop and restart without having to teardown single-user servers. This means that a Spawner may need to persist some information that it can be restored. A dictionary of JSON-able state can be used to store this information.

Unlike start/stop/poll, the state methods must not be coroutines.

In the single-process case, this is only the process ID of the server:

def get_state(self):
    """get the current state"""
    state = super().get_state()
    if self.pid:
        state['pid'] = self.pid
    return state

def load_state(self, state):
    """load state from the database"""
    super().load_state(state)
    if 'pid' in state:
        self.pid = state['pid']

def clear_state(self):
    """clear any state (called after shutdown)"""
    super().clear_state()
    self.pid = 0

Spawner options form

(new in 0.4)

Some deployments may want to offer options to users to influence how their servers are started. This may include cluster-based deployments, where users specify what resources should be available, or docker-based deployments where users can select from a list of base images.

This feature is enabled by setting Spawner.options_form, which is an HTML form snippet inserted unmodified into the spawn form. If the Spawner.options_form is defined, when a user would start their server, they will be directed to a form page, like this:

spawn-form

If Spawner.options_form is undefined, the users server is spawned directly, and no spawn page is rendered.

See this example for a form that allows custom CLI args for the local spawner.

Spawner.options_from_form

Options from this form will always be a dictionary of lists of strings, e.g.:

{
  'integer': ['5'],
  'text': ['some text'],
  'select': ['a', 'b'],
}

When formdata arrives, it is passed through Spawner.options_from_form(formdata), which is a method to turn the form data into the correct structure. This method must return a dictionary, and is meant to interpret the lists-of-strings into the correct types, e.g. for the above form it would look like:

def options_from_form(self, formdata):
    options = {}
    options['integer'] = int(formdata['integer'][0]) # single integer value
    options['text'] = formdata['text'][0] # single string value
    options['select'] = formdata['select'] # list already correct
    options['notinform'] = 'extra info' # not in the form at all
    return options

which would return:

{
  'integer': 5,
  'text': 'some text',
  'select': ['a', 'b'],
  'notinform': 'extra info',
}

When Spawner.spawn is called, this dict is accessible as self.user_options.