Repository: jupyterhub/jupyterhub

Issue:
Configure options_form (or profile_list) for individual users
# What I want
I want to configure the users individual `spawner.options_form` (through KubeSpawner's `profile_list`) based on information provided by my identity provider for each authenticated user individually.

# Why I want this
When a user login, my JupyterHub currently receives information in a custom _claim_ called `gpu_access`. This is something that I've setup in [Okta](https://www.okta.com/) to be provided as a boolean based on if the user belongs to a specific group called GPU Access that I can manage nicely from an UI.

I want to utilize this information to decide what options the user could choose from when spawning. Should the user for example be allowed to utilize a dedicated GPU node?

# Code investigation
These are conclusions drawn from investigating JupyterHub's source code:
- The `pre_spawn_start` and `pre_spawn_hook` are called from within `spawn`.
- `spawn` is called from within `spawn_single_user`.
- `options_form` is evaluated before `spawn_single_user` is invoked in order to provide it with chosen options.

### auth.py / class Authenticator / pre_spawn_start()
https://github.com/jupyterhub/jupyterhub/blob/0e6cf6a485b244c8b02e0da0269648c0de6370f7/jupyterhub/auth.py#L358-L362

### auth.py / class Spawner / pre_spawn_hook()
https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/spawner.py#L541-L561

### user.py / class User / spawn()
This is where `pre_spawn_start` and `pre_spawn_hook` are invoked.
https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/user.py#L437-L451

### base.py / class BaseHandler / spawn_single_user()
This is where `spawn()` is invoked.
https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/handlers/base.py#L714

### base.py / class UserSpawnHandler / get()
https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/handlers/base.py#L1188-L1199

# Request for input
Before deciding to either support or not support configuring `options_form` on a per user basis, it seems reasonable to evaluate how it could be done first. So my request for input is regarding this how we could go about this.

Would we add another hook?
Where would we need to run it?
What arguments would we pass it?

## Ideas
Perhaps we could add a `user_refreshed_hook` on refreshed user state, that we would invoke from this method called `auth_to_user`? I think **this is my recommended solution**. But, I'm a bit uncertain, does this mean we need to allow the BaseHandler to be configurable with a traitlet? What is the @property fields doing? Hmmm...

https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/handlers/base.py#L560-L595

Perhaps we could add a `user_auth_state_updated_hook` whenever `auth_state` is updated/saved? The benefit of this is that we would do things whenever auth_state was updated, no matter if it was through a login etc or something else saved something to the users auth_state. The downside, is that perhaps we want to use a hook like this even though we don't save the auth_state.

https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/user.py#L161-L167
Configure options_form (or profile_list) for individual users
# What I want
I want to configure the users individual `spawner.options_form` (through KubeSpawner's `profile_list`) based on information provided by my identity provider for each authenticated user individually.

# Why I want this
When a user login, my JupyterHub currently receives information in a custom _claim_ called `gpu_access`. This is something that I've setup in [Okta](https://www.okta.com/) to be provided as a boolean based on if the user belongs to a specific group called GPU Access that I can manage nicely from an UI.

I want to utilize this information to decide what options the user could choose from when spawning. Should the user for example be allowed to utilize a dedicated GPU node?

# Code investigation
These are conclusions drawn from investigating JupyterHub's source code:
- The `pre_spawn_start` and `pre_spawn_hook` are called from within `spawn`.
- `spawn` is called from within `spawn_single_user`.
- `options_form` is evaluated before `spawn_single_user` is invoked in order to provide it with chosen options.

### auth.py / class Authenticator / pre_spawn_start()
https://github.com/jupyterhub/jupyterhub/blob/0e6cf6a485b244c8b02e0da0269648c0de6370f7/jupyterhub/auth.py#L358-L362

### auth.py / class Spawner / pre_spawn_hook()
https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/spawner.py#L541-L561

### user.py / class User / spawn()
This is where `pre_spawn_start` and `pre_spawn_hook` are invoked.
https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/user.py#L437-L451

### base.py / class BaseHandler / spawn_single_user()
This is where `spawn()` is invoked.
https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/handlers/base.py#L714

### base.py / class UserSpawnHandler / get()
https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/handlers/base.py#L1188-L1199

# Request for input
Before deciding to either support or not support configuring `options_form` on a per user basis, it seems reasonable to evaluate how it could be done first. So my request for input is regarding this how we could go about this.

Would we add another hook?
Where would we need to run it?
What arguments would we pass it?

## Ideas
Perhaps we could add a `user_refreshed_hook` on refreshed user state, that we would invoke from this method called `auth_to_user`? I think **this is my recommended solution**. But, I'm a bit uncertain, does this mean we need to allow the BaseHandler to be configurable with a traitlet? What is the @property fields doing? Hmmm...

https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/handlers/base.py#L560-L595

Perhaps we could add a `user_auth_state_updated_hook` whenever `auth_state` is updated/saved? The benefit of this is that we would do things whenever auth_state was updated, no matter if it was through a login etc or something else saved something to the users auth_state. The downside, is that perhaps we want to use a hook like this even though we don't save the auth_state.

https://github.com/jupyterhub/jupyterhub/blob/392e432071c21070ab3d54ab2cb72c8bda09e6f4/jupyterhub/user.py#L161-L167


Discussion:
@minrk what do you think? If you think it make sense and provide a direction I'll write a PR for this.
options_form is already configurable per-user, since it can be a callable, which can do arbitrary things based on `self.user`, including based on `await self.user.get_auth_state()`.

`pre_spawn_start` doesn't seem like the right hook for something that doesn't happen prior to spawn, but instead happens prior to requesting the options form as *input* to the spawn. If we need a new hook for that, it should be added, but I suspect that the existing async `.options_form` hook is enough. For your case, it sounds like I would write this hook:

```python
async def get_options_form(spawner):
    auth_state = await spawner.user.get_auth_state()
    has_gpu = auth_state.get('gpu-access')
    if has_gpu:
        return '<gpu form...>'
    else:
        return '<non-gpu form...>'

c.MySpawner.options_form = get_options_form
```

Does that work for you? It doesn't sound like anything would be accomplished by the additional hooks, to me, and I'd like to keep them to a minimum if we can.
@minrk thanks for taking the time to process my blob of text! This seems like an excellent example I did not consider! I'll investigate and follow up!
Thanks for the help @minrk !

I wanted to utilize `profile_list` by `KubeSpawner` and found the following to be a working solution to do that.

```yaml
hub:
  extraConfig:
    spawner: |
      from kubespawner import KubeSpawner

      class CustomKubeSpawner(KubeSpawner):
          # Override the options_form presented for users if they have gpu_access.
          # See: https://github.com/jupyterhub/jupyterhub/issues/2390 for related details
          async def options_form(self, spawner):
              auth_state = await self.user.get_auth_state()
              user_details = auth_state['oauth_user']

              username = user_details['preferred_username']
              gpu_access = user_details['gpu_access'] # type: bool

              # Dynamically provide a profile_list for those with GPU Access
              # ------------------------------------------------------------------
              # NOTE: This is setup using a custom scope and claim called gpu_access
              # NOTE: Claims are info provided after requesting certain Scopes.
              #       These are configurable in our Okta instance.
              if gpu_access:
                  self.log.info(f'GPU access options will be presented for {username}.')
                  self.profile_list = [
                      {
                          'default': True,
                          'display_name': 'Shared, 8 CPU cores',
                          'description': 'Your code will run on a shared machine. This will already be running and you should be up and running in 30 seconds or less.',
                      },            
                      {
                          'display_name': 'Personal, 4 CPU cores & 26GB RAM, 1 NVIDIA Tesla K80 GPU',
                          'description': 'Your code will run on a computer dedicated to you. No such computer is available ahead of time, so you need to wait 5-15 minutes before it is ready.',
                          'kubespawner_override': {
                              'image': 'eu.gcr.io/ds-platform/dsp-user-environment-gpu:c3959b5',
                              'extra_resource_limits': {
                                  'nvidia.com/gpu': '1',
                              },
                              'node_selector': {
                                  'gpu': 'k80',
                              },
                          },
                      },
                      {
                          'display_name': "Personal, 4 CPU cores & 26GB RAM, 1 NVIDIA Tesla P100 GPU",
                          'description': 'Your code will run on a computer dedicated to you. No such computer is available ahead of time, so you need to wait 5-15 minutes before it is ready.',
                          'kubespawner_override': {
                              'image': 'eu.gcr.io/ds-platform/dsp-user-environment-gpu:c3959b5',
                              'extra_resource_limits': {
                                  'nvidia.com/gpu': '1',
                              },
                              'node_selector': {
                                  'gpu': 'p100',
                              },
                          },
                      },
                  ]
              
              return super().options_form
              
      c.JupyterHub.spawner_class = CustomKubeSpawner
```
I still need to figure out how to avoid ending up with a blank choice screen if my profile_list is blank.

![image](https://user-images.githubusercontent.com/3837114/52693003-d13aba00-2f65-11e9-9379-968795b9cdda.png)

---

__Backtracking:__

What matters is that a GET request to /spawn is made. This in turn results in an evaluation if `options_form` is truthy, but as I have overridden the traitlet with a function, it is.

https://github.com/jupyterhub/jupyterhub/blob/919b6a8d6c1ac9577e27ee18fad451e975eedf18/jupyterhub/handlers/pages.py#L110-L114

This leads to the `_render_form` being executed:

https://github.com/jupyterhub/jupyterhub/blob/919b6a8d6c1ac9577e27ee18fad451e975eedf18/jupyterhub/handlers/pages.py#L70-L88

This in turn leads to the base class Spawner's `get_options_form` is called:

https://github.com/jupyterhub/jupyterhub/blob/7c46fe74a50da4badc332ef671954e2fac406757/jupyterhub/spawner.py#L308-L322

__Solution idea:__

I must do as KubeSpawner has done, I must provide a new default value of the traitlet `options_form` rather than overriding it with a truthy function.
## Struggling with my implementing solution idea

I'm struggling as `_options_form_default` isn't defined with `async` and I would like to do something like this...

```python
        class CustomKubeSpawner(KubeSpawner):
            @default('options_form')
            def _options_form_default(self):
                auth_state = await self.user.get_auth_state()
                user_details = auth_state['oauth_user']
                username = user_details['preferred_username']
                gpu_access = user_details['gpu_access'] # type: bool
                # ...
```

## Getting back to @minrk's idea

If we assign this function to `options_form` we will make it truthy, and then we will end up rendering the blank template.

```python
async def my_async_get_options_form(spawner):
    auth_state = await spawner.user.get_auth_state()
    has_gpu = auth_state.get('gpu-access')
    if has_gpu:
        return '<gpu form...>'
    else:
        return '<non-gpu form...>'

c.MySpawner.options_form = my_async_get_options_form
```

## Conclusion

I think I need to override whats going on in the handler of web requests to the `/spawn` endpoint in order to resolve this...

https://github.com/jupyterhub/jupyterhub/blob/919b6a8d6c1ac9577e27ee18fad451e975eedf18/jupyterhub/handlers/pages.py#L90-L126

This is because I need `options_form` to be falsy or truthy based awaiting information from `self.user.get_auth_state()`. But, only `get_options_form` could support this, but that will only be invoked if `options_form` is truthy. I think...

## Resolution?

While I now have something somewhat functional. Currently I cannot redirect past the options based on information returned in `get_auth_state`, but I can decide to always show the form based on information returned in `get_auth_state`.

I'm not sure how to proceed...

## Related issues

#1681 - About making the `Spawner` function `get_options_form` async. Note that this function is only invoked if `options_form` is truthy, as checked from `SpawnHandler` defined in jupyterhub.
Indeed, `_options_form_default` cannot be itself an async method (it can be called in init, so cannot be async). Instead, it can *return* an async method that will be called when options_form should be rendered, but that will be truthy.

I think there is indeed a case here that hasn't been considered and isn't yet supported yet: defining an options form for some users, and having other users see *no* options form, rather than a different one. Is this actually your use case, or are you just testing right now? Do some users have no profile list, while others do?

For now, I would recommend defining a default profile list with 'default', and then defining the gpu profiles as adding to that list. This should work right now.

From what you have described, my sketch would not return a blank options form, because all cases return a non-empty form. What's different is that you are describing a case where the `get_options_form` may return no options form at all, which is not currently allowed.

We can adjust this by actually completing the resolution of the async options form before considering its falsiness.
> I think I need to override whats going on in the handler of web requests to the `/spawn` endpoint in order to resolve this...

I don't think this is true. As long as async `.options_form` always returns a non-empty form, you should be okay. The worst case scenario for this is that users with only a default profile have an extra click to launch, but it has the *benefit* that they see there is an option-picking stage for their deployment, but they aren't entitled to more than one choice.
@minrk ah yes my plan was indeed to provide options for some users while bypassing the options screen for other users. But, I'll make do with always showing this screen no matter what. It will add some complexity in the onboarding experience of my users, but this is not vital and could also help the new users understand whats going on and what may be possible for them if they request further permissions.

I'm happy to work on supporting the use case of _bypassing the options form based on what `options_form` asynchronously returns_ if you think it could make sense.
> if you think it could make sense.

I think it could. It should work if we replace the boolean `if spawner.options_form` checks with async checks to `if await spawner.get_options_form()`. The only tricky bit will be if there are any such checks in places that can't be easily async.
Perhaps we should make the `_render_form` non-async by passing it the awaited `get_spawner_option` results.

I think the only place where `get_options_form` of the base Spawner class is called, is from within `_render_form` of the SpawnerHandler class. The `_render_form` is in turn called from the `post` request if an error occur or from `get` - both within the SpawnerHandler class.

> The only tricky bit will be if there are any such checks in places that can't be easily async.

I didn't grasp this very well. Do you mean it will be tricky if we have this blocking await check and this takes a while to evaluate?
I meant replace all "is there an options form" checks, which are currently `if spawner.options_form` to `if await spawner.get_options_form()`, which returns the actual form, making it consistent with the old behavior of self.options_form when it could only be synchronously produced.

> Do you mean it will be tricky if we have this blocking await check and this takes a while to evaluate?

No, I mean syntactically. Not all places in the code are valid to add an `await` (`_options_form_default`, for example, cannot be made async). I'm not sure if there are any relevant places for this, but it could come up (inside a template render, for instance). This doesn't mean it can't be done, just that the change isn't just one line. This is, for instance, why `_render_form` calls get_options_form and passes the result to the render, because once you are inside render, no async methods can be called.

The alternative is a smaller change in the spawn page logic, *only* doing the full render there:

```python
options_form = await spawner.get_options_form()
if options_form:
    render_form...
else:
    # Explicit spawn request:
```

Now that I think of it, that's probably the way to go.
@minrk okay I think we arrived at the same conclusion, PR coming up to make things easier to discuss further.
@minrk what do you think? If you think it make sense and provide a direction I'll write a PR for this.
options_form is already configurable per-user, since it can be a callable, which can do arbitrary things based on `self.user`, including based on `await self.user.get_auth_state()`.

`pre_spawn_start` doesn't seem like the right hook for something that doesn't happen prior to spawn, but instead happens prior to requesting the options form as *input* to the spawn. If we need a new hook for that, it should be added, but I suspect that the existing async `.options_form` hook is enough. For your case, it sounds like I would write this hook:

```python
async def get_options_form(spawner):
    auth_state = await spawner.user.get_auth_state()
    has_gpu = auth_state.get('gpu-access')
    if has_gpu:
        return '<gpu form...>'
    else:
        return '<non-gpu form...>'

c.MySpawner.options_form = get_options_form
```

Does that work for you? It doesn't sound like anything would be accomplished by the additional hooks, to me, and I'd like to keep them to a minimum if we can.
@minrk thanks for taking the time to process my blob of text! This seems like an excellent example I did not consider! I'll investigate and follow up!
Thanks for the help @minrk !

I wanted to utilize `profile_list` by `KubeSpawner` and found the following to be a working solution to do that.

```yaml
hub:
  extraConfig:
    spawner: |
      from kubespawner import KubeSpawner

      class CustomKubeSpawner(KubeSpawner):
          # Override the options_form presented for users if they have gpu_access.
          # See: https://github.com/jupyterhub/jupyterhub/issues/2390 for related details
          async def options_form(self, spawner):
              auth_state = await self.user.get_auth_state()
              user_details = auth_state['oauth_user']

              username = user_details['preferred_username']
              gpu_access = user_details['gpu_access'] # type: bool

              # Dynamically provide a profile_list for those with GPU Access
              # ------------------------------------------------------------------
              # NOTE: This is setup using a custom scope and claim called gpu_access
              # NOTE: Claims are info provided after requesting certain Scopes.
              #       These are configurable in our Okta instance.
              if gpu_access:
                  self.log.info(f'GPU access options will be presented for {username}.')
                  self.profile_list = [
                      {
                          'default': True,
                          'display_name': 'Shared, 8 CPU cores',
                          'description': 'Your code will run on a shared machine. This will already be running and you should be up and running in 30 seconds or less.',
                      },            
                      {
                          'display_name': 'Personal, 4 CPU cores & 26GB RAM, 1 NVIDIA Tesla K80 GPU',
                          'description': 'Your code will run on a computer dedicated to you. No such computer is available ahead of time, so you need to wait 5-15 minutes before it is ready.',
                          'kubespawner_override': {
                              'image': 'eu.gcr.io/ds-platform/dsp-user-environment-gpu:c3959b5',
                              'extra_resource_limits': {
                                  'nvidia.com/gpu': '1',
                              },
                              'node_selector': {
                                  'gpu': 'k80',
                              },
                          },
                      },
                      {
                          'display_name': "Personal, 4 CPU cores & 26GB RAM, 1 NVIDIA Tesla P100 GPU",
                          'description': 'Your code will run on a computer dedicated to you. No such computer is available ahead of time, so you need to wait 5-15 minutes before it is ready.',
                          'kubespawner_override': {
                              'image': 'eu.gcr.io/ds-platform/dsp-user-environment-gpu:c3959b5',
                              'extra_resource_limits': {
                                  'nvidia.com/gpu': '1',
                              },
                              'node_selector': {
                                  'gpu': 'p100',
                              },
                          },
                      },
                  ]
              
              return super().options_form
              
      c.JupyterHub.spawner_class = CustomKubeSpawner
```
I still need to figure out how to avoid ending up with a blank choice screen if my profile_list is blank.

![image](https://user-images.githubusercontent.com/3837114/52693003-d13aba00-2f65-11e9-9379-968795b9cdda.png)

---

__Backtracking:__

What matters is that a GET request to /spawn is made. This in turn results in an evaluation if `options_form` is truthy, but as I have overridden the traitlet with a function, it is.

https://github.com/jupyterhub/jupyterhub/blob/919b6a8d6c1ac9577e27ee18fad451e975eedf18/jupyterhub/handlers/pages.py#L110-L114

This leads to the `_render_form` being executed:

https://github.com/jupyterhub/jupyterhub/blob/919b6a8d6c1ac9577e27ee18fad451e975eedf18/jupyterhub/handlers/pages.py#L70-L88

This in turn leads to the base class Spawner's `get_options_form` is called:

https://github.com/jupyterhub/jupyterhub/blob/7c46fe74a50da4badc332ef671954e2fac406757/jupyterhub/spawner.py#L308-L322

__Solution idea:__

I must do as KubeSpawner has done, I must provide a new default value of the traitlet `options_form` rather than overriding it with a truthy function.
## Struggling with my implementing solution idea

I'm struggling as `_options_form_default` isn't defined with `async` and I would like to do something like this...

```python
        class CustomKubeSpawner(KubeSpawner):
            @default('options_form')
            def _options_form_default(self):
                auth_state = await self.user.get_auth_state()
                user_details = auth_state['oauth_user']
                username = user_details['preferred_username']
                gpu_access = user_details['gpu_access'] # type: bool
                # ...
```

## Getting back to @minrk's idea

If we assign this function to `options_form` we will make it truthy, and then we will end up rendering the blank template.

```python
async def my_async_get_options_form(spawner):
    auth_state = await spawner.user.get_auth_state()
    has_gpu = auth_state.get('gpu-access')
    if has_gpu:
        return '<gpu form...>'
    else:
        return '<non-gpu form...>'

c.MySpawner.options_form = my_async_get_options_form
```

## Conclusion

I think I need to override whats going on in the handler of web requests to the `/spawn` endpoint in order to resolve this...

https://github.com/jupyterhub/jupyterhub/blob/919b6a8d6c1ac9577e27ee18fad451e975eedf18/jupyterhub/handlers/pages.py#L90-L126

This is because I need `options_form` to be falsy or truthy based awaiting information from `self.user.get_auth_state()`. But, only `get_options_form` could support this, but that will only be invoked if `options_form` is truthy. I think...

## Resolution?

While I now have something somewhat functional. Currently I cannot redirect past the options based on information returned in `get_auth_state`, but I can decide to always show the form based on information returned in `get_auth_state`.

I'm not sure how to proceed...

## Related issues

#1681 - About making the `Spawner` function `get_options_form` async. Note that this function is only invoked if `options_form` is truthy, as checked from `SpawnHandler` defined in jupyterhub.
Indeed, `_options_form_default` cannot be itself an async method (it can be called in init, so cannot be async). Instead, it can *return* an async method that will be called when options_form should be rendered, but that will be truthy.

I think there is indeed a case here that hasn't been considered and isn't yet supported yet: defining an options form for some users, and having other users see *no* options form, rather than a different one. Is this actually your use case, or are you just testing right now? Do some users have no profile list, while others do?

For now, I would recommend defining a default profile list with 'default', and then defining the gpu profiles as adding to that list. This should work right now.

From what you have described, my sketch would not return a blank options form, because all cases return a non-empty form. What's different is that you are describing a case where the `get_options_form` may return no options form at all, which is not currently allowed.

We can adjust this by actually completing the resolution of the async options form before considering its falsiness.
> I think I need to override whats going on in the handler of web requests to the `/spawn` endpoint in order to resolve this...

I don't think this is true. As long as async `.options_form` always returns a non-empty form, you should be okay. The worst case scenario for this is that users with only a default profile have an extra click to launch, but it has the *benefit* that they see there is an option-picking stage for their deployment, but they aren't entitled to more than one choice.
@minrk ah yes my plan was indeed to provide options for some users while bypassing the options screen for other users. But, I'll make do with always showing this screen no matter what. It will add some complexity in the onboarding experience of my users, but this is not vital and could also help the new users understand whats going on and what may be possible for them if they request further permissions.

I'm happy to work on supporting the use case of _bypassing the options form based on what `options_form` asynchronously returns_ if you think it could make sense.
> if you think it could make sense.

I think it could. It should work if we replace the boolean `if spawner.options_form` checks with async checks to `if await spawner.get_options_form()`. The only tricky bit will be if there are any such checks in places that can't be easily async.
Perhaps we should make the `_render_form` non-async by passing it the awaited `get_spawner_option` results.

I think the only place where `get_options_form` of the base Spawner class is called, is from within `_render_form` of the SpawnerHandler class. The `_render_form` is in turn called from the `post` request if an error occur or from `get` - both within the SpawnerHandler class.

> The only tricky bit will be if there are any such checks in places that can't be easily async.

I didn't grasp this very well. Do you mean it will be tricky if we have this blocking await check and this takes a while to evaluate?
I meant replace all "is there an options form" checks, which are currently `if spawner.options_form` to `if await spawner.get_options_form()`, which returns the actual form, making it consistent with the old behavior of self.options_form when it could only be synchronously produced.

> Do you mean it will be tricky if we have this blocking await check and this takes a while to evaluate?

No, I mean syntactically. Not all places in the code are valid to add an `await` (`_options_form_default`, for example, cannot be made async). I'm not sure if there are any relevant places for this, but it could come up (inside a template render, for instance). This doesn't mean it can't be done, just that the change isn't just one line. This is, for instance, why `_render_form` calls get_options_form and passes the result to the render, because once you are inside render, no async methods can be called.

The alternative is a smaller change in the spawn page logic, *only* doing the full render there:

```python
options_form = await spawner.get_options_form()
if options_form:
    render_form...
else:
    # Explicit spawn request:
```

Now that I think of it, that's probably the way to go.
@minrk okay I think we arrived at the same conclusion, PR coming up to make things easier to discuss further.

Resolving patch:
diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py
--- a/jupyterhub/handlers/base.py
+++ b/jupyterhub/handlers/base.py
@@ -1209,7 +1209,7 @@ async def get(self, user_name, user_path):
                 status = 0
             # server is not running, trigger spawn
             if status is not None:
-                if spawner.options_form:
+                if await spawner.get_options_form():
                     url_parts = [self.hub.base_url, 'spawn']
                     if current_user.name != user.name:
                         # spawning on behalf of another user
diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py
--- a/jupyterhub/handlers/pages.py
+++ b/jupyterhub/handlers/pages.py
@@ -74,11 +74,7 @@ class SpawnHandler(BaseHandler):
 
     Only enabled when Spawner.options_form is defined.
     """
-    async def _render_form(self, message='', for_user=None):
-        # Note that 'user' is the authenticated user making the request and
-        # 'for_user' is the user whose server is being spawned.
-        user = for_user or self.get_current_user()
-        spawner_options_form = await user.spawner.get_options_form()
+    def _render_form(self, for_user, spawner_options_form, message=''):
         return self.render_template('spawn.html',
             for_user=for_user,
             spawner_options_form=spawner_options_form,
@@ -107,10 +103,12 @@ async def get(self, for_user=None):
             self.log.debug("User is running: %s", url)
             self.redirect(url)
             return
-        if user.spawner.options_form:
+
+        spawner_options_form = await user.spawner.get_options_form()
+        if spawner_options_form:
             # Add handler to spawner here so you can access query params in form rendering.
             user.spawner.handler = self
-            form = await self._render_form(for_user=user)
+            form = self._render_form(for_user=user, spawner_options_form=spawner_options_form)
             self.finish(form)
         else:
             # Explicit spawn request: clear _spawn_future
@@ -154,7 +152,8 @@ async def post(self, for_user=None):
             await self.spawn_single_user(user, options=options)
         except Exception as e:
             self.log.error("Failed to spawn single-user server with form", exc_info=True)
-            form = await self._render_form(message=str(e), for_user=user)
+            spawner_options_form = await user.spawner.get_options_form()
+            form = self._render_form(for_user=user, spawner_options_form=spawner_options_form, message=str(e))
             self.finish(form)
             return
         if current_user is user:


Test patch:
diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py
--- a/jupyterhub/tests/mocking.py
+++ b/jupyterhub/tests/mocking.py
@@ -167,6 +167,11 @@ def options_from_form(self, form_data):
             options['hello'] = form_data['hello_file'][0]
         return options
 
+class FalsyCallableFormSpawner(FormSpawner):
+    """A spawner that has a callable options form defined returning a falsy value"""
+
+    options_form = lambda a, b: ""
+
 
 class MockStructGroup:
     """Mock grp.struct_group"""
diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py
--- a/jupyterhub/tests/test_pages.py
+++ b/jupyterhub/tests/test_pages.py
@@ -16,7 +16,7 @@
 
 import pytest
 
-from .mocking import FormSpawner
+from .mocking import FormSpawner, FalsyCallableFormSpawner
 from .utils import (
     async_requests,
     api_request,
@@ -207,6 +207,13 @@ async def test_spawn_page(app):
         assert FormSpawner.options_form in r.text
 
 
+async def test_spawn_page_falsy_callable(app):
+    with mock.patch.dict(app.users.settings, {'spawner_class': FalsyCallableFormSpawner}):
+        cookies = await app.login_user('erik')
+        r = await get_page('spawn', app, cookies=cookies)
+        assert 'user/erik' in r.url
+
+
 async def test_spawn_page_admin(app, admin_access):
     with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
         cookies = await app.login_user('admin')