Source code for bokeh.server.contexts

#-----------------------------------------------------------------------------
# Copyright (c) Anaconda, Inc., and Bokeh Contributors.
# All rights reserved.
#
# The full license is in the file LICENSE.txt, distributed with this software.
#-----------------------------------------------------------------------------
''' Provides the Application, Server, and Session context classes.

'''

#-----------------------------------------------------------------------------
# Boilerplate
#-----------------------------------------------------------------------------
from __future__ import annotations

import logging # isort:skip
log = logging.getLogger(__name__)

#-----------------------------------------------------------------------------
# Imports
#-----------------------------------------------------------------------------

# Standard library imports
import asyncio
import weakref
from typing import (
    TYPE_CHECKING,
    Any,
    Awaitable,
    Callable,
    Iterable,
    cast,
)

# Bokeh imports
from ..application.application import ServerContext, SessionContext
from ..document import Document
from ..protocol.exceptions import ProtocolError
from ..util.asyncio import Loop
from ..util.token import get_token_payload
from .session import ServerSession

if TYPE_CHECKING:
    from ..application.application import Application
    from ..core.types import ID
    from ..util.token import TokenPayload
    from .executor import _ServerExecutor
    from .request import RequestLike

#-----------------------------------------------------------------------------
# Globals and constants
#-----------------------------------------------------------------------------

__all__ = (
    'ApplicationContext',
    'BokehServerContext',
    'BokehSessionContext',
)

#-----------------------------------------------------------------------------
# Setup
#-----------------------------------------------------------------------------

#-----------------------------------------------------------------------------
# General API
#-----------------------------------------------------------------------------

#-----------------------------------------------------------------------------
# Dev API
#-----------------------------------------------------------------------------

