Compare commits

...

4 commits

Author SHA1 Message Date
Simon Willison
07912ba46d datasette publish now --alias option
You can now use --alias to attempt to alias after you deploy.

Also updated now.json to use version: 1
2019-01-01 21:14:46 -08:00
Simon Willison
77789d0ff4 Bump test dependencies aiohttp/beautifulsoup4 2018-12-30 21:37:42 -08:00
Simon Willison
7e756ad097 Upgrade Sanic to 18.12.0
Had to fix CSV streaming generation to correctly use await.
2018-12-30 21:37:00 -08:00
Simon Willison
0f3b35d4e8 Experimental new table / encoding schema
See https://github.com/django/asgiref/issues/51#issuecomment-450603464
2018-12-30 21:00:54 -08:00
14 changed files with 75 additions and 34 deletions

View file

@ -27,6 +27,7 @@ from .views.table import RowView, TableView
from .utils import ( from .utils import (
InterruptedError, InterruptedError,
Results, Results,
encode_table_name,
escape_css_string, escape_css_string,
escape_sqlite, escape_sqlite,
get_plugins, get_plugins,
@ -465,6 +466,7 @@ class Datasette:
self.jinja_env = Environment(loader=template_loader, autoescape=True) self.jinja_env = Environment(loader=template_loader, autoescape=True)
self.jinja_env.filters["escape_css_string"] = escape_css_string 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["quote_plus"] = lambda u: urllib.parse.quote_plus(u)
self.jinja_env.filters["encode_table_name"] = encode_table_name
self.jinja_env.filters["escape_sqlite"] = escape_sqlite self.jinja_env.filters["escape_sqlite"] = escape_sqlite
self.jinja_env.filters["to_css_class"] = to_css_class self.jinja_env.filters["to_css_class"] = to_css_class
pm.hook.prepare_jinja2_environment(env=self.jinja_env) pm.hook.prepare_jinja2_environment(env=self.jinja_env)

View file

@ -21,7 +21,8 @@ def publish_subcommand(publish):
help="Application name to use when deploying", help="Application name to use when deploying",
) )
@click.option("--force", is_flag=True, help="Pass --force option to now") @click.option("--force", is_flag=True, help="Pass --force option to now")
@click.option("--token", help="Auth token to use for deploy (Now only)") @click.option("--token", help="Auth token to use for deploy")
@click.option("--alias", help="Desired alias e.g. yoursite.now.sh")
@click.option("--spatialite", is_flag=True, help="Enable SpatialLite extension") @click.option("--spatialite", is_flag=True, help="Enable SpatialLite extension")
def now( def now(
files, files,
@ -41,6 +42,7 @@ def publish_subcommand(publish):
name, name,
force, force,
token, token,
alias,
spatialite, spatialite,
): ):
fail_if_publish_binary_not_installed("now", "Zeit Now", "https://zeit.co/now") fail_if_publish_binary_not_installed("now", "Zeit Now", "https://zeit.co/now")
@ -70,11 +72,12 @@ def publish_subcommand(publish):
"source_url": source_url, "source_url": source_url,
}, },
): ):
open("now.json", "w").write(json.dumps({ now_json = {
"features": { "version": 1
"cloud": "v1"
} }
})) if alias:
now_json["alias"] = alias
open("now.json", "w").write(json.dumps(now_json))
args = [] args = []
if force: if force:
args.append("--force") args.append("--force")
@ -84,3 +87,5 @@ def publish_subcommand(publish):
call(["now"] + args) call(["now"] + args)
else: else:
call("now") call("now")
if alias:
call(["now", "alias"])

View file

