Parcourir la source

Merge pull request #9256 from afshin/hubfix

Add JupyterHub to page config
Steven Silvester il y a 4 ans
Parent
commit
9284892500
1 fichiers modifiés avec 432 ajouts et 415 suppressions
  1. 432 415
      jupyterlab/labhubapp.py

+ 432 - 415
jupyterlab/labhubapp.py

@@ -14,15 +14,23 @@ from traitlets import (
     observe,
     validate,
     TraitError,
+    Type
 )
 
-from jupyterhub.singleuser import (
-    dedent, exponential_backoff, make_ssl_context, url_path_join, _check_version, _exclude_home, 
-    ChoiceLoader, HubOAuth, JupyterHubLoginHandler, JupyterHubLogoutHandler
-)
+try:
+    # Available in JupyterHub 1.2+
+    from jupyterhub.singleuser.mixins import make_singleuser_app
+
+except ImportError:
+    make_singleuser_app = None
+    from jupyterhub.singleuser import (
+        dedent, exponential_backoff, make_ssl_context, url_path_join, _check_version, _exclude_home,
+        ChoiceLoader, HubOAuth, JupyterHubLoginHandler, JupyterHubLogoutHandler
+    )
 
 from tornado.httpclient import AsyncHTTPClient
 from tornado.httpclient import HTTPRequest
+from tornado import web
 
 from .labapp import LabApp, aliases as lab_aliases, flags as lab_flags
 from ._version import __version__
@@ -51,446 +59,455 @@ flags.update(
     }
 )
 
+if make_singleuser_app:
 
-# FIXME: Use from jupyterhub.mixins import make_singleuser_app when available
-class SingleUserNotebookApp(LabApp):
-    """A Subclass of the regular LabApp that is aware of the parent multiuser context."""
+    class SingleUserNotebookMixin(LabApp):
 
-    description = dedent(
-        """
-    Single-user server for JupyterHub. Extends the Jupyter Notebook server.
+        login_handler_class = Type(klass=web.RequestHandler).tag(config=True)
+        logout_handler_class = Type(klass=web.RequestHandler).tag(config=True)
 
-    Meant to be invoked by JupyterHub Spawners, and not directly.
-    """
-    )
+    SingleUserNotebookApp = make_singleuser_app(SingleUserNotebookMixin)
 
-    examples = ""
-    subcommands = {}
-    version = __version__
-    classes = LabApp.classes + [HubOAuth]
-
-    # disable single-user app's localhost checking
-    allow_remote_access = True
-
-    # don't store cookie secrets
-    cookie_secret_file = ''
-    # always generate a new cookie secret on launch
-    # ensures that each spawn clears any cookies from previous session,
-    # triggering OAuth again
-    cookie_secret = Bytes()
-
-    def _cookie_secret_default(self):
-        return os.urandom(32)
-
-    user = CUnicode().tag(config=True)
-    group = CUnicode().tag(config=True)
-
-    @default('user')
-    def _default_user(self):
-        return os.environ.get('JUPYTERHUB_USER') or ''
-
-    @default('group')
-    def _default_group(self):
-        return os.environ.get('JUPYTERHUB_GROUP') or ''
-
-    @observe('user')
-    def _user_changed(self, change):
-        self.log.name = change.new
-
-    hub_host = Unicode().tag(config=True)
-
-    hub_prefix = Unicode('/hub/').tag(config=True)
-
-    @default('keyfile')
-    def _keyfile_default(self):
-        return os.environ.get('JUPYTERHUB_SSL_KEYFILE') or ''
-
-    @default('certfile')
-    def _certfile_default(self):
-        return os.environ.get('JUPYTERHUB_SSL_CERTFILE') or ''
-
-    @default('client_ca')
-    def _client_ca_default(self):
-        return os.environ.get('JUPYTERHUB_SSL_CLIENT_CA') or ''
-
-    @default('hub_prefix')
-    def _hub_prefix_default(self):
-        base_url = os.environ.get('JUPYTERHUB_BASE_URL') or '/'
-        return base_url + 'hub/'
-
-    hub_api_url = Unicode().tag(config=True)
-
-    @default('hub_api_url')
-    def _hub_api_url_default(self):
-        return os.environ.get('JUPYTERHUB_API_URL') or 'http://127.0.0.1:8081/hub/api'
-
-    # defaults for some configurables that may come from service env variables:
-    @default('base_url')
-    def _base_url_default(self):
-        return os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
-
-    # Note: this may be removed if notebook module is >= 5.0.0b1
-    @validate('base_url')
-    def _validate_base_url(self, proposal):
-        """ensure base_url starts and ends with /"""
-        value = proposal.value
-        if not value.startswith('/'):
-            value = '/' + value
-        if not value.endswith('/'):
-            value = value + '/'
-        return value
-
-    @default('port')
-    def _port_default(self):
-        if os.environ.get('JUPYTERHUB_SERVICE_URL'):
-            url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
-            if url.port:
-                return url.port
-            elif url.scheme == 'http':
-                return 80
-            elif url.scheme == 'https':
-                return 443
-        return 8888
-
-    @default('ip')
-    def _ip_default(self):
-        if os.environ.get('JUPYTERHUB_SERVICE_URL'):
-            url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
-            if url.hostname:
-                return url.hostname
-        return '127.0.0.1'
-
-    aliases = aliases
-    flags = flags
-
-    # disble some single-user configurables
-    token = ''
-    open_browser = False
-    quit_button = False
-    trust_xheaders = True
-    login_handler_class = JupyterHubLoginHandler
-    logout_handler_class = JupyterHubLogoutHandler
-    port_retries = (
-        0  # disable port-retries, since the Spawner will tell us what port to use
-    )
+else:
 
