diff --git a/.travis.yml b/.travis.yml
index 1553ef1e..40799d84 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -6,6 +6,7 @@ python:
- "3.6"
- "3.7"
- "3.5"
+ - "3.8-dev"
# Executed for 3.5 AND 3.5 as the first "test" stage:
script:
@@ -23,12 +24,23 @@ jobs:
- stage: deploy latest.datasette.io
if: branch = master AND type = push
script:
- - pip install .
+ - pip install .[test]
- npm install -g now
- python tests/fixtures.py fixtures.db fixtures.json
- export ALIAS=`echo $TRAVIS_COMMIT | cut -c 1-7`
- datasette publish nowv1 fixtures.db -m fixtures.json --token=$NOW_TOKEN --branch=$TRAVIS_COMMIT --version-note=$TRAVIS_COMMIT --name=datasette-latest-$ALIAS --alias=latest.datasette.io --alias=$ALIAS.datasette.io
- stage: release tagged version
+ if: tag IS present
+ python: 3.6
+ deploy:
+ - provider: pypi
+ user: simonw
+ distributions: bdist_wheel
+ password: ${PYPI_PASSWORD}
+ on:
+ branch: master
+ tags: true
+ - stage: publish docker image
if: tag IS present
python: 3.6
script:
@@ -42,11 +54,3 @@ jobs:
- docker build -f Dockerfile -t $REPO:$TRAVIS_TAG .
- docker tag $REPO:$TRAVIS_TAG $REPO:latest
- docker push $REPO
- deploy:
- - provider: pypi
- user: simonw
- distributions: bdist_wheel
- password: ${PYPI_PASSWORD}
- on:
- branch: master
- tags: true
diff --git a/README.md b/README.md
index 638dcd1c..9f85f1ba 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
# Datasette
[](https://pypi.org/project/datasette/)
+[](https://pypi.org/project/datasette/)
[](https://travis-ci.org/simonw/datasette)
[](http://datasette.readthedocs.io/en/latest/?badge=latest)
[](https://github.com/simonw/datasette/blob/master/LICENSE)
@@ -20,6 +21,12 @@ Datasette is aimed at data journalists, museum curators, archivists, local gover
## News
+ * 18th October 2019: [Datasette 0.30](https://datasette.readthedocs.io/en/stable/changelog.html#v0-30)
+ * 13th July 2019: [Single sign-on against GitHub using ASGI middleware](https://simonwillison.net/2019/Jul/14/sso-asgi/) talks about the implementation of [datasette-auth-github](https://github.com/simonw/datasette-auth-github) in more detail.
+ * 7th July 2019: [Datasette 0.29](https://datasette.readthedocs.io/en/stable/changelog.html#v0-29) - ASGI, new plugin hooks, facet by date and much, much more...
+ * [datasette-auth-github](https://github.com/simonw/datasette-auth-github) - a new plugin for Datasette 0.29 that lets you require users to authenticate against GitHub before accessing your Datasette instance. You can whitelist specific users, or you can restrict access to members of specific GitHub organizations or teams.
+ * [datasette-cors](https://github.com/simonw/datasette-cors) - a plugin that lets you configure CORS access from a list of domains (or a set of domain wildcards) so you can make JavaScript calls to a Datasette instance from a specific set of other hosts.
+ * 23rd June 2019: [Porting Datasette to ASGI, and Turtles all the way down](https://simonwillison.net/2019/Jun/23/datasette-asgi/)
* 21st May 2019: The anonymized raw data from [the Stack Overflow Developer Survey 2019](https://stackoverflow.blog/2019/05/21/public-data-release-of-stack-overflows-2019-developer-survey/) has been [published in partnership with Glitch](https://glitch.com/culture/discover-insights-explore-developer-survey-results-2019/), powered by Datasette.
* 19th May 2019: [Datasette 0.28](https://datasette.readthedocs.io/en/stable/changelog.html#v0-28) - a salmagundi of new features!
* No longer immutable! Datasette now supports [databases that change](https://datasette.readthedocs.io/en/stable/changelog.html#supporting-databases-that-change).
@@ -82,26 +89,31 @@ Now visiting http://localhost:8001/History/downloads will show you a web interfa
## datasette serve options
- $ datasette serve --help
-
Usage: datasette serve [OPTIONS] [FILES]...
Serve up specified SQLite database files with a web UI
Options:
-i, --immutable PATH Database files to open in immutable mode
- -h, --host TEXT host for server, defaults to 127.0.0.1
- -p, --port INTEGER port for server, defaults to 8001
+ -h, --host TEXT Host for server. Defaults to 127.0.0.1 which means
+ only connections from the local machine will be
+ allowed. Use 0.0.0.0 to listen to all IPs and
+ allow access from other machines.
+ -p, --port INTEGER Port for server, defaults to 8001
--debug Enable debug mode - useful for development
- --reload Automatically reload if database or code change detected -
- useful for development
- --cors Enable CORS by serving Access-Control-Allow-Origin: *
+ --reload Automatically reload if database or code change
+ detected - useful for development
+ --cors Enable CORS by serving Access-Control-Allow-
+ Origin: *
--load-extension PATH Path to a SQLite extension to load
- --inspect-file TEXT Path to JSON file created using "datasette inspect"
- -m, --metadata FILENAME Path to JSON file containing license/source metadata
+ --inspect-file TEXT Path to JSON file created using "datasette
+ inspect"
+ -m, --metadata FILENAME Path to JSON file containing license/source
+ metadata
--template-dir DIRECTORY Path to directory containing custom templates
--plugins-dir DIRECTORY Path to directory containing custom plugins
- --static STATIC MOUNT mountpoint:path-to-directory for serving static files
+ --static STATIC MOUNT mountpoint:path-to-directory for serving static
+ files
--memory Make :memory: database available
--config CONFIG Set config option using configname:value
datasette.readthedocs.io/en/latest/config.html
diff --git a/datasette/__main__.py b/datasette/__main__.py
new file mode 100644
index 00000000..4adef844
--- /dev/null
+++ b/datasette/__main__.py
@@ -0,0 +1,4 @@
+from datasette.cli import cli
+
+if __name__ == "__main__":
+ cli()
diff --git a/datasette/_version.py b/datasette/_version.py
index a12f24aa..5783f30f 100644
--- a/datasette/_version.py
+++ b/datasette/_version.py
@@ -409,7 +409,7 @@ def render_pep440_old(pieces):
The ".dev0" means dirty.
- Eexceptions:
+ Exceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
diff --git a/datasette/app.py b/datasette/app.py
index 2ef7da41..203e0991 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -1,11 +1,9 @@
import asyncio
import collections
import hashlib
-import json
import os
import sys
import threading
-import time
import traceback
import urllib.parse
from concurrent import futures
@@ -14,10 +12,8 @@ from pathlib import Path
import click
from markupsafe import Markup
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
-from sanic import Sanic, response
-from sanic.exceptions import InvalidUsage, NotFound
-from .views.base import DatasetteError, ureg
+from .views.base import DatasetteError, ureg, AsgiRouter
from .views.database import DatabaseDownload, DatabaseView
from .views.index import IndexView
from .views.special import JsonDataView
@@ -36,7 +32,16 @@ from .utils import (
sqlite_timelimit,
to_css_class,
)
-from .tracer import capture_traces, trace
+from .utils.asgi import (
+ AsgiLifespan,
+ NotFound,
+ asgi_static,
+ asgi_send,
+ asgi_send_html,
+ asgi_send_json,
+ asgi_send_redirect,
+)
+from .tracer import trace, AsgiTracer
from .plugins import pm, DEFAULT_PLUGINS
from .version import __version__
@@ -126,8 +131,8 @@ CONFIG_OPTIONS = (
DEFAULT_CONFIG = {option.name: option.default for option in CONFIG_OPTIONS}
-async def favicon(request):
- return response.text("")
+async def favicon(scope, receive, send):
+ await asgi_send(send, "", 200)
class Datasette:
@@ -154,7 +159,7 @@ class Datasette:
self.files = [MEMORY]
elif memory:
self.files = (MEMORY,) + self.files
- self.databases = {}
+ self.databases = collections.OrderedDict()
self.inspect_data = inspect_data
for file in self.files:
path = file
@@ -263,7 +268,21 @@ class Datasette:
)
if plugins is None:
return None
- return plugins.get(plugin_name)
+ plugin_config = plugins.get(plugin_name)
+ # Resolve any $file and $env keys
+ if isinstance(plugin_config, dict):
+ # Create a copy so we don't mutate the version visible at /-/metadata.json
+ plugin_config_copy = dict(plugin_config)
+ for key, value in plugin_config_copy.items():
+ if isinstance(value, dict):
+ if list(value.keys()) == ["$env"]:
+ plugin_config_copy[key] = os.environ.get(
+ list(value.values())[0]
+ )
+ elif list(value.keys()) == ["$file"]:
+ plugin_config_copy[key] = open(list(value.values())[0]).read()
+ return plugin_config_copy
+ return plugin_config
def app_css_hash(self):
if not hasattr(self, "_app_css_hash"):
@@ -413,6 +432,7 @@ class Datasette:
"full": sys.version,
},
"datasette": datasette_version,
+ "asgi": "3.0",
"sqlite": {
"version": sqlite_version,
"fts_versions": fts_versions,
@@ -437,6 +457,15 @@ class Datasette:
for p in ps
]
+ def threads(self):
+ threads = list(threading.enumerate())
+ return {
+ "num_threads": len(threads),
+ "threads": [
+ {"name": t.name, "ident": t.ident, "daemon": t.daemon} for t in threads
+ ],
+ }
+
def table_metadata(self, database, table):
"Fetch table-specific metadata."
return (
@@ -450,20 +479,7 @@ class Datasette:
def in_thread():
conn = getattr(connections, db_name, None)
if not conn:
- db = self.databases[db_name]
- if db.is_memory:
- conn = sqlite3.connect(":memory:")
- else:
- # mode=ro or immutable=1?
- if db.is_mutable:
- qs = "mode=ro"
- else:
- qs = "immutable=1"
- conn = sqlite3.connect(
- "file:{}?{}".format(db.path, qs),
- uri=True,
- check_same_thread=False,
- )
+ conn = self.databases[db_name].connect()
self.prepare_connection(conn)
setattr(connections, db_name, conn)
return fn(conn)
@@ -543,21 +559,7 @@ class Datasette:
self.renderers[renderer["extension"]] = renderer["callback"]
def app(self):
- class TracingSanic(Sanic):
- async def handle_request(self, request, write_callback, stream_callback):
- if request.args.get("_trace"):
- request["traces"] = []
- request["trace_start"] = time.time()
- with capture_traces(request["traces"]):
- await super().handle_request(
- request, write_callback, stream_callback
- )
- else:
- await super().handle_request(
- request, write_callback, stream_callback
- )
-
- app = TracingSanic(__name__)
+ "Returns an ASGI app function that serves the whole of Datasette"
default_templates = str(app_root / "datasette" / "templates")
template_paths = []
if self.template_dir:
@@ -588,134 +590,143 @@ class Datasette:
pm.hook.prepare_jinja2_environment(env=self.jinja_env)
self.register_renderers()
+
+ routes = []
+
+ def add_route(view, regex):
+ routes.append((regex, view))
+
# Generate a regex snippet to match all registered renderer file extensions
renderer_regex = "|".join(r"\." + key for key in self.renderers.keys())
- app.add_route(IndexView.as_view(self), r"/[^/]+?)/(?P
+ home +
+ {{ super() }} +{% endblock %} + {% block content %} -{% for table in database.tables_and_views_truncated %}{{ table.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, ...{% endif %}
+{% for table in database.tables_and_views_truncated %}{{ table.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_and_views_more %}, ...{% endif %}
{% endfor %} {% endblock %} diff --git a/datasette/templates/query.html b/datasette/templates/query.html index b4b759a5..34fa78a5 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -18,9 +18,15 @@ {% block body_class %}query db-{{ database|to_css_class }}{% endblock %} -{% block content %} - +{% block nav %} ++ home / + {{ database }} +
+ {{ super() }} +{% endblock %} +{% block content %}{% if query %}{{ query.sql }}{% endif %}
+ {% if query %}{{ query.sql }}{% endif %}
{% endif %}
{% else %}
@@ -43,7 +49,10 @@
{% endfor %} {% endif %} - +
+ + +
{% if display_rows %} diff --git a/datasette/templates/row.html b/datasette/templates/row.html index baffaf96..5703900d 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -15,16 +15,23 @@ {% block body_class %}row db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %} -{% block content %} - +{% block nav %} ++ home / + {{ database }} / + {{ table }} +
+ {{ super() }} +{% endblock %} +{% block content %}This data as {% for name, url in renderers.items() %}{{ name }}{{ ", " if not loop.last }}{% endfor %}
-{% include custom_rows_and_columns_templates %} +{% include custom_table_templates %} {% if foreign_key_tables %}+ home / + {{ database }} +
+ {{ super() }} +{% endblock %} + {% block content %} -{{ table_definition }}
+ {{ table_definition }}
{% endif %}
{% if view_definition %}
- {{ view_definition }}
+ {{ view_definition }}
{% endif %}
{% endblock %}
diff --git a/datasette/tracer.py b/datasette/tracer.py
index c6fe0a00..e46a6fda 100644
--- a/datasette/tracer.py
+++ b/datasette/tracer.py
@@ -1,6 +1,7 @@
import asyncio
from contextlib import contextmanager
import time
+import json
import traceback
tracers = {}
@@ -32,15 +33,15 @@ def trace(type, **kwargs):
start = time.time()
yield
end = time.time()
- trace = {
+ trace_info = {
"type": type,
"start": start,
"end": end,
"duration_ms": (end - start) * 1000,
"traceback": traceback.format_list(traceback.extract_stack(limit=6)[:-3]),
}
- trace.update(kwargs)
- tracer.append(trace)
+ trace_info.update(kwargs)
+ tracer.append(trace_info)
@contextmanager
@@ -53,3 +54,77 @@ def capture_traces(tracer):
tracers[task_id] = tracer
yield
del tracers[task_id]
+
+
+class AsgiTracer:
+ # If the body is larger than this we don't attempt to append the trace
+ max_body_bytes = 1024 * 256 # 256 KB
+
+ def __init__(self, app):
+ self.app = app
+
+ async def __call__(self, scope, receive, send):
+ if b"_trace=1" not in scope.get("query_string", b"").split(b"&"):
+ await self.app(scope, receive, send)
+ return
+ trace_start = time.time()
+ traces = []
+
+ accumulated_body = b""
+ size_limit_exceeded = False
+ response_headers = []
+
+ async def wrapped_send(message):
+ nonlocal accumulated_body, size_limit_exceeded, response_headers
+ if message["type"] == "http.response.start":
+ response_headers = message["headers"]
+ await send(message)
+ return
+
+ if message["type"] != "http.response.body" or size_limit_exceeded:
+ await send(message)
+ return
+
+ # Accumulate body until the end or until size is exceeded
+ accumulated_body += message["body"]
+ if len(accumulated_body) > self.max_body_bytes:
+ await send(
+ {
+ "type": "http.response.body",
+ "body": accumulated_body,
+ "more_body": True,
+ }
+ )
+ size_limit_exceeded = True
+ return
+
+ if not message.get("more_body"):
+ # We have all the body - modify it and send the result
+ # TODO: What to do about Content-Type or other cases?
+ trace_info = {
+ "request_duration_ms": 1000 * (time.time() - trace_start),
+ "sum_trace_duration_ms": sum(t["duration_ms"] for t in traces),
+ "num_traces": len(traces),
+ "traces": traces,
+ }
+ try:
+ content_type = [
+ v.decode("utf8")
+ for k, v in response_headers
+ if k.lower() == b"content-type"
+ ][0]
+ except IndexError:
+ content_type = ""
+ if "text/html" in content_type and b"