@ -27,7 +27,7 @@
{% for table in tables %} {% for table in tables %}
{% if show_hidden or not table.hidden %} {% if show_hidden or not table.hidden %}
<div class="db-table"> <div class="db-table">
<h2><a href="/{{ database }}-{{ database_hash }}/{{ table.name|quote_plus }}">{{ table.name }}</a>{% if table.hidden %}<em> (hidden)</em>{% endif %}</h2> <h2><a href="/{{ database }}-{{ database_hash }}/{{ table.name|encode_table_name }}">{{ table.name }}</a>{% if table.hidden %}<em> (hidden)</em>{% endif %}</h2>
<p><em>{% for column in table.columns[:9] %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}{% if table.columns|length > 9 %}...{% endif %}</em></p> <p><em>{% for column in table.columns[:9] %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}{% if table.columns|length > 9 %}...{% endif %}</em></p>
<p>{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}</p> <p>{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}</p>
</div> </div>

View file

@ -21,7 +21,7 @@
{{ "{:,}".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %} {{ "{:,}".format(database.views_count) }} view{% if database.views_count != 1 %}s{% endif %}
{% endif %} {% endif %}
</p> </p>
<p>{% for table in database.tables_truncated %}<a href="{{ database.path }}/{{ table.name|quote_plus }}" title="{{ table.count }} rows">{{ table.name }}</a>{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_more %}, <a href="{{ database.path }}">...</a>{% endif %}</p> <p>{% for table in database.tables_truncated %}<a href="{{ database.path }}/{{ table.name|encode_table_name }}" title="{{ table.count }} rows">{{ table.name }}</a>{% if not loop.last %}, {% endif %}{% endfor %}{% if database.tables_more %}, <a href="{{ database.path }}">...</a>{% endif %}</p>
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}

View file

