Compare commits

...

3 commits

Author SHA1 Message Date
Simon Willison
b545b6a04e Test for bool(results), closes #1832 2022-10-04 21:32:11 -07:00
Simon Willison
e0e8522622 Use new datasette.io/discord link for Discord
Refs https://github.com/simonw/datasette.io/issues/119
2022-09-30 15:25:28 -07:00
Simon Willison
49a11a6042 Keyword-only arguments for a bunch of internal methods, refs #1822 2022-09-26 17:43:55 -07:00
6 changed files with 50 additions and 30 deletions

View file

@ -7,7 +7,7 @@
[![Documentation Status](https://readthedocs.org/projects/datasette/badge/?version=latest)](https://docs.datasette.io/en/latest/?badge=latest) [![Documentation Status](https://readthedocs.org/projects/datasette/badge/?version=latest)](https://docs.datasette.io/en/latest/?badge=latest)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette/blob/main/LICENSE) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette/blob/main/LICENSE)
[![docker: datasette](https://img.shields.io/badge/docker-datasette-blue)](https://hub.docker.com/r/datasetteproject/datasette) [![docker: datasette](https://img.shields.io/badge/docker-datasette-blue)](https://hub.docker.com/r/datasetteproject/datasette)
[![discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://discord.gg/ktd74dm5mw) [![discord](https://img.shields.io/discord/823971286308356157?label=discord)](https://datasette.io/discord)
*An open source multi-tool for exploring and publishing data* *An open source multi-tool for exploring and publishing data*
@ -22,7 +22,7 @@ Datasette is aimed at data journalists, museum curators, archivists, local gover
* Comprehensive documentation: https://docs.datasette.io/ * Comprehensive documentation: https://docs.datasette.io/
* Examples: https://datasette.io/examples * Examples: https://datasette.io/examples
* Live demo of current `main` branch: https://latest.datasette.io/ * Live demo of current `main` branch: https://latest.datasette.io/
* Questions, feedback or want to talk about the project? Join our [Discord](https://discord.gg/ktd74dm5mw) * Questions, feedback or want to talk about the project? Join our [Discord](https://datasette.io/discord)
Want to stay up-to-date with the project? Subscribe to the [Datasette newsletter](https://datasette.substack.com/) for tips, tricks and news on what's new in the Datasette ecosystem. Want to stay up-to-date with the project? Subscribe to the [Datasette newsletter](https://datasette.substack.com/) for tips, tricks and news on what's new in the Datasette ecosystem.

View file

@ -190,6 +190,7 @@ class Datasette:
def __init__( def __init__(
self, self,
files=None, files=None,
*,
immutables=None, immutables=None,
cache_headers=True, cache_headers=True,
cors=False, cors=False,
@ -410,7 +411,7 @@ class Datasette:
def unsign(self, signed, namespace="default"): def unsign(self, signed, namespace="default"):
return URLSafeSerializer(self._secret, namespace).loads(signed) return URLSafeSerializer(self._secret, namespace).loads(signed)
def get_database(self, name=None, route=None): def get_database(self, name=None, *, route=None):
if route is not None: if route is not None:
matches = [db for db in self.databases.values() if db.route == route] matches = [db for db in self.databases.values() if db.route == route]
if not matches: if not matches:
@ -421,7 +422,7 @@ class Datasette:
name = [key for key in self.databases.keys() if key != "_internal"][0] name = [key for key in self.databases.keys() if key != "_internal"][0]
return self.databases[name] return self.databases[name]
def add_database(self, db, name=None, route=None): def add_database(self, db, name=None, *, route=None):
new_databases = self.databases.copy() new_databases = self.databases.copy()
if name is None: if name is None:
# Pick a unique name for this database # Pick a unique name for this database
@ -466,7 +467,7 @@ class Datasette:
orig[key] = upd_value orig[key] = upd_value
return orig return orig
def metadata(self, key=None, database=None, table=None, fallback=True): def metadata(self, key=None, *, database=None, table=None, fallback=True):
""" """
Looks up metadata, cascading backwards from specified level. Looks up metadata, cascading backwards from specified level.
Returns None if metadata value is not found. Returns None if metadata value is not found.
@ -518,7 +519,7 @@ class Datasette:
def _metadata(self): def _metadata(self):
return self.metadata() return self.metadata()
def plugin_config(self, plugin_name, database=None, table=None, fallback=True): def plugin_config(self, plugin_name, *, database=None, table=None, fallback=True):
"""Return config for plugin, falling back from specified database/table""" """Return config for plugin, falling back from specified database/table"""
plugins = self.metadata( plugins = self.metadata(
"plugins", database=database, table=table, fallback=fallback "plugins", database=database, table=table, fallback=fallback
@ -714,6 +715,7 @@ class Datasette:
db_name, db_name,
sql, sql,
params=None, params=None,
*,
truncate=False, truncate=False,
custom_time_limit=None, custom_time_limit=None,
page_size=None, page_size=None,
@ -943,7 +945,7 @@ class Datasette:
) )
async def render_template( async def render_template(
self, templates, context=None, request=None, view_name=None self, templates, context=None, *, request=None, view_name=None
): ):
if not self._startup_invoked: if not self._startup_invoked:
raise Exception("render_template() called before await ds.invoke_startup()") raise Exception("render_template() called before await ds.invoke_startup()")

View file

@ -118,7 +118,9 @@ class Request:
return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True)) return dict(parse_qsl(body.decode("utf-8"), keep_blank_values=True))
@classmethod @classmethod
def fake(cls, path_with_query_string, method="GET", scheme="http", url_vars=None): def fake(
cls, path_with_query_string, *, method="GET", scheme="http", url_vars=None
):
"""Useful for constructing Request objects for tests""" """Useful for constructing Request objects for tests"""
path, _, query_string = path_with_query_string.partition("?") path, _, query_string = path_with_query_string.partition("?")
scope = { scope = {
@ -204,7 +206,7 @@ class AsgiWriter:
) )
async def asgi_send_json(send, info, status=200, headers=None): async def asgi_send_json(send, info, *, status=200, headers=None):
headers = headers or {} headers = headers or {}
await asgi_send( await asgi_send(
send, send,
@ -215,7 +217,7 @@ async def asgi_send_json(send, info, status=200, headers=None):
) )
async def asgi_send_html(send, html, status=200, headers=None): async def asgi_send_html(send, html, *, status=200, headers=None):
headers = headers or {} headers = headers or {}
await asgi_send( await asgi_send(
send, send,
@ -226,7 +228,7 @@ async def asgi_send_html(send, html, status=200, headers=None):
) )
async def asgi_send_redirect(send, location, status=302): async def asgi_send_redirect(send, location, *, status=302):
await asgi_send( await asgi_send(
send, send,
"", "",
@ -236,12 +238,12 @@ async def asgi_send_redirect(send, location, status=302):
) )
async def asgi_send(send, content, status, headers=None, content_type="text/plain"): async def asgi_send(send, content, status, *, headers=None, content_type="text/plain"):
await asgi_start(send, status, headers, content_type) await asgi_start(send, status=status, headers=headers, content_type=content_type)
await send({"type": "http.response.body", "body": content.encode("utf-8")}) await send({"type": "http.response.body", "body": content.encode("utf-8")})
async def asgi_start(send, status, headers=None, content_type="text/plain"): async def asgi_start(send, status, *, headers=None, content_type="text/plain"):
headers = headers or {} headers = headers or {}
# Remove any existing content-type header # Remove any existing content-type header
headers = {k: v for k, v in headers.items() if k.lower() != "content-type"} headers = {k: v for k, v in headers.items() if k.lower() != "content-type"}
@ -259,7 +261,7 @@ async def asgi_start(send, status, headers=None, content_type="text/plain"):
async def asgi_send_file( async def asgi_send_file(
send, filepath, filename=None, content_type=None, chunk_size=4096, headers=None send, filepath, filename=None, *, content_type=None, chunk_size=4096, headers=None
): ):
headers = headers or {} headers = headers or {}
if filename: if filename:
@ -270,9 +272,11 @@ async def asgi_send_file(
if first: if first:
await asgi_start( await asgi_start(
send, send,
200, status=200,
headers, headers=headers,
content_type or guess_type(str(filepath))[0] or "text/plain", content_type=content_type
or guess_type(str(filepath))[0]
or "text/plain",
) )
first = False first = False
more_body = True more_body = True
@ -284,7 +288,7 @@ async def asgi_send_file(
) )
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None): def asgi_static(root_path, *, chunk_size=4096, headers=None, content_type=None):
root_path = Path(root_path) root_path = Path(root_path)
async def inner_static(request, send): async def inner_static(request, send):
@ -292,28 +296,32 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
try: try:
full_path = (root_path / path).resolve().absolute() full_path = (root_path / path).resolve().absolute()
except FileNotFoundError: except FileNotFoundError:
await asgi_send_html(send, "404: Directory not found", 404) await asgi_send_html(send, "404: Directory not found", status=404)
return return
if full_path.is_dir(): if full_path.is_dir():
await asgi_send_html(send, "403: Directory listing is not allowed", 403) await asgi_send_html(
send, "403: Directory listing is not allowed", status=403
)
return return
# Ensure full_path is within root_path to avoid weird "../" tricks # Ensure full_path is within root_path to avoid weird "../" tricks
try: try:
full_path.relative_to(root_path.resolve()) full_path.relative_to(root_path.resolve())
except ValueError: except ValueError:
await asgi_send_html(send, "404: Path not inside root path", 404) await asgi_send_html(send, "404: Path not inside root path", status=404)
return return
try: try:
await asgi_send_file(send, full_path, chunk_size=chunk_size) await asgi_send_file(send, full_path, chunk_size=chunk_size)
except FileNotFoundError: except FileNotFoundError:
await asgi_send_html(send, "404: File not found", 404) await asgi_send_html(send, "404: File not found", status=404)
return return
return inner_static return inner_static
class Response: class Response:
def __init__(self, body=None, status=200, headers=None, content_type="text/plain"): def __init__(
self, body=None, *, status=200, headers=None, content_type="text/plain"
):
self.body = body self.body = body
self.status = status self.status = status
self.headers = headers or {} self.headers = headers or {}
@ -346,6 +354,7 @@ class Response:
self, self,
key, key,
value="", value="",
*,
max_age=None, max_age=None,
expires=None, expires=None,
path="/", path="/",
@ -374,7 +383,7 @@ class Response:
self._set_cookie_headers.append(cookie.output(header="").strip()) self._set_cookie_headers.append(cookie.output(header="").strip())
@classmethod @classmethod
def html(cls, body, status=200, headers=None): def html(cls, body, *, status=200, headers=None):
return cls( return cls(
body, body,
status=status, status=status,
@ -383,7 +392,7 @@ class Response:
) )
@classmethod @classmethod
def text(cls, body, status=200, headers=None): def text(cls, body, *, status=200, headers=None):
return cls( return cls(
str(body), str(body),
status=status, status=status,
@ -392,7 +401,7 @@ class Response:
) )
@classmethod @classmethod
def json(cls, body, status=200, headers=None, default=None): def json(cls, body, *, status=200, headers=None, default=None):
return cls( return cls(
json.dumps(body, default=default), json.dumps(body, default=default),
status=status, status=status,
@ -401,7 +410,7 @@ class Response:
) )
@classmethod @classmethod
def redirect(cls, path, status=302, headers=None): def redirect(cls, path, *, status=302, headers=None):
headers = headers or {} headers = headers or {}
headers["Location"] = path headers["Location"] = path
return cls("", status=status, headers=headers) return cls("", status=status, headers=headers)
@ -412,6 +421,7 @@ class AsgiFileDownload:
self, self,
filepath, filepath,
filename=None, filename=None,
*,
content_type="application/octet-stream", content_type="application/octet-stream",
headers=None, headers=None,
): ):

View file

@ -28,7 +28,7 @@ Changelog
Datasette can now run entirely in your browser using WebAssembly. Try out `Datasette Lite <https://lite.datasette.io/>`__, take a look `at the code <https://github.com/simonw/datasette-lite>`__ or read more about it in `Datasette Lite: a server-side Python web application running in a browser <https://simonwillison.net/2022/May/4/datasette-lite/>`__. Datasette can now run entirely in your browser using WebAssembly. Try out `Datasette Lite <https://lite.datasette.io/>`__, take a look `at the code <https://github.com/simonw/datasette-lite>`__ or read more about it in `Datasette Lite: a server-side Python web application running in a browser <https://simonwillison.net/2022/May/4/datasette-lite/>`__.
Datasette now has a `Discord community <https://discord.gg/ktd74dm5mw>`__ for questions and discussions about Datasette and its ecosystem of projects. Datasette now has a `Discord community <https://datasette.io/discord>`__ for questions and discussions about Datasette and its ecosystem of projects.
Features Features
~~~~~~~~ ~~~~~~~~

View file

@ -17,7 +17,7 @@ datasette| |discord|
.. |docker: datasette| image:: https://img.shields.io/badge/docker-datasette-blue .. |docker: datasette| image:: https://img.shields.io/badge/docker-datasette-blue
:target: https://hub.docker.com/r/datasetteproject/datasette :target: https://hub.docker.com/r/datasetteproject/datasette
.. |discord| image:: https://img.shields.io/discord/823971286308356157?label=discord .. |discord| image:: https://img.shields.io/discord/823971286308356157?label=discord
:target: https://discord.gg/ktd74dm5mw :target: https://datasette.io/discord
*An open source multi-tool for exploring and publishing data* *An open source multi-tool for exploring and publishing data*

View file

@ -30,6 +30,14 @@ async def test_results_first(db):
assert isinstance(row, sqlite3.Row) assert isinstance(row, sqlite3.Row)
@pytest.mark.asyncio
@pytest.mark.parametrize("expected", (True, False))
async def test_results_bool(db, expected):
where = "" if expected else "where pk = 0"
results = await db.execute("select * from facetable {}".format(where))
assert bool(results) is expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
"query,expected", "query,expected",
[ [