Source code for betty.serve

"""
Provide the Serve API to serve resources within the application.
"""

from __future__ import annotations

import contextlib
import logging
import threading
import webbrowser
from abc import ABC, abstractmethod
from asyncio import to_thread
from http.client import HTTPConnection
from http.server import SimpleHTTPRequestHandler, HTTPServer
from io import StringIO
from pathlib import Path
from typing import Any, TYPE_CHECKING, Self
from typing import final
from urllib.parse import urlparse

from aiofiles.os import makedirs, symlink
from aiofiles.tempfile import TemporaryDirectory, AiofilesContextManagerTempDir
from typing_extensions import override

from betty.error import UserFacingError
from betty.functools import Do
from betty.locale.localizable import _
from betty.project.factory import ProjectDependentFactory

if TYPE_CHECKING:
    from betty.locale.localizer import Localizer
    from betty.project import Project
    from types import TracebackType

DEFAULT_PORT = 8000


[docs] class ServerNotStartedError(RuntimeError): """ Raised when a web server has not (fully) started yet. """ pass # pragma: no cover
[docs] class NoPublicUrlBecauseServerNotStartedError(ServerNotStartedError): """ A public URL is not yet available because the server has not (fully) started yet. """
[docs] def __init__(self): super().__init__( "Cannot get the public URL for a server that has not started yet." )
[docs] class OsError(UserFacingError, OSError): """ Raised for I/O errors. """ pass # pragma: no cover
[docs] class Server(ABC): """ Provide a (development) web server. """
[docs] def __init__(self, localizer: Localizer): self._localizer = localizer
[docs] @abstractmethod async def start(self) -> None: # noqa B027 """ Start the server. """ pass
[docs] async def show(self) -> None: """ Show the served site to the user. """ logging.getLogger(__name__).info( self._localizer._("Serving your site at {url}...").format( url=self.public_url, ) ) webbrowser.open_new_tab(self.public_url)
[docs] @abstractmethod async def stop(self) -> None: # noqa B027 """ Stop the server. """ pass
@property @abstractmethod def public_url(self) -> str: """ The server's public URL. """ pass async def __aenter__(self) -> Server: await self.start() try: await self.assert_available() except BaseException: await self.stop() raise return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: await self.stop()
[docs] async def assert_available(self) -> None: """ Assert that this server is available. """ try: await Do[Any, None](self._assert_available).until() except Exception as error: raise UserFacingError( _("The server at {url} was unreachable after starting.").format( url=self.public_url ) ) from error
async def _assert_available(self) -> None: await to_thread(self.__assert_available) def __assert_available(self) -> None: url = urlparse(self.public_url) connection = HTTPConnection(url.netloc) connection.request("GET", url.path) response = connection.getresponse() assert 400 > response.status >= 200
[docs] class ProjectServer(ProjectDependentFactory, Server): """ A web server for a Betty project. """
[docs] def __init__(self, localizer: Localizer, project: Project) -> None: super().__init__(localizer) self._project = project
[docs] @override @classmethod async def new_for_project(cls, project: Project) -> Self: return cls(await project.app.localizer, project)
@final class _BuiltinServerRequestHandler(SimpleHTTPRequestHandler): @override def end_headers(self) -> None: self.send_header("Cache-Control", "no-cache") super().end_headers()
[docs] @final class BuiltinServer(Server): """ A built-in server for a WWW directory. """
[docs] def __init__( self, www_directory_path: Path, *, root_path: str | None = None, localizer: Localizer, ) -> None: super().__init__(localizer) self._www_directory_path = www_directory_path self._root_path = root_path self._http_server: HTTPServer | None = None self._port: int | None = None self._thread: threading.Thread | None = None self._temporary_root_directory: AiofilesContextManagerTempDir | None = None
[docs] @override async def start(self) -> None: if self._root_path: # To mimic the root path, symlink the project's WWW directory into a temporary # directory, so we do not have to make changes to any existing files. self._temporary_root_directory = TemporaryDirectory() temporary_root_directory_path = Path( await self._temporary_root_directory.__aenter__() ) temporary_www_directory = temporary_root_directory_path for root_path_component in self._root_path.split("/"): temporary_www_directory /= root_path_component if temporary_www_directory != temporary_root_directory_path: temporary_www_directory.parent.mkdir(parents=True, exist_ok=True) await symlink(self._www_directory_path, temporary_www_directory) www_directory_path = temporary_root_directory_path else: www_directory_path = self._www_directory_path logging.getLogger(__name__).info( self._localizer._("Starting Python's built-in web server...") ) for self._port in range(DEFAULT_PORT, 65535): with contextlib.suppress(OSError): self._http_server = HTTPServer( ("", self._port), lambda request, client_address, server: _BuiltinServerRequestHandler( request, client_address, server, directory=str(www_directory_path), ), ) break if self._http_server is None: raise OsError(_("Cannot find an available port to bind the web server to.")) self._thread = threading.Thread(target=self._serve) self._thread.start()
@override @property def public_url(self) -> str: if self._port is not None: url = f"http://localhost:{self._port}" if self._root_path: url = f"{url}/{self._root_path}" return url raise NoPublicUrlBecauseServerNotStartedError() def _serve(self) -> None: with contextlib.redirect_stderr(StringIO()): assert self._http_server self._http_server.serve_forever()
[docs] @override async def stop(self) -> None: if self._http_server is not None: self._http_server.shutdown() self._http_server.server_close() if self._thread is not None: self._thread.join() if self._temporary_root_directory is not None: await self._temporary_root_directory.__aexit__(None, None, None)
[docs] @final class BuiltinProjectServer(ProjectServer): """ A built-in server for a Betty project. """
[docs] def __init__(self, localizer: Localizer, project: Project) -> None: super().__init__(localizer, project) self._server = BuiltinServer( project.configuration.www_directory_path, root_path=project.configuration.root_path, localizer=localizer, )
@override @property def public_url(self) -> str: return self._server.public_url
[docs] @override async def start(self) -> None: await makedirs(self._project.configuration.www_directory_path, exist_ok=True) await self._server.start()
[docs] @override async def stop(self) -> None: await self._server.stop()