-    disable_user_config = Bool(
-        False,
-        help="""Disable user configuration of single-user server.
-
-        Prevents user-writable files that normally configure the single-user server
-        from being loaded, ensuring admins have full control of configuration.
-        """,
-    ).tag(config=True)
-
-    @validate('notebook_dir')
-    def _notebook_dir_validate(self, proposal):
-        value = os.path.expanduser(proposal['value'])
-        # Strip any trailing slashes
-        # *except* if it's root
-        _, path = os.path.splitdrive(value)
-        if path == os.sep:
-            return value
-        value = value.rstrip(os.sep)
-        if not os.path.isabs(value):
-            # If we receive a non-absolute path, make it absolute.
-            value = os.path.abspath(value)
-        if not os.path.isdir(value):
-            raise TraitError("No such notebook dir: %r" % value)
-        return value
-
-    @default('log_datefmt')
-    def _log_datefmt_default(self):
-        """Exclude date from default date format"""
-        return "%Y-%m-%d %H:%M:%S"
-
-    @default('log_format')
-    def _log_format_default(self):
-        """override default log format to include time"""
-        return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
-
-    def _confirm_exit(self):
-        # disable the exit confirmation for background notebook processes
-        self.io_loop.add_callback_from_signal(self.io_loop.stop)
-
-    def migrate_config(self):
-        if self.disable_user_config:
-            # disable config-migration when user config is disabled
-            return
-        else:
-            super(SingleUserNotebookApp, self).migrate_config()
-
-    @property
-    def config_file_paths(self):
-        path = super(SingleUserNotebookApp, self).config_file_paths
-
-        if self.disable_user_config:
-            # filter out user-writable config dirs if user config is disabled
-            path = list(_exclude_home(path))
-        return path
-
-    @property
-    def nbextensions_path(self):
-        path = super(SingleUserNotebookApp, self).nbextensions_path
-
-        if self.disable_user_config:
-            path = list(_exclude_home(path))
-        return path
-
-    @validate('static_custom_path')
-    def _validate_static_custom_path(self, proposal):
-        path = proposal['value']
-        if self.disable_user_config:
-            path = list(_exclude_home(path))
-        return path
-
-    # create dynamic default http client,
-    # configured with any relevant ssl config
-    hub_http_client = Any()
-
-    @default('hub_http_client')
-    def _default_client(self):
-        ssl_context = make_ssl_context(
-            self.keyfile, self.certfile, cafile=self.client_ca
-        )
-        AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context})
-        return AsyncHTTPClient()
+    class SingleUserNotebookApp(LabApp):
+        """A Subclass of the regular LabApp that is aware of the parent multiuser context."""
 
-    async def check_hub_version(self):
-        """Test a connection to my Hub
+        description = dedent(
+            """
+        Single-user server for JupyterHub. Extends the Jupyter Notebook server.
 
