import contextlib
import copy
import pathlib
import platform
import secrets
import shutil
import tempfile
import threading
import time
from typing import Optional, Union, cast
from urllib.parse import quote_plus, urlparse
import uvicorn
from ..storage import SQLStorage, get_storage
from ..utils import ensure_uri
class ThreadedServer(uvicorn.Server):
def install_signal_handlers(self):
pass
@contextlib.contextmanager
def run_in_thread(self):
thread = threading.Thread(target=self.run)
thread.start()
try:
# Wait for server to start up, or raise TimeoutError.
for _ in range(200):
time.sleep(0.1)
if self.started:
break
else:
raise TimeoutError("Server did not start in 20 seconds.")
host, port = self.servers[0].sockets[0].getsockname()
yield f"http://{host}:{port}"
finally:
self.should_exit = True
thread.join()
[docs]
class SimpleTiledServer:
"""
Run a simple Tiled server on a background thread.
This is intended to be used for tutorials and development. It employs only
basic security and should not be used to store anything important. It does
not scale to large number of users. By default, it uses temporary storage.
Parameters
----------
directory : Optional[Path, str]
Location where data, including files and embedded databases, will be
stored. By default, a temporary directory will be used.
api_key : Optional[str]
By default, an 8-bit random secret is generated. (Production Tiled
servers use longer secrets.)
port : Optional[int]
Port the server will listen on. By default, a random free high port
is allocated by the operating system.
readable_storage : Optional[Union[str, pathlib.Path, list[Union[str, pathlib.Path]]]
If provided, the server will be able to read from these storage locations, in addition
to the default storage location defined by `directory`.
Examples
--------
Run a server and connect to it.
>>> from tiled.server import SimpleTiledServer
>>> from tiled.client import from_uri
>>> ts = SimpleTiledServer()
>>> client = from_uri(ts.uri)
Locate server data, databases, and log files.
>>> ts.directory
Run a server with persistent storage that can be reused.
>>> ts = SimpleTiledServer("my_data/")
"""
[docs]
def __init__(
self,
directory: Optional[Union[str, pathlib.Path]] = None,
api_key: Optional[str] = None,
port: int = 0,
readable_storage: Optional[
Union[str, pathlib.Path, list[Union[str, pathlib.Path]]]
] = None,
):
# Delay import to avoid circular import.
from ..catalog import from_uri as catalog_from_uri
from ..config import Authentication, StreamingCacheConfig
from .app import build_app
from .logging_config import LOGGING_CONFIG
if directory is None:
directory = pathlib.Path(tempfile.mkdtemp())
self._cleanup_directory = True
else:
directory = pathlib.Path(directory).resolve()
self._cleanup_directory = False
(directory / "data").mkdir(parents=True, exist_ok=True)
storage_uri = ensure_uri(f"duckdb:///{str(directory / 'storage.duckdb')}")
# In production we use a proper 32-bit token, but for brevity we
# use just 8 here. This server only accepts connections on localhost
# and is not intended for production use, so we think this is an
# acceptable concession to usability.
api_key = api_key or secrets.token_hex(8)
# Alter copy of default LOGGING_CONFIG to log to files instead of
# stdout and stderr.
log_config = copy.deepcopy(LOGGING_CONFIG)
log_config["handlers"]["access"]["class"] = "logging.FileHandler"
del log_config["handlers"]["access"]["stream"]
log_config["handlers"]["access"]["filename"] = str(directory / "access.log")
log_config["handlers"]["default"]["class"] = "logging.FileHandler"
del log_config["handlers"]["default"]["stream"]
log_config["handlers"]["default"]["filename"] = str(directory / "error.log")
# Catalog from uri wants readable storage to be a list,
# but we want to allow users to pass in a single path (as a str or pathlib.Path)
# for convenience.
if readable_storage is not None and (
isinstance(readable_storage, str)
or isinstance(readable_storage, pathlib.Path)
):
readable_storage = [readable_storage]
self.catalog = catalog_from_uri(
directory / "catalog.db",
writable_storage=[directory / "data", storage_uri],
init_if_not_exists=True,
readable_storage=readable_storage,
cache_config=StreamingCacheConfig(uri="memory").model_dump(),
)
self.app = build_app(
self.catalog, authentication=Authentication(single_user_api_key=api_key)
)
self._cm = ThreadedServer(
uvicorn.Config(self.app, port=port, loop="asyncio", log_config=log_config)
).run_in_thread()
base_url = self._cm.__enter__()
# Extract port from base_url.
actual_port = urlparse(base_url).port
# Stash attributes for easy introspection
self.port = actual_port
self.directory = directory
self.storage = cast(SQLStorage, get_storage(storage_uri))
self.api_key = api_key
self.uri = f"{base_url}/api/v1?api_key={quote_plus(api_key)}"
self.web_ui_link = f"{base_url}?api_key={quote_plus(api_key)}"
def __repr__(self):
return f"<{type(self).__name__} '{self.uri}'>"
def _repr_html_(self):
# For Jupyter
return f"""
<table>
<tr>
<td>Web Interface</td>
<td><a href={self.web_ui_link}>{self.web_ui_link}</a></td>
</tr>
<td>API</td>
<td><code>{self.web_ui_link}</code></td>
</tr>
</table>"""
def close(self):
self.storage.dispose() # Close the connection to the storage DB
self._cm.__exit__(None, None, None)
if self._cleanup_directory and (platform.system() != "Windows"):
# Windows cannot delete the logfiles because the global Python
# logging system still has the logfiles open for appending.
shutil.rmtree(self.directory)
def __enter__(self):
return self
def __exit__(self, *args):
self.close()