2018-05-25 00:15:37 +00:00
|
|
|
import asyncio
|
2018-07-10 15:13:27 +00:00
|
|
|
import click
|
2018-05-20 17:01:49 +00:00
|
|
|
import collections
|
2018-05-13 12:58:28 +00:00
|
|
|
import hashlib
|
2019-04-21 05:28:15 +00:00
|
|
|
import json
|
2018-05-13 12:58:28 +00:00
|
|
|
import os
|
2018-05-02 08:46:54 +00:00
|
|
|
import sys
|
2018-05-25 00:15:37 +00:00
|
|
|
import threading
|
2019-05-01 23:16:15 +00:00
|
|
|
import time
|
2018-04-14 13:17:20 +00:00
|
|
|
import traceback
|
2018-05-13 12:58:28 +00:00
|
|
|
import urllib.parse
|
|
|
|
from concurrent import futures
|
|
|
|
from pathlib import Path
|
|
|
|
|
2018-05-28 21:24:19 +00:00
|
|
|
from markupsafe import Markup
|
2018-05-13 12:58:28 +00:00
|
|
|
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
|
|
|
|
from sanic import Sanic, response
|
|
|
|
from sanic.exceptions import InvalidUsage, NotFound
|
|
|
|
|
2019-05-04 02:15:14 +00:00
|
|
|
from .views.base import DatasetteError, ureg
|
2018-05-21 08:02:34 +00:00
|
|
|
from .views.database import DatabaseDownload, DatabaseView
|
|
|
|
from .views.index import IndexView
|
2018-06-07 15:22:29 +00:00
|
|
|
from .views.special import JsonDataView
|
2018-05-21 08:02:34 +00:00
|
|
|
from .views.table import RowView, TableView
|
2019-05-01 23:01:56 +00:00
|
|
|
from .renderer import json_renderer
|
2018-05-13 12:58:28 +00:00
|
|
|
|
2017-11-10 19:25:54 +00:00
|
|
|
from .utils import (
|
2018-05-25 00:15:37 +00:00
|
|
|
InterruptedError,
|
|
|
|
Results,
|
2019-05-02 00:39:39 +00:00
|
|
|
detect_spatialite,
|
2017-11-11 05:55:50 +00:00
|
|
|
escape_css_string,
|
2018-04-03 13:39:50 +00:00
|
|
|
escape_sqlite,
|
2019-04-07 02:56:07 +00:00
|
|
|
get_outbound_foreign_keys,
|
2018-04-19 05:24:48 +00:00
|
|
|
get_plugins,
|
2018-04-16 05:22:01 +00:00
|
|
|
module_from_path,
|
2018-08-16 00:58:56 +00:00
|
|
|
sqlite3,
|
2018-05-25 00:15:37 +00:00
|
|
|
sqlite_timelimit,
|
2019-04-07 01:58:51 +00:00
|
|
|
table_columns,
|
2019-05-04 02:15:14 +00:00
|
|
|
to_css_class,
|
2017-11-10 19:25:54 +00:00
|
|
|
)
|
2018-05-21 08:02:34 +00:00
|
|
|
from .inspect import inspect_hash, inspect_views, inspect_tables
|
2019-04-21 05:28:15 +00:00
|
|
|
from .tracer import capture_traces, trace
|
2018-08-28 07:36:22 +00:00
|
|
|
from .plugins import pm, DEFAULT_PLUGINS
|
2017-11-16 15:20:54 +00:00
|
|
|
from .version import __version__
|
2017-10-23 16:02:40 +00:00
|
|
|
|
2017-10-27 07:08:24 +00:00
|
|
|
app_root = Path(__file__).parent.parent
|
2017-10-23 16:02:40 +00:00
|
|
|
|
2018-05-25 00:15:37 +00:00
|
|
|
connections = threading.local()
|
2019-03-14 23:42:38 +00:00
|
|
|
MEMORY = object()
|
2018-04-16 00:56:15 +00:00
|
|
|
|
2019-05-04 02:15:14 +00:00
|
|
|
ConfigOption = collections.namedtuple("ConfigOption", ("name", "default", "help"))
|
2018-05-20 17:01:49 +00:00
|
|
|
CONFIG_OPTIONS = (
|
2019-05-04 02:15:14 +00:00
|
|
|
ConfigOption("default_page_size", 100, "Default page size for the table view"),
|
|
|
|
ConfigOption(
|
|
|
|
"max_returned_rows",
|
|
|
|
1000,
|
|
|
|
"Maximum rows that can be returned from a table or custom query",
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"num_sql_threads",
|
|
|
|
3,
|
|
|
|
"Number of threads in the thread pool for executing SQLite queries",
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"sql_time_limit_ms", 1000, "Time limit for a SQL query in milliseconds"
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"default_facet_size", 30, "Number of values to return for requested facets"
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"facet_time_limit_ms", 200, "Time limit for calculating a requested facet"
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"facet_suggest_time_limit_ms",
|
|
|
|
50,
|
|
|
|
"Time limit for calculating a suggested facet",
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"hash_urls",
|
|
|
|
False,
|
|
|
|
"Include DB file contents hash in URLs, for far-future caching",
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"allow_facet",
|
|
|
|
True,
|
|
|
|
"Allow users to specify columns to facet using ?_facet= parameter",
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"allow_download",
|
|
|
|
True,
|
|
|
|
"Allow users to download the original SQLite database files",
|
|
|
|
),
|
|
|
|
ConfigOption("suggest_facets", True, "Calculate and display suggested facets"),
|
|
|
|
ConfigOption("allow_sql", True, "Allow arbitrary SQL queries via ?sql= parameter"),
|
|
|
|
ConfigOption(
|
|
|
|
"default_cache_ttl",
|
|
|
|
5,
|
|
|
|
"Default HTTP cache TTL (used in Cache-Control: max-age= header)",
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"default_cache_ttl_hashed",
|
|
|
|
365 * 24 * 60 * 60,
|
|
|
|
"Default HTTP cache TTL for hashed URL pages",
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"cache_size_kb", 0, "SQLite cache size in KB (0 == use SQLite default)"
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"allow_csv_stream",
|
|
|
|
True,
|
|
|
|
"Allow .csv?_stream=1 to download all rows (ignoring max_returned_rows)",
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"max_csv_mb",
|
|
|
|
100,
|
|
|
|
"Maximum size allowed for CSV export in MB - set 0 to disable this limit",
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"truncate_cells_html",
|
|
|
|
2048,
|
|
|
|
"Truncate cells longer than this in HTML table view - set 0 to disable",
|
|
|
|
),
|
|
|
|
ConfigOption(
|
|
|
|
"force_https_urls",
|
|
|
|
False,
|
|
|
|
"Force URLs in API output to always use https:// protocol",
|
|
|
|
),
|
2018-05-20 17:01:49 +00:00
|
|
|
)
|
2019-05-04 02:15:14 +00:00
|
|
|
DEFAULT_CONFIG = {option.name: option.default for option in CONFIG_OPTIONS}
|
2018-05-18 05:08:26 +00:00
|
|
|
|
|
|
|
|
2017-10-24 02:00:37 +00:00
|
|
|
async def favicon(request):
|
2018-05-13 12:55:15 +00:00
|
|
|
return response.text("")
|
2017-10-24 02:00:37 +00:00
|
|
|
|
|
|
|
|
2019-03-31 23:51:52 +00:00
|
|
|
class ConnectedDatabase:
|
2019-05-02 00:39:39 +00:00
|
|
|
def __init__(self, ds, path=None, is_mutable=False, is_memory=False):
|
|
|
|
self.ds = ds
|
2019-03-31 23:51:52 +00:00
|
|
|
self.path = path
|
|
|
|
self.is_mutable = is_mutable
|
|
|
|
self.is_memory = is_memory
|
|
|
|
self.hash = None
|
2019-05-02 01:12:58 +00:00
|
|
|
self.cached_size = None
|
2019-05-02 00:39:39 +00:00
|
|
|
self.cached_table_counts = None
|
2019-03-31 23:51:52 +00:00
|
|
|
if not self.is_mutable:
|
|
|
|
p = Path(path)
|
|
|
|
self.hash = inspect_hash(p)
|
2019-05-02 01:12:58 +00:00
|
|
|
self.cached_size = p.stat().st_size
|
|
|
|
|
|
|
|
@property
|
|
|
|
def size(self):
|
2019-05-05 18:01:14 +00:00
|
|
|
if self.is_memory:
|
|
|
|
return 0
|
2019-05-02 01:12:58 +00:00
|
|
|
if self.cached_size is not None:
|
|
|
|
return self.cached_size
|
|
|
|
else:
|
|
|
|
return Path(self.path).stat().st_size
|
2019-03-31 23:51:52 +00:00
|
|
|
|
2019-05-02 00:39:39 +00:00
|
|
|
async def table_counts(self, limit=10):
|
|
|
|
if not self.is_mutable and self.cached_table_counts is not None:
|
|
|
|
return self.cached_table_counts
|
|
|
|
# Try to get counts for each table, $limit timeout for each count
|
|
|
|
counts = {}
|
|
|
|
for table in await self.table_names():
|
|
|
|
try:
|
2019-05-04 02:15:14 +00:00
|
|
|
table_count = (
|
|
|
|
await self.ds.execute(
|
|
|
|
self.name,
|
|
|
|
"select count(*) from [{}]".format(table),
|
|
|
|
custom_time_limit=limit,
|
|
|
|
)
|
|
|
|
).rows[0][0]
|
2019-05-02 00:39:39 +00:00
|
|
|
counts[table] = table_count
|
|
|
|
except InterruptedError:
|
|
|
|
counts[table] = None
|
|
|
|
if not self.is_mutable:
|
|
|
|
self.cached_table_counts = counts
|
|
|
|
return counts
|
|
|
|
|
2019-03-31 23:51:52 +00:00
|
|
|
@property
|
2019-04-20 17:50:45 +00:00
|
|
|
def mtime_ns(self):
|
|
|
|
return Path(self.path).stat().st_mtime_ns
|
|
|
|
|
|
|
|
@property
|
2019-03-31 23:51:52 +00:00
|
|
|
def name(self):
|
|
|
|
if self.is_memory:
|
|
|
|
return ":memory:"
|
|
|
|
else:
|
|
|
|
return Path(self.path).stem
|
|
|
|
|
2019-05-02 00:39:39 +00:00
|
|
|
async def table_names(self):
|
2019-05-04 02:15:14 +00:00
|
|
|
results = await self.ds.execute(
|
|
|
|
self.name, "select name from sqlite_master where type='table'"
|
|
|
|
)
|
2019-05-02 00:39:39 +00:00
|
|
|
return [r[0] for r in results.rows]
|
|
|
|
|
|
|
|
async def hidden_table_names(self):
|
|
|
|
# Mark tables 'hidden' if they relate to FTS virtual tables
|
2019-05-04 02:15:14 +00:00
|
|
|
hidden_tables = [
|
|
|
|
r[0]
|
|
|
|
for r in (
|
|
|
|
await self.ds.execute(
|
|
|
|
self.name,
|
|
|
|
"""
|
2019-05-02 00:39:39 +00:00
|
|
|
select name from sqlite_master
|
|
|
|
where rootpage = 0
|
|
|
|
and sql like '%VIRTUAL TABLE%USING FTS%'
|
2019-05-04 02:15:14 +00:00
|
|
|
""",
|
|
|
|
)
|
|
|
|
).rows
|
|
|
|
]
|
2019-05-02 00:39:39 +00:00
|
|
|
has_spatialite = await self.ds.execute_against_connection_in_thread(
|
|
|
|
self.name, detect_spatialite
|
|
|
|
)
|
|
|
|
if has_spatialite:
|
|
|
|
# Also hide Spatialite internal tables
|
|
|
|
hidden_tables += [
|
|
|
|
"ElementaryGeometries",
|
|
|
|
"SpatialIndex",
|
|
|
|
"geometry_columns",
|
|
|
|
"spatial_ref_sys",
|
|
|
|
"spatialite_history",
|
|
|
|
"sql_statements_log",
|
|
|
|
"sqlite_sequence",
|
|
|
|
"views_geometry_columns",
|
|
|
|
"virts_geometry_columns",
|
|
|
|
] + [
|
|
|
|
r[0]
|
|
|
|
for r in (
|
2019-05-04 02:15:14 +00:00
|
|
|
await self.ds.execute(
|
|
|
|
self.name,
|
|
|
|
"""
|
2019-05-02 00:39:39 +00:00
|
|
|
select name from sqlite_master
|
|
|
|
where name like "idx_%"
|
|
|
|
and type = "table"
|
2019-05-04 02:15:14 +00:00
|
|
|
""",
|
|
|
|
)
|
2019-05-02 00:39:39 +00:00
|
|
|
).rows
|
|
|
|
]
|
2019-05-02 00:54:48 +00:00
|
|
|
# Add any from metadata.json
|
|
|
|
db_metadata = self.ds.metadata(database=self.name)
|
|
|
|
if "tables" in db_metadata:
|
|
|
|
hidden_tables += [
|
2019-05-04 02:15:14 +00:00
|
|
|
t
|
|
|
|
for t in db_metadata["tables"]
|
|
|
|
if db_metadata["tables"][t].get("hidden")
|
2019-05-02 00:54:48 +00:00
|
|
|
]
|
|
|
|
# Also mark as hidden any tables which start with the name of a hidden table
|
|
|
|
# e.g. "searchable_fts" implies "searchable_fts_content" should be hidden
|
|
|
|
for table_name in await self.table_names():
|
|
|
|
for hidden_table in hidden_tables[:]:
|
|
|
|
if table_name.startswith(hidden_table):
|
|
|
|
hidden_tables.append(table_name)
|
|
|
|
continue
|
|
|
|
|
2019-05-02 00:39:39 +00:00
|
|
|
return hidden_tables
|
|
|
|
|
|
|
|
async def view_names(self):
|
2019-05-04 02:15:14 +00:00
|
|
|
results = await self.ds.execute(
|
|
|
|
self.name, "select name from sqlite_master where type='view'"
|
|
|
|
)
|
2019-05-02 00:39:39 +00:00
|
|
|
return [r[0] for r in results.rows]
|
|
|
|
|
2019-03-31 23:51:52 +00:00
|
|
|
def __repr__(self):
|
|
|
|
tags = []
|
|
|
|
if self.is_mutable:
|
|
|
|
tags.append("mutable")
|
|
|
|
if self.is_memory:
|
|
|
|
tags.append("memory")
|
|
|
|
if self.hash:
|
|
|
|
tags.append("hash={}".format(self.hash))
|
|
|
|
if self.size is not None:
|
|
|
|
tags.append("size={}".format(self.size))
|
|
|
|
tags_str = ""
|
|
|
|
if tags:
|
|
|
|
tags_str = " ({})".format(", ".join(tags))
|
2019-05-04 02:15:14 +00:00
|
|
|
return "<ConnectedDatabase: {}{}>".format(self.name, tags_str)
|
2019-03-31 23:51:52 +00:00
|
|
|
|
|
|
|
|
2017-11-10 19:05:57 +00:00
|
|
|
class Datasette:
|
2017-11-13 19:33:01 +00:00
|
|
|
def __init__(
|
2018-05-13 12:55:15 +00:00
|
|
|
self,
|
|
|
|
files,
|
2019-03-17 23:25:15 +00:00
|
|
|
immutables=None,
|
2018-05-13 12:55:15 +00:00
|
|
|
cache_headers=True,
|
|
|
|
cors=False,
|
|
|
|
inspect_data=None,
|
|
|
|
metadata=None,
|
|
|
|
sqlite_extensions=None,
|
|
|
|
template_dir=None,
|
|
|
|
plugins_dir=None,
|
|
|
|
static_mounts=None,
|
2019-03-14 23:42:38 +00:00
|
|
|
memory=False,
|
2018-05-20 17:01:49 +00:00
|
|
|
config=None,
|
2018-06-17 20:14:55 +00:00
|
|
|
version_note=None,
|
2018-05-13 12:55:15 +00:00
|
|
|
):
|
2019-03-17 23:25:15 +00:00
|
|
|
immutables = immutables or []
|
2019-03-17 23:36:35 +00:00
|
|
|
self.files = tuple(files) + tuple(immutables)
|
2019-03-17 23:25:15 +00:00
|
|
|
self.immutables = set(immutables)
|
2019-03-14 23:42:38 +00:00
|
|
|
if not self.files:
|
|
|
|
self.files = [MEMORY]
|
|
|
|
elif memory:
|
|
|
|
self.files = (MEMORY,) + self.files
|
2019-03-31 23:51:52 +00:00
|
|
|
self.databases = {}
|
|
|
|
for file in self.files:
|
|
|
|
path = file
|
|
|
|
is_memory = False
|
|
|
|
if file is MEMORY:
|
|
|
|
path = None
|
|
|
|
is_memory = True
|
2019-05-02 00:39:39 +00:00
|
|
|
is_mutable = path not in self.immutables
|
2019-05-04 02:15:14 +00:00
|
|
|
db = ConnectedDatabase(
|
|
|
|
self, path, is_mutable=is_mutable, is_memory=is_memory
|
|
|
|
)
|
2019-03-31 23:51:52 +00:00
|
|
|
if db.name in self.databases:
|
|
|
|
raise Exception("Multiple files with same stem: {}".format(db.name))
|
|
|
|
self.databases[db.name] = db
|
2017-11-10 20:26:37 +00:00
|
|
|
self.cache_headers = cache_headers
|
2017-11-13 18:17:42 +00:00
|
|
|
self.cors = cors
|
2017-11-13 15:20:02 +00:00
|
|
|
self._inspect = inspect_data
|
2018-08-13 14:56:50 +00:00
|
|
|
self._metadata = metadata or {}
|
2017-11-15 02:41:03 +00:00
|
|
|
self.sqlite_functions = []
|
2017-11-16 16:46:04 +00:00
|
|
|
self.sqlite_extensions = sqlite_extensions or []
|
2017-11-30 16:05:01 +00:00
|
|
|
self.template_dir = template_dir
|
2018-04-16 05:22:01 +00:00
|
|
|
self.plugins_dir = plugins_dir
|
2017-12-03 16:33:36 +00:00
|
|
|
self.static_mounts = static_mounts or []
|
2018-08-11 20:06:45 +00:00
|
|
|
self._config = dict(DEFAULT_CONFIG, **(config or {}))
|
2019-05-01 23:01:56 +00:00
|
|
|
self.renderers = {} # File extension -> renderer function
|
2018-06-17 20:14:55 +00:00
|
|
|
self.version_note = version_note
|
2018-05-27 00:43:22 +00:00
|
|
|
self.executor = futures.ThreadPoolExecutor(
|
2018-08-11 20:06:45 +00:00
|
|
|
max_workers=self.config("num_sql_threads")
|
2018-05-27 00:43:22 +00:00
|
|
|
)
|
2018-08-11 20:06:45 +00:00
|
|
|
self.max_returned_rows = self.config("max_returned_rows")
|
|
|
|
self.sql_time_limit_ms = self.config("sql_time_limit_ms")
|
|
|
|
self.page_size = self.config("default_page_size")
|
2018-04-16 05:22:01 +00:00
|
|
|
# Execute plugins in constructor, to ensure they are available
|
|
|
|
# when the rest of `datasette inspect` executes
|
|
|
|
if self.plugins_dir:
|
|
|
|
for filename in os.listdir(self.plugins_dir):
|
|
|
|
filepath = os.path.join(self.plugins_dir, filename)
|
2018-05-13 12:44:22 +00:00
|
|
|
mod = module_from_path(filepath, name=filename)
|
|
|
|
try:
|
|
|
|
pm.register(mod)
|
|
|
|
except ValueError:
|
|
|
|
# Plugin already registered
|
|
|
|
pass
|
2019-05-11 22:55:30 +00:00
|
|
|
# Run the sanity checks
|
|
|
|
asyncio.get_event_loop().run_until_complete(self.run_sanity_checks())
|
|
|
|
|
|
|
|
async def run_sanity_checks(self):
|
|
|
|
# Only one check right now, for Spatialite
|
|
|
|
for database_name, database in self.databases.items():
|
|
|
|
# Run pragma_info on every table
|
|
|
|
for table in await database.table_names():
|
|
|
|
try:
|
|
|
|
await self.execute(
|
|
|
|
database_name,
|
|
|
|
"PRAGMA table_info({});".format(escape_sqlite(table)),
|
|
|
|
)
|
|
|
|
except sqlite3.OperationalError as e:
|
|
|
|
if e.args[0] == "no such module: VirtualSpatialIndex":
|
|
|
|
raise click.UsageError(
|
|
|
|
"It looks like you're trying to load a SpatiaLite"
|
|
|
|
" database without first loading the SpatiaLite module."
|
|
|
|
"\n\nRead more: https://datasette.readthedocs.io/en/latest/spatialite.html"
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
raise
|
2017-11-13 15:20:02 +00:00
|
|
|
|
2018-08-11 20:06:45 +00:00
|
|
|
def config(self, key):
|
|
|
|
return self._config.get(key, None)
|
|
|
|
|
|
|
|
def config_dict(self):
|
|
|
|
# Returns a fully resolved config dictionary, useful for templates
|
2019-05-04 02:15:14 +00:00
|
|
|
return {option.name: self.config(option.name) for option in CONFIG_OPTIONS}
|
2018-08-11 20:06:45 +00:00
|
|
|
|
2018-08-13 14:56:50 +00:00
|
|
|
def metadata(self, key=None, database=None, table=None, fallback=True):
|
|
|
|
"""
|
|
|
|
Looks up metadata, cascading backwards from specified level.
|
2018-08-28 08:35:21 +00:00
|
|
|
Returns None if metadata value is not found.
|
2018-08-13 14:56:50 +00:00
|
|
|
"""
|
2019-05-04 02:15:14 +00:00
|
|
|
assert not (
|
|
|
|
database is None and table is not None
|
|
|
|
), "Cannot call metadata() with table= specified but not database="
|
2018-08-13 14:56:50 +00:00
|
|
|
databases = self._metadata.get("databases") or {}
|
|
|
|
search_list = []
|
|
|
|
if database is not None:
|
|
|
|
search_list.append(databases.get(database) or {})
|
|
|
|
if table is not None:
|
2019-05-04 02:15:14 +00:00
|
|
|
table_metadata = ((databases.get(database) or {}).get("tables") or {}).get(
|
|
|
|
table
|
|
|
|
) or {}
|
2018-08-13 14:56:50 +00:00
|
|
|
search_list.insert(0, table_metadata)
|
|
|
|
search_list.append(self._metadata)
|
|
|
|
if not fallback:
|
|
|
|
# No fallback allowed, so just use the first one in the list
|
|
|
|
search_list = search_list[:1]
|
|
|
|
if key is not None:
|
|
|
|
for item in search_list:
|
|
|
|
if key in item:
|
|
|
|
return item[key]
|
|
|
|
return None
|
|
|
|
else:
|
|
|
|
# Return the merged list
|
|
|
|
m = {}
|
|
|
|
for item in search_list:
|
|
|
|
m.update(item)
|
|
|
|
return m
|
|
|
|
|
2019-05-04 02:15:14 +00:00
|
|
|
def plugin_config(self, plugin_name, database=None, table=None, fallback=True):
|
2018-08-28 08:35:21 +00:00
|
|
|
"Return config for plugin, falling back from specified database/table"
|
|
|
|
plugins = self.metadata(
|
|
|
|
"plugins", database=database, table=table, fallback=fallback
|
|
|
|
)
|
|
|
|
if plugins is None:
|
|
|
|
return None
|
|
|
|
return plugins.get(plugin_name)
|
|
|
|
|
2017-12-09 03:10:09 +00:00
|
|
|
def app_css_hash(self):
|
2018-05-13 12:55:15 +00:00
|
|
|
if not hasattr(self, "_app_css_hash"):
|
2017-12-09 03:10:09 +00:00
|
|
|
self._app_css_hash = hashlib.sha1(
|
2019-05-04 02:15:14 +00:00
|
|
|
open(os.path.join(str(app_root), "datasette/static/app.css"))
|
|
|
|
.read()
|
|
|
|
.encode("utf8")
|
|
|
|
).hexdigest()[:6]
|
2017-12-09 03:10:09 +00:00
|
|
|
return self._app_css_hash
|
|
|
|
|
2018-07-16 02:33:30 +00:00
|
|
|
def get_canned_queries(self, database_name):
|
2019-05-04 02:15:14 +00:00
|
|
|
queries = self.metadata("queries", database=database_name, fallback=False) or {}
|
2018-08-13 14:56:50 +00:00
|
|
|
names = queries.keys()
|
2019-05-04 02:15:14 +00:00
|
|
|
return [self.get_canned_query(database_name, name) for name in names]
|
2018-07-16 02:33:30 +00:00
|
|
|
|
2017-12-05 16:17:02 +00:00
|
|
|
def get_canned_query(self, database_name, query_name):
|
2019-05-04 02:15:14 +00:00
|
|
|
queries = self.metadata("queries", database=database_name, fallback=False) or {}
|
2018-08-13 14:56:50 +00:00
|
|
|
query = queries.get(query_name)
|
2017-12-05 16:17:02 +00:00
|
|
|
if query:
|
2018-07-16 02:33:30 +00:00
|
|
|
if not isinstance(query, dict):
|
|
|
|
query = {"sql": query}
|
|
|
|
query["name"] = query_name
|
|
|
|
return query
|
2017-12-05 16:17:02 +00:00
|
|
|
|
2018-06-16 17:33:17 +00:00
|
|
|
async def get_table_definition(self, database_name, table, type_="table"):
|
|
|
|
table_definition_rows = list(
|
|
|
|
await self.execute(
|
|
|
|
database_name,
|
2019-05-04 02:15:14 +00:00
|
|
|
"select sql from sqlite_master where name = :n and type=:t",
|
2018-06-16 17:33:17 +00:00
|
|
|
{"n": table, "t": type_},
|
|
|
|
)
|
|
|
|
)
|
|
|
|
if not table_definition_rows:
|
|
|
|
return None
|
|
|
|
return table_definition_rows[0][0]
|
|
|
|
|
|
|
|
def get_view_definition(self, database_name, view):
|
2019-05-04 02:15:14 +00:00
|
|
|
return self.get_table_definition(database_name, view, "view")
|
2018-06-16 17:33:17 +00:00
|
|
|
|
2018-03-27 16:18:32 +00:00
|
|
|
def update_with_inherited_metadata(self, metadata):
|
|
|
|
# Fills in source/license with defaults, if available
|
2018-05-13 12:55:15 +00:00
|
|
|
metadata.update(
|
|
|
|
{
|
2018-08-13 14:56:50 +00:00
|
|
|
"source": metadata.get("source") or self.metadata("source"),
|
2019-05-04 02:15:14 +00:00
|
|
|
"source_url": metadata.get("source_url") or self.metadata("source_url"),
|
2018-08-13 14:56:50 +00:00
|
|
|
"license": metadata.get("license") or self.metadata("license"),
|
2018-05-13 12:55:15 +00:00
|
|
|
"license_url": metadata.get("license_url")
|
2018-08-13 14:56:50 +00:00
|
|
|
or self.metadata("license_url"),
|
2019-03-10 21:37:11 +00:00
|
|
|
"about": metadata.get("about") or self.metadata("about"),
|
2019-05-04 02:15:14 +00:00
|
|
|
"about_url": metadata.get("about_url") or self.metadata("about_url"),
|
2018-05-13 12:55:15 +00:00
|
|
|
}
|
|
|
|
)
|
2018-03-27 16:18:32 +00:00
|
|
|
|
2017-11-26 22:51:42 +00:00
|
|
|
def prepare_connection(self, conn):
|
|
|
|
conn.row_factory = sqlite3.Row
|
2018-05-13 12:55:15 +00:00
|
|
|
conn.text_factory = lambda x: str(x, "utf-8", "replace")
|
2017-11-26 22:51:42 +00:00
|
|
|
for name, num_args, func in self.sqlite_functions:
|
|
|
|
conn.create_function(name, num_args, func)
|
|
|
|
if self.sqlite_extensions:
|
|
|
|
conn.enable_load_extension(True)
|
|
|
|
for extension in self.sqlite_extensions:
|
|
|
|
conn.execute("SELECT load_extension('{}')".format(extension))
|
2018-08-11 20:06:45 +00:00
|
|
|
if self.config("cache_size_kb"):
|
2019-05-04 02:15:14 +00:00
|
|
|
conn.execute("PRAGMA cache_size=-{}".format(self.config("cache_size_kb")))
|
2019-04-13 19:20:10 +00:00
|
|
|
# pylint: disable=no-member
|
2018-04-16 00:56:15 +00:00
|
|
|
pm.hook.prepare_connection(conn=conn)
|
2017-11-26 22:51:42 +00:00
|
|
|
|
2019-03-31 18:02:22 +00:00
|
|
|
async def table_exists(self, database, table):
|
|
|
|
results = await self.execute(
|
|
|
|
database,
|
|
|
|
"select 1 from sqlite_master where type='table' and name=?",
|
2019-05-04 02:15:14 +00:00
|
|
|
params=(table,),
|
2019-03-31 18:02:22 +00:00
|
|
|
)
|
|
|
|
return bool(results.rows)
|
2018-06-15 06:51:23 +00:00
|
|
|
|
2019-04-13 18:48:00 +00:00
|
|
|
async def expand_foreign_keys(self, database, table, column, values):
|
|
|
|
"Returns dict mapping (column, value) -> label"
|
|
|
|
labeled_fks = {}
|
|
|
|
foreign_keys = await self.foreign_keys_for_table(database, table)
|
|
|
|
# Find the foreign_key for this column
|
|
|
|
try:
|
|
|
|
fk = [
|
2019-05-04 02:15:14 +00:00
|
|
|
foreign_key
|
|
|
|
for foreign_key in foreign_keys
|
2019-04-13 18:48:00 +00:00
|
|
|
if foreign_key["column"] == column
|
|
|
|
][0]
|
|
|
|
except IndexError:
|
|
|
|
return {}
|
|
|
|
label_column = await self.label_column_for_table(database, fk["other_table"])
|
|
|
|
if not label_column:
|
2019-05-04 02:15:14 +00:00
|
|
|
return {(fk["column"], value): str(value) for value in values}
|
2019-04-13 18:48:00 +00:00
|
|
|
labeled_fks = {}
|
2019-05-04 02:15:14 +00:00
|
|
|
sql = """
|
2019-04-13 18:48:00 +00:00
|
|
|
select {other_column}, {label_column}
|
|
|
|
from {other_table}
|
|
|
|
where {other_column} in ({placeholders})
|
2019-05-04 02:15:14 +00:00
|
|
|
""".format(
|
2019-04-13 18:48:00 +00:00
|
|
|
other_column=escape_sqlite(fk["other_column"]),
|
|
|
|
label_column=escape_sqlite(label_column),
|
|
|
|
other_table=escape_sqlite(fk["other_table"]),
|
|
|
|
placeholders=", ".join(["?"] * len(set(values))),
|
|
|
|
)
|
|
|
|
try:
|
2019-05-04 02:15:14 +00:00
|
|
|
results = await self.execute(database, sql, list(set(values)))
|
2019-04-13 18:48:00 +00:00
|
|
|
except InterruptedError:
|
|
|
|
pass
|
|
|
|
else:
|
|
|
|
for id, value in results:
|
|
|
|
labeled_fks[(fk["column"], id)] = value
|
|
|
|
return labeled_fks
|
|
|
|
|
2019-04-13 19:16:05 +00:00
|
|
|
def absolute_url(self, request, path):
|
|
|
|
url = urllib.parse.urljoin(request.url, path)
|
|
|
|
if url.startswith("http://") and self.config("force_https_urls"):
|
2019-05-04 02:15:14 +00:00
|
|
|
url = "https://" + url[len("http://") :]
|
2019-04-13 19:16:05 +00:00
|
|
|
return url
|
|
|
|
|
2017-11-13 15:20:02 +00:00
|
|
|
def inspect(self):
|
2018-05-21 08:02:34 +00:00
|
|
|
" Inspect the database and return a dictionary of table metadata "
|
|
|
|
if self._inspect:
|
|
|
|
return self._inspect
|
|
|
|
|
|
|
|
self._inspect = {}
|
|
|
|
for filename in self.files:
|
2019-03-14 23:42:38 +00:00
|
|
|
if filename is MEMORY:
|
|
|
|
self._inspect[":memory:"] = {
|
|
|
|
"hash": "000",
|
|
|
|
"file": ":memory:",
|
|
|
|
"size": 0,
|
|
|
|
"views": {},
|
|
|
|
"tables": {},
|
|
|
|
}
|
|
|
|
else:
|
|
|
|
path = Path(filename)
|
|
|
|
name = path.stem
|
|
|
|
if name in self._inspect:
|
|
|
|
raise Exception("Multiple files with same stem %s" % name)
|
2019-05-11 22:55:30 +00:00
|
|
|
with sqlite3.connect("file:{}?mode=ro".format(path), uri=True) as conn:
|
|
|
|
self.prepare_connection(conn)
|
|
|
|
self._inspect[name] = {
|
|
|
|
"hash": inspect_hash(path),
|
|
|
|
"file": str(path),
|
|
|
|
"size": path.stat().st_size,
|
|
|
|
"views": inspect_views(conn),
|
|
|
|
"tables": inspect_tables(
|
|
|
|
conn, (self.metadata("databases") or {}).get(name, {})
|
|
|
|
),
|
|
|
|
}
|
2017-11-13 15:20:02 +00:00
|
|
|
return self._inspect
|
2017-11-10 19:05:57 +00:00
|
|
|
|
2018-04-14 11:27:06 +00:00
|
|
|
def register_custom_units(self):
|
|
|
|
"Register any custom units defined in the metadata.json with Pint"
|
2018-08-13 14:56:50 +00:00
|
|
|
for unit in self.metadata("custom_units") or []:
|
2018-04-14 11:27:06 +00:00
|
|
|
ureg.define(unit)
|
|
|
|
|
2018-05-02 08:46:54 +00:00
|
|
|
def versions(self):
|
2018-05-13 12:55:15 +00:00
|
|
|
conn = sqlite3.connect(":memory:")
|
2018-05-02 08:46:54 +00:00
|
|
|
self.prepare_connection(conn)
|
2018-05-13 12:55:15 +00:00
|
|
|
sqlite_version = conn.execute("select sqlite_version()").fetchone()[0]
|
2018-05-02 08:46:54 +00:00
|
|
|
sqlite_extensions = {}
|
|
|
|
for extension, testsql, hasversion in (
|
2018-05-13 12:55:15 +00:00
|
|
|
("json1", "SELECT json('{}')", False),
|
|
|
|
("spatialite", "SELECT spatialite_version()", True),
|
2018-05-02 08:46:54 +00:00
|
|
|
):
|
|
|
|
try:
|
|
|
|
result = conn.execute(testsql)
|
|
|
|
if hasversion:
|
|
|
|
sqlite_extensions[extension] = result.fetchone()[0]
|
|
|
|
else:
|
|
|
|
sqlite_extensions[extension] = None
|
2019-04-13 19:20:10 +00:00
|
|
|
except Exception:
|
2018-05-02 08:46:54 +00:00
|
|
|
pass
|
2018-05-11 13:19:25 +00:00
|
|
|
# Figure out supported FTS versions
|
|
|
|
fts_versions = []
|
2018-05-13 12:55:15 +00:00
|
|
|
for fts in ("FTS5", "FTS4", "FTS3"):
|
2018-05-11 13:19:25 +00:00
|
|
|
try:
|
|
|
|
conn.execute(
|
2018-05-23 17:43:34 +00:00
|
|
|
"CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(fts=fts)
|
2018-05-11 13:19:25 +00:00
|
|
|
)
|
|
|
|
fts_versions.append(fts)
|
|
|
|
except sqlite3.OperationalError:
|
|
|
|
continue
|
2018-06-17 20:14:55 +00:00
|
|
|
datasette_version = {"version": __version__}
|
|
|
|
if self.version_note:
|
|
|
|
datasette_version["note"] = self.version_note
|
2018-05-02 08:46:54 +00:00
|
|
|
return {
|
2018-05-13 12:55:15 +00:00
|
|
|
"python": {
|
2019-05-04 02:15:14 +00:00
|
|
|
"version": ".".join(map(str, sys.version_info[:3])),
|
|
|
|
"full": sys.version,
|
2018-05-02 08:46:54 +00:00
|
|
|
},
|
2018-06-17 20:14:55 +00:00
|
|
|
"datasette": datasette_version,
|
2018-05-13 12:55:15 +00:00
|
|
|
"sqlite": {
|
|
|
|
"version": sqlite_version,
|
|
|
|
"fts_versions": fts_versions,
|
|
|
|
"extensions": sqlite_extensions,
|
2019-01-11 00:44:37 +00:00
|
|
|
"compile_options": [
|
|
|
|
r[0] for r in conn.execute("pragma compile_options;").fetchall()
|
|
|
|
],
|
2018-05-02 08:46:54 +00:00
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2019-01-26 20:01:16 +00:00
|
|
|
def plugins(self, show_all=False):
|
|
|
|
ps = list(get_plugins(pm))
|
|
|
|
if not show_all:
|
|
|
|
ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS]
|
2018-05-13 13:06:02 +00:00
|
|
|
return [
|
|
|
|
{
|
|
|
|
"name": p["name"],
|
|
|
|
"static": p["static_path"] is not None,
|
|
|
|
"templates": p["templates_path"] is not None,
|
|
|
|
"version": p.get("version"),
|
|
|
|
}
|
2019-01-26 20:01:16 +00:00
|
|
|
for p in ps
|
2018-05-13 13:06:02 +00:00
|
|
|
]
|
|
|
|
|
2019-04-07 02:56:07 +00:00
|
|
|
def table_metadata(self, database, table):
|
|
|
|
"Fetch table-specific metadata."
|
2019-05-04 02:15:14 +00:00
|
|
|
return (
|
|
|
|
(self.metadata("databases") or {})
|
|
|
|
.get(database, {})
|
|
|
|
.get("tables", {})
|
|
|
|
.get(table, {})
|
2019-04-07 02:56:07 +00:00
|
|
|
)
|
|
|
|
|
2019-04-07 01:58:51 +00:00
|
|
|
async def table_columns(self, db_name, table):
|
|
|
|
return await self.execute_against_connection_in_thread(
|
|
|
|
db_name, lambda conn: table_columns(conn, table)
|
|
|
|
)
|
|
|
|
|
2019-04-07 02:56:07 +00:00
|
|
|
async def foreign_keys_for_table(self, database, table):
|
|
|
|
return await self.execute_against_connection_in_thread(
|
|
|
|
database, lambda conn: get_outbound_foreign_keys(conn, table)
|
|
|
|
)
|
|
|
|
|
|
|
|
async def label_column_for_table(self, db_name, table):
|
2019-05-04 02:15:14 +00:00
|
|
|
explicit_label_column = self.table_metadata(db_name, table).get("label_column")
|
2019-04-07 02:56:07 +00:00
|
|
|
if explicit_label_column:
|
|
|
|
return explicit_label_column
|
|
|
|
# If a table has two columns, one of which is ID, then label_column is the other one
|
|
|
|
column_names = await self.table_columns(db_name, table)
|
2019-05-04 02:15:14 +00:00
|
|
|
if column_names and len(column_names) == 2 and "id" in column_names:
|
2019-04-07 02:56:07 +00:00
|
|
|
return [c for c in column_names if c != "id"][0]
|
|
|
|
# Couldn't find a label:
|
|
|
|
return None
|
|
|
|
|
2019-03-31 18:02:22 +00:00
|
|
|
async def execute_against_connection_in_thread(self, db_name, fn):
|
|
|
|
def in_thread():
|
2018-05-25 00:15:37 +00:00
|
|
|
conn = getattr(connections, db_name, None)
|
|
|
|
if not conn:
|
2019-03-31 23:51:52 +00:00
|
|
|
db = self.databases[db_name]
|
|
|
|
if db.is_memory:
|
2019-03-14 23:42:38 +00:00
|
|
|
conn = sqlite3.connect(":memory:")
|
|
|
|
else:
|
2019-03-17 23:25:15 +00:00
|
|
|
# mode=ro or immutable=1?
|
2019-03-31 23:51:52 +00:00
|
|
|
if db.is_mutable:
|
2019-03-17 23:25:15 +00:00
|
|
|
qs = "mode=ro"
|
2019-03-31 23:51:52 +00:00
|
|
|
else:
|
|
|
|
qs = "immutable=1"
|
2019-03-14 23:42:38 +00:00
|
|
|
conn = sqlite3.connect(
|
2019-03-31 23:51:52 +00:00
|
|
|
"file:{}?{}".format(db.path, qs),
|
2019-03-14 23:42:38 +00:00
|
|
|
uri=True,
|
|
|
|
check_same_thread=False,
|
|
|
|
)
|
2018-05-25 00:15:37 +00:00
|
|
|
self.prepare_connection(conn)
|
|
|
|
setattr(connections, db_name, conn)
|
2019-03-31 18:02:22 +00:00
|
|
|
return fn(conn)
|
2018-05-25 00:15:37 +00:00
|
|
|
|
2019-05-04 02:15:14 +00:00
|
|
|
return await asyncio.get_event_loop().run_in_executor(self.executor, in_thread)
|
2019-03-31 18:02:22 +00:00
|
|
|
|
|
|
|
async def execute(
|
|
|
|
self,
|
|
|
|
db_name,
|
|
|
|
sql,
|
|
|
|
params=None,
|
|
|
|
truncate=False,
|
|
|
|
custom_time_limit=None,
|
|
|
|
page_size=None,
|
Extract facet code out into a new plugin hook, closes #427 (#445)
Datasette previously only supported one type of faceting: exact column value counting.
With this change, faceting logic is extracted out into one or more separate classes which can implement other patterns of faceting - this is discussed in #427, but potential upcoming facet types include facet-by-date, facet-by-JSON-array, facet-by-many-2-many and more.
A new plugin hook, register_facet_classes, can be used by plugins to add in additional facet classes.
Each class must implement two methods: suggest(), which scans columns in the table to decide if they might be worth suggesting for faceting, and facet_results(), which executes the facet operation and returns results ready to be displayed in the UI.
2019-05-03 00:11:26 +00:00
|
|
|
log_sql_errors=True,
|
2019-03-31 18:02:22 +00:00
|
|
|
):
|
|
|
|
"""Executes sql against db_name in a thread"""
|
|
|
|
page_size = page_size or self.page_size
|
|
|
|
|
|
|
|
def sql_operation_in_thread(conn):
|
2018-05-25 00:15:37 +00:00
|
|
|
time_limit_ms = self.sql_time_limit_ms
|
|
|
|
if custom_time_limit and custom_time_limit < time_limit_ms:
|
|
|
|
time_limit_ms = custom_time_limit
|
|
|
|
|
|
|
|
with sqlite_timelimit(conn, time_limit_ms):
|
|
|
|
try:
|
|
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute(sql, params or {})
|
|
|
|
max_returned_rows = self.max_returned_rows
|
|
|
|
if max_returned_rows == page_size:
|
|
|
|
max_returned_rows += 1
|
|
|
|
if max_returned_rows and truncate:
|
|
|
|
rows = cursor.fetchmany(max_returned_rows + 1)
|
|
|
|
truncated = len(rows) > max_returned_rows
|
|
|
|
rows = rows[:max_returned_rows]
|
|
|
|
else:
|
|
|
|
rows = cursor.fetchall()
|
|
|
|
truncated = False
|
|
|
|
except sqlite3.OperationalError as e:
|
2019-05-04 02:15:14 +00:00
|
|
|
if e.args == ("interrupted",):
|
Extract facet code out into a new plugin hook, closes #427 (#445)
Datasette previously only supported one type of faceting: exact column value counting.
With this change, faceting logic is extracted out into one or more separate classes which can implement other patterns of faceting - this is discussed in #427, but potential upcoming facet types include facet-by-date, facet-by-JSON-array, facet-by-many-2-many and more.
A new plugin hook, register_facet_classes, can be used by plugins to add in additional facet classes.
Each class must implement two methods: suggest(), which scans columns in the table to decide if they might be worth suggesting for faceting, and facet_results(), which executes the facet operation and returns results ready to be displayed in the UI.
2019-05-03 00:11:26 +00:00
|
|
|
raise InterruptedError(e, sql, params)
|
|
|
|
if log_sql_errors:
|
|
|
|
print(
|
|
|
|
"ERROR: conn={}, sql = {}, params = {}: {}".format(
|
|
|
|
conn, repr(sql), params, e
|
|
|
|
)
|
2018-05-25 00:15:37 +00:00
|
|
|
)
|
|
|
|
raise
|
|
|
|
|
|
|
|
if truncate:
|
|
|
|
return Results(rows, truncated, cursor.description)
|
|
|
|
|
|
|
|
else:
|
|
|
|
return Results(rows, False, cursor.description)
|
|
|
|
|
2019-05-11 19:06:22 +00:00
|
|
|
with trace("sql", database=db_name, sql=sql.strip(), params=params):
|
2019-04-21 05:28:15 +00:00
|
|
|
results = await self.execute_against_connection_in_thread(
|
|
|
|
db_name, sql_operation_in_thread
|
|
|
|
)
|
|
|
|
return results
|
2018-05-25 00:15:37 +00:00
|
|
|
|
2019-05-01 23:01:56 +00:00
|
|
|
def register_renderers(self):
|
|
|
|
""" Register output renderers which output data in custom formats. """
|
|
|
|
# Built-in renderers
|
2019-05-04 02:15:14 +00:00
|
|
|
self.renderers["json"] = json_renderer
|
2019-05-01 23:01:56 +00:00
|
|
|
|
|
|
|
# Hooks
|
|
|
|
hook_renderers = []
|
|
|
|
for hook in pm.hook.register_output_renderer(datasette=self):
|
|
|
|
if type(hook) == list:
|
|
|
|
hook_renderers += hook
|
|
|
|
else:
|
|
|
|
hook_renderers.append(hook)
|
|
|
|
|
|
|
|
for renderer in hook_renderers:
|
2019-05-04 02:15:14 +00:00
|
|
|
self.renderers[renderer["extension"]] = renderer["callback"]
|
2019-05-01 23:01:56 +00:00
|
|
|
|
2017-11-10 19:05:57 +00:00
|
|
|
def app(self):
|
2019-04-21 05:28:15 +00:00
|
|
|
class TracingSanic(Sanic):
|
|
|
|
async def handle_request(self, request, write_callback, stream_callback):
|
|
|
|
if request.args.get("_trace"):
|
|
|
|
request["traces"] = []
|
2019-05-01 23:16:15 +00:00
|
|
|
request["trace_start"] = time.time()
|
2019-04-21 05:28:15 +00:00
|
|
|
with capture_traces(request["traces"]):
|
2019-05-05 17:58:35 +00:00
|
|
|
await super().handle_request(
|
2019-05-04 02:15:14 +00:00
|
|
|
request, write_callback, stream_callback
|
|
|
|
)
|
2019-04-21 05:28:15 +00:00
|
|
|
else:
|
2019-05-05 17:58:35 +00:00
|
|
|
await super().handle_request(
|
2019-05-04 02:15:14 +00:00
|
|
|
request, write_callback, stream_callback
|
|
|
|
)
|
2019-04-21 05:28:15 +00:00
|
|
|
|
|
|
|
app = TracingSanic(__name__)
|
2018-05-13 12:55:15 +00:00
|
|
|
default_templates = str(app_root / "datasette" / "templates")
|
2018-04-19 05:50:27 +00:00
|
|
|
template_paths = []
|
2017-11-30 16:05:01 +00:00
|
|
|
if self.template_dir:
|
2018-04-19 05:50:27 +00:00
|
|
|
template_paths.append(self.template_dir)
|
2018-05-13 12:55:15 +00:00
|
|
|
template_paths.extend(
|
|
|
|
[
|
|
|
|
plugin["templates_path"]
|
|
|
|
for plugin in get_plugins(pm)
|
|
|
|
if plugin["templates_path"]
|
|
|
|
]
|
|
|
|
)
|
2018-04-19 05:50:27 +00:00
|
|
|
template_paths.append(default_templates)
|
2018-05-13 12:55:15 +00:00
|
|
|
template_loader = ChoiceLoader(
|
|
|
|
[
|
|
|
|
FileSystemLoader(template_paths),
|
|
|
|
# Support {% extends "default:table.html" %}:
|
|
|
|
PrefixLoader(
|
|
|
|
{"default": FileSystemLoader(default_templates)}, delimiter=":"
|
|
|
|
),
|
|
|
|
]
|
2017-11-10 19:05:57 +00:00
|
|
|
)
|
2018-05-13 12:55:15 +00:00
|
|
|
self.jinja_env = Environment(loader=template_loader, autoescape=True)
|
|
|
|
self.jinja_env.filters["escape_css_string"] = escape_css_string
|
|
|
|
self.jinja_env.filters["quote_plus"] = lambda u: urllib.parse.quote_plus(u)
|
|
|
|
self.jinja_env.filters["escape_sqlite"] = escape_sqlite
|
|
|
|
self.jinja_env.filters["to_css_class"] = to_css_class
|
2019-04-13 19:20:10 +00:00
|
|
|
# pylint: disable=no-member
|
2018-04-16 00:56:15 +00:00
|
|
|
pm.hook.prepare_jinja2_environment(env=self.jinja_env)
|
2019-05-01 23:01:56 +00:00
|
|
|
|
|
|
|
self.register_renderers()
|
|
|
|
# Generate a regex snippet to match all registered renderer file extensions
|
|
|
|
renderer_regex = "|".join(r"\." + key for key in self.renderers.keys())
|
|
|
|
|
2018-12-29 02:22:27 +00:00
|
|
|
app.add_route(IndexView.as_view(self), r"/<as_format:(\.jsono?)?$>")
|
2017-11-10 19:05:57 +00:00
|
|
|
# TODO: /favicon.ico and /-/static/ deserve far-future cache expires
|
2018-05-13 12:55:15 +00:00
|
|
|
app.add_route(favicon, "/favicon.ico")
|
|
|
|
app.static("/-/static/", str(app_root / "datasette" / "static"))
|
2017-12-03 16:33:36 +00:00
|
|
|
for path, dirname in self.static_mounts:
|
|
|
|
app.static(path, dirname)
|
2018-04-18 02:32:48 +00:00
|
|
|
# Mount any plugin static/ directories
|
2018-04-19 05:24:48 +00:00
|
|
|
for plugin in get_plugins(pm):
|
2018-05-13 12:55:15 +00:00
|
|
|
if plugin["static_path"]:
|
|
|
|
modpath = "/-/static-plugins/{}/".format(plugin["name"])
|
|
|
|
app.static(modpath, plugin["static_path"])
|
2018-04-19 05:24:48 +00:00
|
|
|
app.add_route(
|
2018-05-13 12:55:15 +00:00
|
|
|
JsonDataView.as_view(self, "inspect.json", self.inspect),
|
2018-12-29 02:22:27 +00:00
|
|
|
r"/-/inspect<as_format:(\.json)?$>",
|
2018-04-19 05:24:48 +00:00
|
|
|
)
|
|
|
|
app.add_route(
|
2018-08-13 14:56:50 +00:00
|
|
|
JsonDataView.as_view(self, "metadata.json", lambda: self._metadata),
|
2018-12-29 02:22:27 +00:00
|
|
|
r"/-/metadata<as_format:(\.json)?$>",
|
2018-04-19 05:24:48 +00:00
|
|
|
)
|
2018-05-02 08:46:54 +00:00
|
|
|
app.add_route(
|
2018-05-13 12:55:15 +00:00
|
|
|
JsonDataView.as_view(self, "versions.json", self.versions),
|
2018-12-29 02:22:27 +00:00
|
|
|
r"/-/versions<as_format:(\.json)?$>",
|
2018-05-02 08:46:54 +00:00
|
|
|
)
|
2018-04-19 05:24:48 +00:00
|
|
|
app.add_route(
|
2018-05-13 13:06:02 +00:00
|
|
|
JsonDataView.as_view(self, "plugins.json", self.plugins),
|
2018-12-29 02:22:27 +00:00
|
|
|
r"/-/plugins<as_format:(\.json)?$>",
|
2018-04-19 05:24:48 +00:00
|
|
|
)
|
2018-05-18 06:16:28 +00:00
|
|
|
app.add_route(
|
2018-08-11 20:06:45 +00:00
|
|
|
JsonDataView.as_view(self, "config.json", lambda: self._config),
|
2018-12-29 02:22:27 +00:00
|
|
|
r"/-/config<as_format:(\.json)?$>",
|
2018-05-18 06:16:28 +00:00
|
|
|
)
|
2017-11-10 19:05:57 +00:00
|
|
|
app.add_route(
|
2018-12-29 02:22:27 +00:00
|
|
|
DatabaseDownload.as_view(self), r"/<db_name:[^/]+?><as_db:(\.db)$>"
|
2017-11-10 19:05:57 +00:00
|
|
|
)
|
|
|
|
app.add_route(
|
2019-05-01 23:01:56 +00:00
|
|
|
DatabaseView.as_view(self),
|
2019-05-04 02:15:14 +00:00
|
|
|
r"/<db_name:[^/]+?><as_format:(" + renderer_regex + r"|.jsono|\.csv)?$>",
|
2017-11-10 19:05:57 +00:00
|
|
|
)
|
|
|
|
app.add_route(
|
2019-05-04 02:15:14 +00:00
|
|
|
TableView.as_view(self), r"/<db_name:[^/]+>/<table_and_format:[^/]+?$>"
|
2017-11-10 19:05:57 +00:00
|
|
|
)
|
|
|
|
app.add_route(
|
|
|
|
RowView.as_view(self),
|
2019-05-04 02:15:14 +00:00
|
|
|
r"/<db_name:[^/]+>/<table:[^/]+?>/<pk_path:[^/]+?><as_format:("
|
|
|
|
+ renderer_regex
|
|
|
|
+ r")?$>",
|
2017-11-10 19:05:57 +00:00
|
|
|
)
|
2018-04-14 11:27:06 +00:00
|
|
|
self.register_custom_units()
|
2019-04-21 05:28:15 +00:00
|
|
|
|
2018-06-21 15:13:07 +00:00
|
|
|
# On 404 with a trailing slash redirect to path without that slash:
|
2019-04-13 19:20:10 +00:00
|
|
|
# pylint: disable=unused-variable
|
2018-06-21 15:13:07 +00:00
|
|
|
@app.middleware("response")
|
|
|
|
def redirect_on_404_with_trailing_slash(request, original_response):
|
|
|
|
if original_response.status == 404 and request.path.endswith("/"):
|
|
|
|
path = request.path.rstrip("/")
|
|
|
|
if request.query_string:
|
|
|
|
path = "{}?{}".format(path, request.query_string)
|
|
|
|
return response.redirect(path)
|
2018-04-14 11:27:06 +00:00
|
|
|
|
2019-04-21 05:28:15 +00:00
|
|
|
@app.middleware("response")
|
2019-04-21 17:41:16 +00:00
|
|
|
async def add_traces_to_response(request, response):
|
|
|
|
if request.get("traces") is None:
|
|
|
|
return
|
2019-05-11 19:06:22 +00:00
|
|
|
traces = request["traces"]
|
|
|
|
trace_info = {
|
|
|
|
"request_duration_ms": 1000 * (time.time() - request["trace_start"]),
|
|
|
|
"sum_trace_duration_ms": sum(t["duration_ms"] for t in traces),
|
|
|
|
"num_traces": len(traces),
|
|
|
|
"traces": traces,
|
2019-05-01 23:16:15 +00:00
|
|
|
}
|
2019-05-04 02:15:14 +00:00
|
|
|
if "text/html" in response.content_type and b"</body>" in response.body:
|
2019-05-11 19:06:22 +00:00
|
|
|
extra = json.dumps(trace_info, indent=2)
|
2019-04-21 17:41:16 +00:00
|
|
|
extra_html = "<pre>{}</pre></body>".format(extra).encode("utf8")
|
|
|
|
response.body = response.body.replace(b"</body>", extra_html)
|
|
|
|
elif "json" in response.content_type and response.body.startswith(b"{"):
|
2019-04-21 18:20:20 +00:00
|
|
|
data = json.loads(response.body.decode("utf8"))
|
2019-05-11 19:06:22 +00:00
|
|
|
if "_trace" not in data:
|
|
|
|
data["_trace"] = trace_info
|
2019-04-21 17:41:16 +00:00
|
|
|
response.body = json.dumps(data).encode("utf8")
|
2019-04-21 05:28:15 +00:00
|
|
|
|
2018-04-13 18:17:22 +00:00
|
|
|
@app.exception(Exception)
|
|
|
|
def on_exception(request, exception):
|
|
|
|
title = None
|
2018-05-28 21:24:19 +00:00
|
|
|
help = None
|
2018-04-13 18:17:22 +00:00
|
|
|
if isinstance(exception, NotFound):
|
|
|
|
status = 404
|
|
|
|
info = {}
|
|
|
|
message = exception.args[0]
|
2018-04-14 16:11:16 +00:00
|
|
|
elif isinstance(exception, InvalidUsage):
|
|
|
|
status = 405
|
|
|
|
info = {}
|
|
|
|
message = exception.args[0]
|
2018-04-13 18:17:22 +00:00
|
|
|
elif isinstance(exception, DatasetteError):
|
|
|
|
status = exception.status
|
|
|
|
info = exception.error_dict
|
|
|
|
message = exception.message
|
2018-05-28 21:24:19 +00:00
|
|
|
if exception.messagge_is_html:
|
|
|
|
message = Markup(message)
|
2018-04-13 18:17:22 +00:00
|
|
|
title = exception.title
|
|
|
|
else:
|
|
|
|
status = 500
|
|
|
|
info = {}
|
|
|
|
message = str(exception)
|
2018-04-14 13:17:20 +00:00
|
|
|
traceback.print_exc()
|
2018-05-13 12:55:15 +00:00
|
|
|
templates = ["500.html"]
|
2018-04-13 18:17:22 +00:00
|
|
|
if status != 500:
|
2018-05-13 12:55:15 +00:00
|
|
|
templates = ["{}.html".format(status)] + templates
|
|
|
|
info.update(
|
|
|
|
{"ok": False, "error": message, "status": status, "title": title}
|
|
|
|
)
|
2018-06-29 12:52:51 +00:00
|
|
|
if request is not None and request.path.split("?")[0].endswith(".json"):
|
2019-05-05 11:59:45 +00:00
|
|
|
r = response.json(info, status=status)
|
2018-05-13 12:55:15 +00:00
|
|
|
|
2018-04-13 18:17:22 +00:00
|
|
|
else:
|
|
|
|
template = self.jinja_env.select_template(templates)
|
2019-05-05 11:59:45 +00:00
|
|
|
r = response.html(template.render(info), status=status)
|
|
|
|
if self.cors:
|
|
|
|
r.headers["Access-Control-Allow-Origin"] = "*"
|
|
|
|
return r
|
2018-04-13 18:17:22 +00:00
|
|
|
|
2019-05-02 00:39:39 +00:00
|
|
|
# First time server starts up, calculate table counts for immutable databases
|
|
|
|
@app.listener("before_server_start")
|
|
|
|
async def setup_db(app, loop):
|
|
|
|
for dbname, database in self.databases.items():
|
|
|
|
if not database.is_mutable:
|
2019-05-04 02:15:14 +00:00
|
|
|
await database.table_counts(limit=60 * 60 * 1000)
|
2019-05-02 00:39:39 +00:00
|
|
|
|
2017-11-10 19:05:57 +00:00
|
|
|
return app
|