#-----------------------------------------------------------------------------
# Copyright (c) Anaconda, Inc., and Bokeh Contributors.
# All rights reserved.
#
# The full license is in the file LICENSE.txt, distributed with this software.
#-----------------------------------------------------------------------------
''' Provide a request handler that returns a page displaying a document.
'''
#-----------------------------------------------------------------------------
# Boilerplate
#-----------------------------------------------------------------------------
from __future__ import annotations
import logging # isort:skip
log = logging.getLogger(__name__)
#-----------------------------------------------------------------------------
# Imports
#-----------------------------------------------------------------------------
# Standard library imports
import asyncio
import os
import stat
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from typing import Any, TypeVar
# External imports
from tornado import httputil, iostream, version_info as tornado_version_info
from tornado.ioloop import IOLoop
from tornado.web import HTTPError, StaticFileHandler as TornadoStaticFileHandler
# Bokeh imports
from bokeh.settings import settings
#-----------------------------------------------------------------------------
# Globals and constants
#-----------------------------------------------------------------------------
__all__ = (
'AsyncStaticFileHandler',
'StaticHandler',
)
T = TypeVar("T")
#-----------------------------------------------------------------------------
# General API
#-----------------------------------------------------------------------------
#-----------------------------------------------------------------------------
# Dev API
#-----------------------------------------------------------------------------
[docs]
class AsyncStaticFileHandler(TornadoStaticFileHandler):
''' Serve static files without performing filesystem I/O on the event loop. '''
async def get(self, path: str, include_body: bool = True) -> None:
self.path = self.parse_url_path(path)
absolute_path = self.get_absolute_path(self.root, self.path)
self.absolute_path = await self._validate_absolute_path(self.root, absolute_path)
if self.absolute_path is None:
return
self.modified = await self._run_in_executor(self.get_modified_time)
self._computed_etag = await self._run_in_executor(self.compute_etag)
self.set_headers()
if self.should_return_304():
self.set_status(304)
return
request_range = None
range_header = self.request.headers.get("Range")
if range_header:
request_range = httputil._parse_request_range(range_header)
size = await self._run_in_executor(self.get_content_size)
if request_range:
start, end = request_range
if start is not None and start < 0:
start += size
if start < 0:
start = 0
if (
start is not None
and (start >= size or (end is not None and start >= end))
) or end == 0:
self.set_status(416)
self.set_header("Content-Type", "text/plain")
self.set_header("Content-Range", f"bytes */{size}")
return
if end is not None and end > size:
end = size
if size != (end or size) - (start or 0):
self.set_status(206)
self.set_header(
"Content-Range", httputil._get_content_range(start, end, size),
)
else:
start = end = None
if start is not None and end is not None:
content_length = end - start
elif end is not None:
content_length = end
elif start is not None:
content_length = size - start
else:
content_length = size
self.set_header("Content-Length", content_length)
if include_body:
await self._stream_content(start, end)
else:
assert self.request.method == "HEAD"
[docs]
def compute_etag(self) -> str | None:
if hasattr(self, "_computed_etag"):
return self._computed_etag
return super().compute_etag()
[docs]
def get_modified_time(self) -> datetime:
stat_result = self._stat()
modified = datetime.fromtimestamp(int(stat_result.st_mtime), UTC)
if tornado_version_info < (6, 4):
return modified.replace(tzinfo=None)
return modified
async def _run_in_executor(self, func: Callable[..., T], *args: Any) -> T:
future = IOLoop.current().run_in_executor(None, func, *args)
try:
return await asyncio.shield(future)
except asyncio.CancelledError:
try:
await future
except Exception:
pass
raise
async def _validate_absolute_path(self, root: str, absolute_path: str) -> str | None:
root = os.path.abspath(root)
if not root.endswith(os.path.sep):
root += os.path.sep
if not (absolute_path + os.path.sep).startswith(root):
raise HTTPError(403, "%s is not in root static directory", self.path)
stat_result = await self._stat_path(absolute_path)
if stat.S_ISDIR(stat_result.st_mode) and self.default_filename is not None:
if not self.request.path.endswith("/"):
if self.request.path.startswith("//"):
raise HTTPError(
403, "cannot redirect path with two initial slashes",
)
self.redirect(self.request.path + "/", permanent=True)
return None
absolute_path = os.path.join(absolute_path, self.default_filename)
stat_result = await self._stat_path(absolute_path)
if not stat.S_ISREG(stat_result.st_mode):
raise HTTPError(403, "%s is not a file", self.path)
self._stat_result = stat_result
return absolute_path
async def _stat_path(self, absolute_path: str) -> os.stat_result:
try:
return await self._run_in_executor(self._get_stat_result, absolute_path)
except OSError as error:
raise HTTPError(404) from error
@classmethod
def _get_stat_result(cls, absolute_path: str) -> os.stat_result:
return os.stat(absolute_path)
async def _stream_content(self, start: int | None, end: int | None) -> None:
raw_content: bytes | Iterator[bytes] = await self._run_in_executor(
self.get_content, self.absolute_path, start, end,
)
content = iter([raw_content]) if isinstance(raw_content, bytes) else iter(raw_content)
try:
while True:
chunk, done = await self._run_in_executor(_next_chunk, content)
if done:
return
try:
self.write(chunk)
await self.flush()
except iostream.StreamClosedError:
return
finally:
close = getattr(content, "close", None)
if close is not None:
await self._run_in_executor(close)
[docs]
class StaticHandler(AsyncStaticFileHandler):
''' Implements a custom Tornado static file handler for BokehJS
JavaScript and CSS resources.
'''
def __init__(self, tornado_app: Any, *args: Any, **kw: Any) -> None:
kw['path'] = settings.bokehjs_path()
# Note: tornado_app is stored as self.application
super().__init__(tornado_app, *args, **kw)
# We aren't using tornado's built-in static_path function
# because it relies on TornadoApplication's autoconfigured
# static handler instead of our custom one. We have a
# custom one because we think we might want to serve
# static files from multiple paths at once in the future.
@classmethod
def append_version(cls, path: str) -> str:
# This version is cached on the StaticFileHandler class,
# keyed by absolute filesystem path, and only invalidated
# on an explicit StaticFileHandler.reset(). The reset is
# automatic on every request if you set static_hash_cache=False
# in TornadoApplication kwargs. In dev mode rely on dev tools
# to manage caching. This improves the ability to debug code.
if settings.dev:
return path
else:
version = TornadoStaticFileHandler.get_version(dict(static_path=settings.bokehjs_path()), path)
return f"{path}?v={version}"
#-----------------------------------------------------------------------------
# Private API
#-----------------------------------------------------------------------------
def _next_chunk(content: Iterator[bytes]) -> tuple[bytes, bool]:
try:
return next(content), False
except StopIteration:
return b"", True
#-----------------------------------------------------------------------------
# Code
#-----------------------------------------------------------------------------