-        - exit if I can't connect at all
-        - check version and warn on sufficient mismatch
+        Meant to be invoked by JupyterHub Spawners, and not directly.
         """
-        client = self.hub_http_client
-        RETRIES = 5
-        for i in range(1, RETRIES + 1):
-            try:
-                resp = await client.fetch(self.hub_api_url)
-            except Exception:
-                self.log.exception(
-                    "Failed to connect to my Hub at %s (attempt %i/%i). Is it running?",
-                    self.hub_api_url,
-                    i,
-                    RETRIES,
-                )
-                await gen.sleep(min(2 ** i, 16))
-            else:
-                break
-        else:
-            self.exit(1)
+        )
 
-        hub_version = resp.headers.get('X-JupyterHub-Version')
-        _check_version(hub_version, __version__, self.log)
+        examples = ""
+        subcommands = {}
+        version = __version__
+        classes = LabApp.classes + [HubOAuth]
 
-    server_name = Unicode()
+        # disable single-user app's localhost checking
+        allow_remote_access = True
 
-    @default('server_name')
-    def _server_name_default(self):
-        return os.environ.get('JUPYTERHUB_SERVER_NAME', '')
+        # don't store cookie secrets
+        cookie_secret_file = ''
+        # always generate a new cookie secret on launch
+        # ensures that each spawn clears any cookies from previous session,
+        # triggering OAuth again
+        cookie_secret = Bytes()
 
-    hub_activity_url = Unicode(
-        config=True, help="URL for sending JupyterHub activity updates"
-    )
+        def _cookie_secret_default(self):
+            return os.urandom(32)
 
-    @default('hub_activity_url')
-    def _default_activity_url(self):
-        return os.environ.get('JUPYTERHUB_ACTIVITY_URL', '')
-
-    hub_activity_interval = Integer(
-        300,
-        config=True,
-        help="""
-        Interval (in seconds) on which to update the Hub
-        with our latest activity.
-        """,
-    )
+        user = CUnicode().tag(config=True)
+        group = CUnicode().tag(config=True)
+
+        @default('user')
+        def _default_user(self):
+            return os.environ.get('JUPYTERHUB_USER') or ''
+
+        @default('group')
+        def _default_group(self):
+            return os.environ.get('JUPYTERHUB_GROUP') or ''
+
+        @observe('user')
+        def _user_changed(self, change):
+            self.log.name = change.new
+
+        hub_host = Unicode().tag(config=True)
+
+        hub_prefix = Unicode('/hub/').tag(config=True)
+
+        @default('keyfile')
+        def _keyfile_default(self):
+            return os.environ.get('JUPYTERHUB_SSL_KEYFILE') or ''
+
+        @default('certfile')
+        def _certfile_default(self):
+            return os.environ.get('JUPYTERHUB_SSL_CERTFILE') or ''
+
+        @default('client_ca')
+        def _client_ca_default(self):
+            return os.environ.get('JUPYTERHUB_SSL_CLIENT_CA') or ''
+
+        @default('hub_prefix')
+        def _hub_prefix_default(self):
+            base_url = os.environ.get('JUPYTERHUB_BASE_URL') or '/'
+            return base_url + 'hub/'
+
+        hub_api_url = Unicode().tag(config=True)
+
+        @default('hub_api_url')
+        def _hub_api_url_default(self):
+            return os.environ.get('JUPYTERHUB_API_URL') or 'http://127.0.0.1:8081/hub/api'
+
+        # defaults for some configurables that may come from service env variables:
+        @default('base_url')
+        def _base_url_default(self):
+            return os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
+
+        # Note: this may be removed if notebook module is >= 5.0.0b1
+        @validate('base_url')
+        def _validate_base_url(self, proposal):
+            """ensure base_url starts and ends with /"""
+            value = proposal.value
+            if not value.startswith('/'):
+                value = '/' + value
+            if not value.endswith('/'):
+                value = value + '/'
+            return value
+
+        @default('port')
+        def _port_default(self):
+            if os.environ.get('JUPYTERHUB_SERVICE_URL'):
+                url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
+                if url.port:
+                    return url.port
+                elif url.scheme == 'http':
+                    return 80
+                elif url.scheme == 'https':
+                    return 443
+            return 8888
+
+        @default('ip')
+        def _ip_default(self):
+            if os.environ.get('JUPYTERHUB_SERVICE_URL'):
+                url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
+                if url.hostname:
+                    return url.hostname
+            return '127.0.0.1'
+
+        aliases = aliases
+        flags = flags
+
+        # disble some single-user configurables
+        token = ''
+        open_browser = False
+        quit_button = False
+        trust_xheaders = True
+        login_handler_class = JupyterHubLoginHandler
+        logout_handler_class = JupyterHubLogoutHandler
+        port_retries = (
+            0  # disable port-retries, since the Spawner will tell us what port to use
+        )
 
-    @default('hub_activity_interval')
-    def _default_activity_interval(self):
-        env_value = os.environ.get('JUPYTERHUB_ACTIVITY_INTERVAL')
-        if env_value:
-            return int(env_value)
-        else:
-            return 300
-
-    _last_activity_sent = Any(allow_none=True)
-
-    async def notify_activity(self):
-        """Notify jupyterhub of activity"""
-        client = self.hub_http_client
-        last_activity = self.web_app.last_activity()
-        if not last_activity:
-            self.log.debug("No activity to send to the Hub")
-            return
-        if last_activity:
-            # protect against mixed timezone comparisons
-            if not last_activity.tzinfo:
-                # assume naive timestamps are utc
-                self.log.warning("last activity is using naive timestamps")
-                last_activity = last_activity.replace(tzinfo=timezone.utc)
-
-        if self._last_activity_sent and last_activity < self._last_activity_sent:
-            self.log.debug("No activity since %s", self._last_activity_sent)
-            return
-
-        last_activity_timestamp = isoformat(last_activity)
-
-        async def notify():
-            self.log.debug("Notifying Hub of activity %s", last_activity_timestamp)
-            req = HTTPRequest(
-                url=self.hub_activity_url,
-                method='POST',
-                headers={
-                    "Authorization": "token {}".format(self.hub_auth.api_token),
-                    "Content-Type": "application/json",
-                },
-                body=json.dumps(
-                    {
-                        'servers': {
-                            self.server_name: {'last_activity': last_activity_timestamp}
-                        },
-                        'last_activity': last_activity_timestamp,
-                    }
-                ),
+        disable_user_config = Bool(
+            False,
+            help="""Disable user configuration of single-user server.
+
+            Prevents user-writable files that normally configure the single-user server
+            from being loaded, ensuring admins have full control of configuration.
+            """,
+        ).tag(config=True)
+
+        @validate('notebook_dir')
+        def _notebook_dir_validate(self, proposal):
+            value = os.path.expanduser(proposal['value'])
+            # Strip any trailing slashes
+            # *except* if it's root
+            _, path = os.path.splitdrive(value)
+            if path == os.sep:
+                return value
+            value = value.rstrip(os.sep)
+            if not os.path.isabs(value):
+                # If we receive a non-absolute path, make it absolute.
+                value = os.path.abspath(value)
+            if not os.path.isdir(value):
+                raise TraitError("No such notebook dir: %r" % value)
+            return value
+
+        @default('log_datefmt')
+        def _log_datefmt_default(self):
+            """Exclude date from default date format"""
+            return "%Y-%m-%d %H:%M:%S"
+
+        @default('log_format')
+        def _log_format_default(self):
+            """override default log format to include time"""
+            return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
+
+        def _confirm_exit(self):
+            # disable the exit confirmation for background notebook processes
+            self.io_loop.add_callback_from_signal(self.io_loop.stop)
+
+        def migrate_config(self):
+            if self.disable_user_config:
+                # disable config-migration when user config is disabled
+                return
+            else:
+                super(SingleUserNotebookApp, self).migrate_config()
+
+        @property
+        def config_file_paths(self):
+            path = super(SingleUserNotebookApp, self).config_file_paths
+
+            if self.disable_user_config:
+                # filter out user-writable config dirs if user config is disabled
+                path = list(_exclude_home(path))
+            return path
+
+        @property
+        def nbextensions_path(self):
+            path = super(SingleUserNotebookApp, self).nbextensions_path
+
+            if self.disable_user_config:
+                path = list(_exclude_home(path))
+            return path
+
+        @validate('static_custom_path')
+        def _validate_static_custom_path(self, proposal):
+            path = proposal['value']
+            if self.disable_user_config:
+                path = list(_exclude_home(path))
+            return path
+
+        # create dynamic default http client,
+        # configured with any relevant ssl config
+        hub_http_client = Any()
+
+        @default('hub_http_client')
+        def _default_client(self):
+            ssl_context = make_ssl_context(
+                self.keyfile, self.certfile, cafile=self.client_ca
             )
-            try:
-                await client.fetch(req)
-            except Exception:
-                self.log.exception("Error notifying Hub of activity")
-                return False
+            AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context})
+            return AsyncHTTPClient()
+
+        async def check_hub_version(self):
+            """Test a connection to my Hub
+
+            - exit if I can't connect at all
+            - check version and warn on sufficient mismatch
+            """
+            client = self.hub_http_client
+            RETRIES = 5
+            for i in range(1, RETRIES + 1):
+                try:
+                    resp = await client.fetch(self.hub_api_url)
+                except Exception:
+                    self.log.exception(
+                        "Failed to connect to my Hub at %s (attempt %i/%i). Is it running?",
+                        self.hub_api_url,
+                        i,
+                        RETRIES,
+                    )
+                    await gen.sleep(min(2 ** i, 16))
+                else:
+                    break
             else:
-                return True
-
-        await exponential_backoff(
-            notify,
-            fail_message="Failed to notify Hub of activity",
-            start_wait=1,
-            max_wait=15,
-            timeout=60,
+                self.exit(1)
+
+            hub_version = resp.headers.get('X-JupyterHub-Version')
+            _check_version(hub_version, __version__, self.log)
+
+        server_name = Unicode()
+
+        @default('server_name')
+        def _server_name_default(self):
+            return os.environ.get('JUPYTERHUB_SERVER_NAME', '')
+
+        hub_activity_url = Unicode(
+            config=True, help="URL for sending JupyterHub activity updates"
         )
-        self._last_activity_sent = last_activity
-
-    async def keep_activity_updated(self):
-        if not self.hub_activity_url or not self.hub_activity_interval:
-            self.log.warning("Activity events disabled")
-            return
-        self.log.info(
-            "Updating Hub with activity every %s seconds", self.hub_activity_interval
+
+        @default('hub_activity_url')
+        def _default_activity_url(self):
+            return os.environ.get('JUPYTERHUB_ACTIVITY_URL', '')
+
+        hub_activity_interval = Integer(
+            300,
+            config=True,
+            help="""
+            Interval (in seconds) on which to update the Hub
+            with our latest activity.
+            """,
         )
-        while True:
-            try:
-                await self.notify_activity()
-            except Exception as e:
-                self.log.exception("Error notifying Hub of activity")
-            # add 20% jitter to the interval to avoid alignment
-            # of lots of requests from user servers
-            t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
-            await asyncio.sleep(t)
-
-    def initialize(self, argv=None):
-        # disable trash by default
-        # this can be re-enabled by config
-        self.config.FileContentsManager.delete_to_trash = False
-        return super().initialize(argv)
-
-    def start(self):
-        self.log.info("Starting jupyterhub-singleuser server version %s", __version__)
-        # start by hitting Hub to check version
-        ioloop.IOLoop.current().run_sync(self.check_hub_version)
-        ioloop.IOLoop.current().add_callback(self.keep_activity_updated)
-        super(SingleUserNotebookApp, self).start()
-
-    def init_hub_auth(self):
-        api_token = None
-        if os.getenv('JPY_API_TOKEN'):
-            # Deprecated env variable (as of 0.7.2)
-            api_token = os.environ['JPY_API_TOKEN']
-        if os.getenv('JUPYTERHUB_API_TOKEN'):
-            api_token = os.environ['JUPYTERHUB_API_TOKEN']
-
-        if not api_token:
-            self.exit(
-                "JUPYTERHUB_API_TOKEN env is required to run jupyterhub-singleuser. Did you launch it manually?"
+
+        @default('hub_activity_interval')
+        def _default_activity_interval(self):
+            env_value = os.environ.get('JUPYTERHUB_ACTIVITY_INTERVAL')
+            if env_value:
+                return int(env_value)
+            else:
+                return 300
+
+        _last_activity_sent = Any(allow_none=True)
+
+        async def notify_activity(self):
+            """Notify jupyterhub of activity"""
+            client = self.hub_http_client
+            last_activity = self.web_app.last_activity()
+            if not last_activity:
+                self.log.debug("No activity to send to the Hub")
+                return
+            if last_activity:
+                # protect against mixed timezone comparisons
+                if not last_activity.tzinfo:
+                    # assume naive timestamps are utc
+                    self.log.warning("last activity is using naive timestamps")
+                    last_activity = last_activity.replace(tzinfo=timezone.utc)
+
+            if self._last_activity_sent and last_activity < self._last_activity_sent:
+                self.log.debug("No activity since %s", self._last_activity_sent)
+                return
+
+            last_activity_timestamp = isoformat(last_activity)
+
+            async def notify():
+                self.log.debug("Notifying Hub of activity %s", last_activity_timestamp)
+                req = HTTPRequest(
+                    url=self.hub_activity_url,
+                    method='POST',
+                    headers={
+                        "Authorization": "token {}".format(self.hub_auth.api_token),
+                        "Content-Type": "application/json",
+                    },
+                    body=json.dumps(
+                        {
+                            'servers': {
+                                self.server_name: {'last_activity': last_activity_timestamp}
+                            },
+                            'last_activity': last_activity_timestamp,
+                        }
+                    ),
+                )
+                try:
+                    await client.fetch(req)
+                except Exception:
+                    self.log.exception("Error notifying Hub of activity")
+                    return False
+                else:
+                    return True
+
+            await exponential_backoff(
+                notify,
+                fail_message="Failed to notify Hub of activity",
+                start_wait=1,
+                max_wait=15,
+                timeout=60,
             )
-        self.hub_auth = HubOAuth(
-            parent=self,
-            api_token=api_token,
-            api_url=self.hub_api_url,
-            hub_prefix=self.hub_prefix,
-            base_url=self.base_url,
-            keyfile=self.keyfile,
-            certfile=self.certfile,
-            client_ca=self.client_ca,
-        )
-        # smoke check
-        if not self.hub_auth.oauth_client_id:
-            raise ValueError("Missing OAuth client ID")
-
-    def init_webapp(self):
-        # load the hub-related settings into the tornado settings dict
-        self.init_hub_auth()
-        s = self.tornado_settings
-        s['log_function'] = log_request
-        s['user'] = self.user
-        s['group'] = self.group
-        s['hub_prefix'] = self.hub_prefix
-        s['hub_host'] = self.hub_host
-        s['hub_auth'] = self.hub_auth
-        csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join(
-            self.hub_prefix, 'security/csp-report'
-        )
-        headers = s.setdefault('headers', {})
-        headers['X-JupyterHub-Version'] = __version__
-        # set CSP header directly to workaround bugs in jupyter/notebook 5.0
-        headers.setdefault(
-            'Content-Security-Policy',
-            ';'.join(["frame-ancestors 'self'", "report-uri " + csp_report_uri]),
-        )
-        super(SingleUserNotebookApp, self).init_webapp()
+            self._last_activity_sent = last_activity
+
+        async def keep_activity_updated(self):
+            if not self.hub_activity_url or not self.hub_activity_interval:
+                self.log.warning("Activity events disabled")
+                return
+            self.log.info(
+                "Updating Hub with activity every %s seconds", self.hub_activity_interval
+            )
+            while True:
+                try:
+                    await self.notify_activity()
+                except Exception as e:
+                    self.log.exception("Error notifying Hub of activity")
+                # add 20% jitter to the interval to avoid alignment
+                # of lots of requests from user servers
+                t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
+                await asyncio.sleep(t)
+
+        def initialize(self, argv=None):
+            # disable trash by default
+            # this can be re-enabled by config
+            self.config.FileContentsManager.delete_to_trash = False
+            return super().initialize(argv)
+
+        def start(self):
+            self.log.info("Starting jupyterhub-singleuser server version %s", __version__)
+            # start by hitting Hub to check version
+            ioloop.IOLoop.current().run_sync(self.check_hub_version)
+            ioloop.IOLoop.current().add_callback(self.keep_activity_updated)
+            super(SingleUserNotebookApp, self).start()
+
+        def init_hub_auth(self):
+            api_token = None
+            if os.getenv('JPY_API_TOKEN'):
+                # Deprecated env variable (as of 0.7.2)
+                api_token = os.environ['JPY_API_TOKEN']
+            if os.getenv('JUPYTERHUB_API_TOKEN'):
+                api_token = os.environ['JUPYTERHUB_API_TOKEN']
+
+            if not api_token:
+                self.exit(
+                    "JUPYTERHUB_API_TOKEN env is required to run jupyterhub-singleuser. Did you launch it manually?"
+                )
+            self.hub_auth = HubOAuth(
+                parent=self,
+                api_token=api_token,
+                api_url=self.hub_api_url,
+                hub_prefix=self.hub_prefix,
+                base_url=self.base_url,
+                keyfile=self.keyfile,
+                certfile=self.certfile,
+                client_ca=self.client_ca,
+            )
+            # smoke check
+            if not self.hub_auth.oauth_client_id:
+                raise ValueError("Missing OAuth client ID")
+
+        def init_webapp(self):
+            # load the hub-related settings into the tornado settings dict
+            self.init_hub_auth()
+            s = self.tornado_settings
+            s['log_function'] = log_request
+            s['user'] = self.user
+            s['group'] = self.group
+            s['hub_prefix'] = self.hub_prefix
+            s['hub_host'] = self.hub_host
+            s['hub_auth'] = self.hub_auth
+            csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join(
+                self.hub_prefix, 'security/csp-report'
+            )
+            headers = s.setdefault('headers', {})
+            headers['X-JupyterHub-Version'] = __version__
+            # set CSP header directly to workaround bugs in jupyter/notebook 5.0
+            headers.setdefault(
+                'Content-Security-Policy',
+                ';'.join(["frame-ancestors 'self'", "report-uri " + csp_report_uri]),
+            )
+            super(SingleUserNotebookApp, self).init_webapp()
 
-        # add OAuth callback
-        self.web_app.add_handlers(
-            r".*$",
-            [(urlparse(self.hub_auth.oauth_redirect_uri).path, OAuthCallbackHandler)],
-        )
+            # add OAuth callback
+            self.web_app.add_handlers(
+                r".*$",
+                [(urlparse(self.hub_auth.oauth_redirect_uri).path, OAuthCallbackHandler)],
+            )
 
-        # apply X-JupyterHub-Version to *all* request handlers (even redirects)
-        self.patch_default_headers()
-        self.patch_templates()
+            # apply X-JupyterHub-Version to *all* request handlers (even redirects)
+            self.patch_default_headers()
+            self.patch_templates()
 
-    def patch_default_headers(self):
-        if hasattr(RequestHandler, '_orig_set_default_headers'):
-            return
-        RequestHandler._orig_set_default_headers = RequestHandler.set_default_headers
+        def patch_default_headers(self):
+            if hasattr(RequestHandler, '_orig_set_default_headers'):
+                return
+            RequestHandler._orig_set_default_headers = RequestHandler.set_default_headers
 
-        def set_jupyterhub_header(self):
-            self._orig_set_default_headers()
-            self.set_header('X-JupyterHub-Version', __version__)
+            def set_jupyterhub_header(self):
+                self._orig_set_default_headers()
+                self.set_header('X-JupyterHub-Version', __version__)
 
-        RequestHandler.set_default_headers = set_jupyterhub_header
+            RequestHandler.set_default_headers = set_jupyterhub_header
 
-    def patch_templates(self):
-        """Patch page templates to add Hub-related buttons"""
+        def patch_templates(self):
+            """Patch page templates to add Hub-related buttons"""
 
-        self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(
-            self.hub_prefix, 'logo'
-        )
-        self.jinja_template_vars['hub_host'] = self.hub_host
-        self.jinja_template_vars['hub_prefix'] = self.hub_prefix
-        env = self.web_app.settings['jinja2_env']
+            self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(
+                self.hub_prefix, 'logo'
+            )
+            self.jinja_template_vars['hub_host'] = self.hub_host
+            self.jinja_template_vars['hub_prefix'] = self.hub_prefix
+            env = self.web_app.settings['jinja2_env']
 
-        env.globals['hub_control_panel_url'] = self.hub_host + url_path_join(
-            self.hub_prefix, 'home'
-        )
+            env.globals['hub_control_panel_url'] = self.hub_host + url_path_join(
+                self.hub_prefix, 'home'
+            )
 
-        # patch jinja env loading to modify page template
-        def get_page(name):
-            if name == 'page.html':
-                return page_template
+            # patch jinja env loading to modify page template
+            def get_page(name):
+                if name == 'page.html':
+                    return page_template
 
-        orig_loader = env.loader
-        env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
+            orig_loader = env.loader
+            env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
 
 
 def main(argv=None):