@ -16,7 +16,7 @@
{% block body_class %}row db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %} {% block body_class %}row db-{{ database|to_css_class }} table-{{ table|to_css_class }}{% endblock %}
{% block content %} {% block content %}
<div class="hd"><a href="/">home</a> / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a> / <a href="/{{ database }}-{{ database_hash }}/{{ table|quote_plus }}">{{ table }}</a></div> <div class="hd"><a href="/">home</a> / <a href="/{{ database }}-{{ database_hash }}">{{ database }}</a> / <a href="/{{ database }}-{{ database_hash }}/{{ table|encode_table_name }}">{{ table }}</a></div>
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_hash[:6] }}">{{ table }}: {{ ', '.join(primary_key_values) }}</a></h1> <h1 style="padding-left: 10px; border-left: 10px solid #{{ database_hash[:6] }}">{{ table }}: {{ ', '.join(primary_key_values) }}</a></h1>
@ -31,7 +31,7 @@
<ul> <ul>
{% for other in foreign_key_tables %} {% for other in foreign_key_tables %}
<li> <li>
<a href="/{{ database }}-{{ database_hash }}/{{ other.other_table|quote_plus }}?{{ other.other_column }}={{ ', '.join(primary_key_values) }}"> <a href="/{{ database }}-{{ database_hash }}/{{ other.other_table|encode_table_name }}?{{ other.other_column }}={{ ', '.join(primary_key_values) }}">
{{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a> {{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %}</a>
from {{ other.other_column }} in {{ other.other_table }} from {{ other.other_column }} in {{ other.other_table }}
</li> </li>

View file

@ -29,7 +29,7 @@
</h3> </h3>
{% endif %} {% endif %}
<form class="filters" action="/{{ database }}-{{ database_hash }}/{{ table|quote_plus }}" method="get"> <form class="filters" action="/{{ database }}-{{ database_hash }}/{{ table|encode_table_name }}" method="get">
{% if supports_search %} {% if supports_search %}
<div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div> <div class="search-row"><label for="_search">Search:</label><input id="_search" type="search" name="_search" value="{{ search }}"></div>
{% endif %} {% endif %}

View file

@ -72,6 +72,23 @@ def urlsafe_components(token):
] ]
_decode_table_name_re = re.compile(r"U\+([\da-h]{4})", re.IGNORECASE)
_encode_table_name_re = re.compile("[{}]".format(''.join(re.escape(c) for c in (
";", "/", "?", ":", "@", "&", "=", "+", "$", ",", "~"
))))
def decode_table_name(table_name):
return _decode_table_name_re.sub(lambda m: chr(int(m.group(1), 16)), table_name)
def encode_table_name(table_name):
return _encode_table_name_re.sub(
lambda m: "U+{0:0{1}x}".format(ord(m.group(0)), 4).upper(),
table_name
)
def path_from_row_pks(row, pks, use_rowid, quote=True): def path_from_row_pks(row, pks, use_rowid, quote=True):
""" Generate an optionally URL-quoted unique identifier """ Generate an optionally URL-quoted unique identifier
for a row from its primary keys.""" for a row from its primary keys."""
@ -867,13 +884,13 @@ class LimitedWriter:
self.limit_bytes = limit_mb * 1024 * 1024 self.limit_bytes = limit_mb * 1024 * 1024
self.bytes_count = 0 self.bytes_count = 0
def write(self, bytes): async def write(self, bytes):
self.bytes_count += len(bytes) self.bytes_count += len(bytes)
if self.limit_bytes and (self.bytes_count > self.limit_bytes): if self.limit_bytes and (self.bytes_count > self.limit_bytes):
raise WriteLimitExceeded("CSV contains more than {} bytes".format( raise WriteLimitExceeded("CSV contains more than {} bytes".format(
self.limit_bytes self.limit_bytes
)) ))
self.writer.write(bytes) await self.writer.write(bytes)
_infinities = {float("inf"), float("-inf")} _infinities = {float("inf"), float("-inf")}

View file

@ -19,6 +19,8 @@ from datasette.utils import (
InterruptedError, InterruptedError,
InvalidSql, InvalidSql,
LimitedWriter, LimitedWriter,
encode_table_name,
decode_table_name,
is_url, is_url,
path_from_row_pks, path_from_row_pks,
path_with_added_args, path_with_added_args,
@ -161,7 +163,7 @@ class BaseView(RenderMixin):
if expected != hash: if expected != hash:
if "table_and_format" in kwargs: if "table_and_format" in kwargs:
table, _format = resolve_table_and_format( table, _format = resolve_table_and_format(
table_and_format=urllib.parse.unquote_plus( table_and_format=decode_table_name(
kwargs["table_and_format"] kwargs["table_and_format"]
), ),
table_exists=lambda t: self.ds.table_exists(name, t) table_exists=lambda t: self.ds.table_exists(name, t)
@ -170,13 +172,13 @@ class BaseView(RenderMixin):
if _format: if _format:
kwargs["as_format"] = ".{}".format(_format) kwargs["as_format"] = ".{}".format(_format)
elif "table" in kwargs: elif "table" in kwargs:
kwargs["table"] = urllib.parse.unquote_plus( kwargs["table"] = decode_table_name(
kwargs["table"] kwargs["table"]
) )
should_redirect = "/{}-{}".format(name, expected) should_redirect = "/{}-{}".format(name, expected)
if "table" in kwargs: if "table" in kwargs:
should_redirect += "/" + urllib.parse.quote_plus( should_redirect += "/" + encode_table_name(
kwargs["table"] kwargs["table"]
) )
if "pk_path" in kwargs: if "pk_path" in kwargs:
@ -259,13 +261,13 @@ class BaseView(RenderMixin):
request, database, hash, **kwargs request, database, hash, **kwargs
) )
if first: if first:
writer.writerow(headings) await writer.writerow(headings)
first = False first = False
next = data.get("next") next = data.get("next")
for row in data["rows"]: for row in data["rows"]:
if not expanded_columns: if not expanded_columns:
# Simple path # Simple path
writer.writerow(row) await writer.writerow(row)
else: else:
# Look for {"value": "label": } dicts and expand # Look for {"value": "label": } dicts and expand
new_row = [] new_row = []
@ -275,10 +277,10 @@ class BaseView(RenderMixin):
new_row.append(cell["label"]) new_row.append(cell["label"])
else: else:
new_row.append(cell) new_row.append(cell)
writer.writerow(new_row) await writer.writerow(new_row)
except Exception as e: except Exception as e:
print('caught this', e) print('caught this', e)
r.write(str(e)) await r.write(str(e))
return return
content_type = "text/plain; charset=utf-8" content_type = "text/plain; charset=utf-8"
@ -305,7 +307,7 @@ class BaseView(RenderMixin):
_format = (kwargs.pop("as_format", None) or "").lstrip(".") _format = (kwargs.pop("as_format", None) or "").lstrip(".")
if "table_and_format" in kwargs: if "table_and_format" in kwargs:
table, _ext_format = resolve_table_and_format( table, _ext_format = resolve_table_and_format(
table_and_format=urllib.parse.unquote_plus( table_and_format=decode_table_name(
kwargs["table_and_format"] kwargs["table_and_format"]
), ),
table_exists=lambda t: self.ds.table_exists(database, t) table_exists=lambda t: self.ds.table_exists(database, t)
@ -314,7 +316,7 @@ class BaseView(RenderMixin):
kwargs["table"] = table kwargs["table"] = table
del kwargs["table_and_format"] del kwargs["table_and_format"]
elif "table" in kwargs: elif "table" in kwargs:
kwargs["table"] = urllib.parse.unquote_plus( kwargs["table"] = decode_table_name(
kwargs["table"] kwargs["table"]
) )

View file

@ -11,6 +11,7 @@ from datasette.utils import (
InterruptedError, InterruptedError,
append_querystring, append_querystring,
compound_keys_after_sql, compound_keys_after_sql,
encode_table_name,
escape_sqlite, escape_sqlite,
filters_should_redirect, filters_should_redirect,
is_url, is_url,
@ -147,7 +148,7 @@ class RowTableShared(BaseView):
"value": jinja2.Markup( "value": jinja2.Markup(
'<a href="/{database}/{table}/{flat_pks_quoted}">{flat_pks}</a>'.format( '<a href="/{database}/{table}/{flat_pks_quoted}">{flat_pks}</a>'.format(
database=database, database=database,
table=urllib.parse.quote_plus(table), table=encode_table_name(table),
flat_pks=str( flat_pks=str(
jinja2.escape( jinja2.escape(
path_from_row_pks(row, pks, not pks, False) path_from_row_pks(row, pks, not pks, False)
@ -187,7 +188,7 @@ class RowTableShared(BaseView):
) )
display_value = jinja2.Markup(link_template.format( display_value = jinja2.Markup(link_template.format(
database=database, database=database,
table=urllib.parse.quote_plus(other_table), table=encode_table_name(other_table),
link_id=urllib.parse.quote_plus(str(value)), link_id=urllib.parse.quote_plus(str(value)),
id=str(jinja2.escape(value)), id=str(jinja2.escape(value)),
label=str(jinja2.escape(label)), label=str(jinja2.escape(label)),

View file

@ -18,6 +18,7 @@ Options:
--source_url TEXT Source URL for metadata --source_url TEXT Source URL for metadata
-n, --name TEXT Application name to use when deploying -n, --name TEXT Application name to use when deploying
--force Pass --force option to now --force Pass --force option to now
--token TEXT Auth token to use for deploy (Now only) --token TEXT Auth token to use for deploy
--alias TEXT Desired alias e.g. yoursite.now.sh
--spatialite Enable SpatialLite extension --spatialite Enable SpatialLite extension
--help Show this message and exit. --help Show this message and exit.

View file

@ -36,7 +36,7 @@ setup(
install_requires=[ install_requires=[
'click==6.7', 'click==6.7',
'click-default-group==1.2', 'click-default-group==1.2',
'Sanic==0.7.0', 'sanic==18.12.0',
'Jinja2==2.10', 'Jinja2==2.10',
'hupper==1.0', 'hupper==1.0',
'pint==0.8.1', 'pint==0.8.1',
@ -50,8 +50,8 @@ setup(
extras_require={ extras_require={
'test': [ 'test': [
'pytest==4.0.2', 'pytest==4.0.2',
'aiohttp==3.3.2', 'aiohttp==3.5.1',
'beautifulsoup4==4.6.1', 'beautifulsoup4==4.6.3',
] ]
}, },
tests_require=[ tests_require=[

View file

@ -597,7 +597,7 @@ def test_table_shape_object_compound_primary_Key(app_client):
def test_table_with_slashes_in_name(app_client): def test_table_with_slashes_in_name(app_client):
response = app_client.get('/fixtures/table%2Fwith%2Fslashes.csv?_shape=objects&_format=json') response = app_client.get('/fixtures/tableU+002FwithU+002Fslashes.csv?_shape=objects&_format=json')
assert response.status == 200 assert response.status == 200
data = response.json data = response.json
assert data['rows'] == [{ assert data['rows'] == [{
@ -897,7 +897,7 @@ def test_row(app_client):
def test_row_strange_table_name(app_client): def test_row_strange_table_name(app_client):
response = app_client.get('/fixtures/table%2Fwith%2Fslashes.csv/3.json?_shape=objects') response = app_client.get('/fixtures/tableU+002FwithU+002Fslashes.csv/3.json?_shape=objects')
assert response.status == 200 assert response.status == 200
assert [{'pk': '3', 'content': 'hey'}] == response.json['rows'] assert [{'pk': '3', 'content': 'hey'}] == response.json['rows']

View file

@ -54,14 +54,14 @@ def test_row(app_client):
def test_row_strange_table_name(app_client): def test_row_strange_table_name(app_client):
response = app_client.get( response = app_client.get(
'/fixtures/table%2Fwith%2Fslashes.csv/3', '/fixtures/tableU+002FwithU+002Fslashes.csv/3',
allow_redirects=False allow_redirects=False
) )
assert response.status == 302 assert response.status == 302
assert response.headers['Location'].endswith( assert response.headers['Location'].endswith(
'/table%2Fwith%2Fslashes.csv/3' '/tableU+002FwithU+002Fslashes.csv/3'
) )
response = app_client.get('/fixtures/table%2Fwith%2Fslashes.csv/3') response = app_client.get('/fixtures/tableU+002FwithU+002Fslashes.csv/3')
assert response.status == 200 assert response.status == 200
@ -358,7 +358,7 @@ def test_facets_persist_through_filter_form(app_client):
('/fixtures/simple_primary_key', [ ('/fixtures/simple_primary_key', [
'table', 'db-fixtures', 'table-simple_primary_key' 'table', 'db-fixtures', 'table-simple_primary_key'
]), ]),
('/fixtures/table%2Fwith%2Fslashes.csv', [ ('/fixtures/tableU+002FwithU+002Fslashes.csv', [
'table', 'db-fixtures', 'table-tablewithslashescsv-fa7563' 'table', 'db-fixtures', 'table-tablewithslashescsv-fa7563'
]), ]),
('/fixtures/simple_primary_key/1', [ ('/fixtures/simple_primary_key/1', [

View file

@ -374,3 +374,16 @@ def test_path_with_format(path, format, extra_qs, expected):
) )
actual = utils.path_with_format(request, format, extra_qs) actual = utils.path_with_format(request, format, extra_qs)
assert expected == actual assert expected == actual
@pytest.mark.parametrize("name,expected", [
("table", "table"),
("table/and/slashes", "tableU+002FandU+002Fslashes"),
("~table", "U+007Etable"),
("+bobcats!", "U+002Bbobcats!"),
("U+007Etable", "UU+002B007Etable"),
])
def test_encode_decode_table_name(name, expected):
encoded = utils.encode_table_name(name)
assert encoded == expected
assert name == utils.decode_table_name(encoded)