[docs] class BokehServerContext(ServerContext): def __init__(self, application_context: ApplicationContext) -> None: self._application_context = weakref.ref(application_context) @property def application_context(self) -> ApplicationContext | None: return self._application_context() @property def sessions(self) -> list[ServerSession]: result: list[ServerSession] = [] context = self.application_context if context: for session in context.sessions: result.append(session) return result
[docs] class BokehSessionContext(SessionContext): _session: ServerSession | None _request: _RequestProxy | None _token: str | None def __init__(self, session_id: ID, server_context: ServerContext, document: Document, logout_url: str | None = None) -> None: self._document = document self._session = None self._logout_url = logout_url super().__init__(server_context, session_id) # request arguments used to instantiate this session self._request = None self._token = None def _set_session(self, session: ServerSession) -> None: self._session = session
[docs] async def with_locked_document(self, func: Callable[[Document], Awaitable[None]]) -> None: if self._session is None: # this means we are in on_session_created, so no locking yet, # we have exclusive access await func(self._document) else: await self._session.with_document_locked(func, self._document)
@property def destroyed(self) -> bool: if self._session is None: # this means we are in on_session_created return False else: return self._session.destroyed @property def document(self) -> Document: return self._document @property def logout_url(self) -> str | None: return self._logout_url @property def request(self) -> _RequestProxy | None: return self._request @property def token_payload(self) -> TokenPayload: assert self._token is not None return get_token_payload(self._token) @property def session(self) -> ServerSession | None: return self._session
[docs] class ApplicationContext: ''' Server-side holder for ``bokeh.application.Application`` plus any associated data. This holds data that's global to all sessions, while ``ServerSession`` holds data specific to an "instance" of the application. ''' _sessions: dict[ID, ServerSession] _pending_sessions: dict[ID, asyncio.Task[ServerSession]] _session_contexts: dict[ID, SessionContext] _server_context: BokehServerContext def __init__(self, application: Application, io_loop: Loop | None = None, url: str | None = None, logout_url: str | None = None, executor: _ServerExecutor | None = None): self._application = application self._loop = io_loop self._executor = executor self._sessions = {} self._pending_sessions = {} self._session_contexts = {} self._server_context = BokehServerContext(self) self._url = url self._logout_url = logout_url def _can_initialize_document_in_worker(self) -> bool: from ..application.handlers.code import CodeHandler from ..application.handlers.directory import DirectoryHandler from ..application.handlers.document_lifecycle import DocumentLifecycleHandler handlers = [ handler for handler in self._application._handlers if not isinstance(handler, DocumentLifecycleHandler) ] return bool(handlers) and all(not isinstance(handler, (CodeHandler, DirectoryHandler)) for handler in handlers) @property def io_loop(self) -> Loop | None: return self._loop @property def application(self) -> Application: return self._application @property def url(self) -> str | None: return self._url @property def server_context(self) -> BokehServerContext: return self._server_context @property def sessions(self) -> Iterable[ServerSession]: return self._sessions.values() def run_load_hook(self) -> None: try: self._application.on_server_loaded(self.server_context) except Exception as e: log.error(f"Error in server loaded hook {e!r}", exc_info=True) def run_unload_hook(self) -> None: try: self._application.on_server_unloaded(self.server_context) except Exception as e: log.error(f"Error in server unloaded hook {e!r}", exc_info=True) async def create_session_if_needed(self, session_id: ID, request: RequestLike | None = None, token: str | None = None) -> ServerSession: # this is because empty session_ids would be "falsey" and # potentially open up a way for clients to confuse us if len(session_id) == 0: raise ProtocolError("Session ID must not be empty") if session_id in self._sessions: return self._sessions[session_id] pending = self._pending_sessions.get(session_id) if pending is None: pending = self._pending_sessions[session_id] = asyncio.create_task( self._create_session(session_id, request, token), ) pending.add_done_callback(lambda task: self._session_creation_done(session_id, task)) # Session initialization belongs to the application context, not to # whichever HTTP or websocket request happened to start it. A dropped # request must not cancel work that other waiters may still need. return await asyncio.shield(pending) def _session_creation_done(self, session_id: ID, task: asyncio.Task[ServerSession]) -> None: if self._pending_sessions.get(session_id) is task: del self._pending_sessions[session_id] if not task.cancelled() and (exception := task.exception()) is not None: log.error("Failed to create session %r: %s", session_id, exception, exc_info=exception) def _cancel_pending_sessions(self) -> tuple[asyncio.Task[ServerSession], ...]: pending = tuple(self._pending_sessions.values()) for task in pending: task.cancel() return pending async def _shutdown_pending_sessions(self) -> None: if pending := self._cancel_pending_sessions(): await asyncio.gather(*pending, return_exceptions=True) async def _initialize_document_async(self, doc: Document) -> None: if not self._can_initialize_document_in_worker(): # Code handlers temporarily patch process-global state such as # sys.path, sys.argv, cwd, and bokeh.io functions. Run them on the # event-loop thread so no unrelated request can observe that state. # This also preserves immediate creation for an empty Application. self._application.initialize_document(doc) return worker = asyncio.create_task(asyncio.to_thread(self._application.initialize_document, doc)) try: await asyncio.shield(worker) except asyncio.CancelledError: # Executor work cannot be stopped once running. Keep the session # creation task alive until it has finished so orderly shutdown can # run application unload hooks after initialization code exits. try: await worker except Exception as error: log.error("Failed to initialize cancelled session: %s", error, exc_info=error) raise async def _create_session(self, session_id: ID, request: RequestLike | None = None, token: str | None = None) -> ServerSession: doc = Document() session_context = BokehSessionContext(session_id, self.server_context, doc, logout_url=self._logout_url) if request is not None: payload = get_token_payload(token) if token else {} if ('cookies' in payload and 'headers' in payload and 'Cookie' not in payload['headers']): # Restore Cookie header from cookies dictionary payload['headers']['Cookie'] = '; '.join([ f'{k}={v}' for k, v in payload['cookies'].items() ]) # using private attr so users only have access to a read-only property session_context._request = _RequestProxy(request, arguments=payload.get('arguments'), cookies=payload.get('cookies'), headers=payload.get('headers')) session_context._token = token # expose the session context to the document # use the _attribute to set the public property .session_context doc._session_context = weakref.ref(session_context) try: await self._application.on_session_created(session_context) except Exception as e: log.error("Failed to run session creation hooks %r", e, exc_info=True) # Safe synchronous application code can run in a worker. Handlers that # patch process-global state remain on the event-loop thread. await self._initialize_document_async(doc) io_loop = self._loop or asyncio.get_running_loop() session = ServerSession(session_id, doc, io_loop=io_loop, token=token, executor=self._executor) self._sessions[session_id] = session session_context._set_session(session) self._session_contexts[session_id] = session_context return session def get_session(self, session_id: ID) -> ServerSession: if session_id in self._sessions: session = self._sessions[session_id] return session else: raise ProtocolError("No such session " + session_id) async def _discard_session(self, session: ServerSession, should_discard: Callable[[ServerSession], bool]) -> None: # Cleanup and orderly shutdown can independently retain the same # session in a snapshot. Treat a session that another path already # removed as successfully discarded rather than destroying it twice. if self._sessions.get(session.id) is not session: return if session.connection_count > 0: raise RuntimeError("Should not be discarding a session with open connections") log.debug("Discarding session %r last in use %r milliseconds ago", session.id, session.milliseconds_since_last_unsubscribe) session_context = self._session_contexts[session.id] discarded = False # session.destroy() wants the document lock so it can shut down the document # callbacks. async def do_discard() -> None: nonlocal discarded # while we awaited for the document lock, the discard-worthiness of the # session may have changed. # However, since we have the document lock, our own lock will cause the # block count to be 1. If there's any other block count besides our own, # we want to skip session destruction though. if self._sessions.get(session.id) is not session: return if should_discard(session) and session.expiration_blocked_count == 1: session.destroy() del self._sessions[session.id] del self._session_contexts[session.id] discarded = True log.debug("Session %r was successfully discarded", session.id) else: log.warning(f"Session {session.id!r} was scheduled to discard but came back to life") await cast(Awaitable[None], session.with_document_locked(do_discard)) # session lifecycle hooks are supposed to be called outside the document lock, # we only run these if we actually ended up destroying the session. if discarded: try: await self._application.on_session_destroyed(session_context) except Exception as e: log.error("Failed to run session destroy hooks %r", e, exc_info=True) return None async def _shutdown_sessions(self) -> None: for session in list(self._sessions.values()): if session.connection_count > 0: log.warning("Session %r still has open connections during shutdown", session.id) continue session._stop_callbacks() await self._discard_session(session, lambda session: session.connection_count == 0) async def _cleanup_sessions(self, unused_session_linger_milliseconds: int) -> None: def should_discard_ignoring_block(session: ServerSession) -> bool: return session.connection_count == 0 and \ (session.milliseconds_since_last_unsubscribe > unused_session_linger_milliseconds or \ session.expiration_requested) # build a temp list to avoid trouble from self._sessions changes to_discard: list[ServerSession] = [] for session in self._sessions.values(): if should_discard_ignoring_block(session) and not session.expiration_blocked: to_discard.append(session) if len(to_discard) > 0: log.debug(f"Scheduling {len(to_discard)} sessions to discard") # asynchronously reconsider each session for session in to_discard: if should_discard_ignoring_block(session) and not session.expiration_blocked: await self._discard_session(session, should_discard_ignoring_block) return None
#----------------------------------------------------------------------------- # Private API #----------------------------------------------------------------------------- class _RequestProxy: _arguments: dict[str, list[bytes]] _cookies: dict[str, str] _headers: dict[str, str | list[str]] def __init__( self, request: RequestLike, arguments: dict[str, bytes | list[bytes]] | None = None, cookies: dict[str, str] | None = None, headers: dict[str, str | list[str]] | None = None, ) -> None: self._request = request if arguments is not None: self._arguments = {key: value if isinstance(value, list) else [value] for key, value in arguments.items()} elif hasattr(request, 'arguments'): self._arguments = dict(request.arguments) else: self._arguments = {} if 'bokeh-session-id' in self._arguments: del self._arguments['bokeh-session-id'] if cookies is not None: self._cookies = cookies elif hasattr(request, 'cookies'): # Django cookies are plain strings, tornado cookies are objects with a value request_cookies = cast(dict[str, Any], request.cookies) self._cookies = {k: v if isinstance(v, str) else v.value for k, v in request_cookies.items()} else: self._cookies = {} if headers is not None: self._headers = headers elif hasattr(request, 'headers'): self._headers = dict(request.headers) else: self._headers = {} @property def arguments(self) -> dict[str, list[bytes]]: return self._arguments @property def cookies(self) -> dict[str, str]: return self._cookies @property def headers(self) -> dict[str, str | list[str]]: return self._headers def __getattr__(self, name: str) -> Any: if not name.startswith("_"): val = getattr(self._request, name, None) if val is not None: return val raise AttributeError(name) #----------------------------------------------------------------------------- # Code #-----------------------------------------------------------------------------