Compare commits

..

No commits in common. "main" and "1.0a37" have entirely different histories.

133 changed files with 1569 additions and 1647 deletions

View file

@ -89,8 +89,7 @@ def pytest_runtest_protocol(item, nextitem):
continue continue
try: try:
ds.close() ds.close()
except Exception as e: # noqa: BLE001 except Exception as e:
# Surfaced as a pytest warning; teardown must not fail the run
item.warn( item.warn(
pytest.PytestUnraisableExceptionWarning( pytest.PytestUnraisableExceptionWarning(
f"Error closing Datasette instance: {e!r}" f"Error closing Datasette instance: {e!r}"

View file

@ -1,9 +1,7 @@
import time
from itsdangerous import BadSignature
from datasette import hookimpl from datasette import hookimpl
from itsdangerous import BadSignature
from datasette.utils import baseconv from datasette.utils import baseconv
import time
@hookimpl @hookimpl

View file

@ -2,8 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextvars import contextvars
from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.permissions import Resource from datasette.permissions import Resource
@ -13,10 +12,11 @@ import dataclasses
import datetime import datetime
import functools import functools
import glob import glob
import httpx
import importlib.metadata import importlib.metadata
import inspect import inspect
from itsdangerous import BadSignature
import json import json
import logging
import os import os
import re import re
import secrets import secrets
@ -28,41 +28,90 @@ import urllib.parse
from concurrent import futures from concurrent import futures
from pathlib import Path from pathlib import Path
import httpx from markupsafe import Markup, escape
from itsdangerous import BadSignature, URLSafeSerializer from itsdangerous import URLSafeSerializer
from jinja2 import ( from jinja2 import (
ChoiceLoader, ChoiceLoader,
Environment, Environment,
FileSystemLoader, FileSystemLoader,
PrefixLoader,
pass_context, pass_context,
PrefixLoader,
) )
from jinja2.environment import Template from jinja2.environment import Template
from jinja2.exceptions import TemplateNotFound from jinja2.exceptions import TemplateNotFound
from markupsafe import Markup, escape
from . import stored_queries, write_sql
from .column_types import SQLiteType
from .csrf import CrossOriginProtectionMiddleware
from .database import Database, QueryInterrupted
from .events import Event from .events import Event
from .plugins import DEFAULT_PLUGINS, get_plugins, pm from .column_types import SQLiteType
from . import stored_queries, write_sql
from .views import Context
from .views.database import (
database_download,
DatabaseView,
QueryView,
)
from .views.table_create_alter import (
DatabaseForeignKeyTargetsView,
TableAlterView,
TableCreateView,
TableForeignKeySuggestionsView,
)
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
from .views.stored_queries import (
QueryCreateAnalyzeView,
QueryDeleteView,
QueryDefinitionView,
QueryEditView,
GlobalQueryListView,
QueryListView,
QueryParametersView,
QueryStoreView,
QueryUpdateView,
)
from .views.index import IndexView
from .views.special import (
JsonDataView,
PatternPortfolioView,
AutocompleteDebugView,
AuthTokenView,
ApiExplorerView,
CreateTokenView,
LogoutView,
AllowDebugView,
PermissionsDebugView,
MessagesDebugView,
AllowedResourcesView,
PermissionRulesView,
PermissionCheckView,
JumpView,
InstanceSchemaView,
DatabaseSchemaView,
TableSchemaView,
)
from .views.table import (
TableAutocompleteView,
TableInsertView,
TableUpsertView,
TableSetColumnTypeView,
TableDropView,
TableFragmentView,
table_view,
)
from .views.row import RowView, RowDeleteView, RowUpdateView
from .renderer import json_renderer from .renderer import json_renderer
from .resources import DatabaseResource, TableResource
from .tokens import TokenInvalid
from .tracer import AsgiTracer
from .url_builder import Urls from .url_builder import Urls
from .database import Database, QueryInterrupted
from .utils import ( from .utils import (
SPATIALITE_FUNCTIONS,
PaginatedResources, PaginatedResources,
PrefixedUrlString, PrefixedUrlString,
SPATIALITE_FUNCTIONS,
StartupError, StartupError,
add_cors_headers,
async_call_with_supported_arguments, async_call_with_supported_arguments,
await_me_maybe, await_me_maybe,
baseconv, baseconv,
call_with_supported_arguments, call_with_supported_arguments,
detect_json1, detect_json1,
add_cors_headers,
display_actor, display_actor,
escape_css_string, escape_css_string,
escape_sqlite, escape_sqlite,
@ -72,97 +121,47 @@ from .utils import (
move_plugins_and_allow, move_plugins_and_allow,
move_table_config, move_table_config,
parse_metadata, parse_metadata,
redact_keys,
resolve_env_secrets, resolve_env_secrets,
resolve_routes, resolve_routes,
row_sql_params_pks,
sha256_file, sha256_file,
tilde_decode, tilde_decode,
tilde_encode, tilde_encode,
to_css_class, to_css_class,
urlsafe_components, urlsafe_components,
redact_keys,
row_sql_params_pks,
) )
from .tokens import TokenInvalid
from .utils.asgi import ( from .utils.asgi import (
AsgiLifespan, AsgiLifespan,
AsgiRunOnFirstRequest,
BadRequest, BadRequest,
DatabaseNotFound,
Forbidden, Forbidden,
NotFound, NotFound,
DatabaseNotFound,
TableNotFound,
RowNotFound,
Request, Request,
Response, Response,
RowNotFound, AsgiRunOnFirstRequest,
TableNotFound, asgi_static,
asgi_send, asgi_send,
asgi_send_file, asgi_send_file,
asgi_send_redirect, asgi_send_redirect,
asgi_static,
) )
from .csrf import CrossOriginProtectionMiddleware
from .utils.internal_db import init_internal_db, populate_schema_tables from .utils.internal_db import init_internal_db, populate_schema_tables
from .utils.sqlite import ( from .utils.sqlite import (
sqlite3, sqlite3,
using_pysqlite3, using_pysqlite3,
) )
from .tracer import AsgiTracer
from .plugins import pm, DEFAULT_PLUGINS, get_plugins
from .version import __version__ from .version import __version__
from .views import Context
from .views.database import ( from .resources import DatabaseResource, TableResource
DatabaseView,
QueryView,
database_download,
)
from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView
from .views.index import IndexView
from .views.row import RowDeleteView, RowUpdateView, RowView
from .views.special import (
AllowDebugView,
AllowedResourcesView,
ApiExplorerView,
AuthTokenView,
AutocompleteDebugView,
CreateTokenView,
DatabaseSchemaView,
InstanceSchemaView,
JsonDataView,
JumpView,
LogoutView,
MessagesDebugView,
PatternPortfolioView,
PermissionCheckView,
PermissionRulesView,
PermissionsDebugView,
TableSchemaView,
)
from .views.stored_queries import (
GlobalQueryListView,
QueryCreateAnalyzeView,
QueryDefinitionView,
QueryDeleteView,
QueryEditView,
QueryListView,
QueryParametersView,
QueryStoreView,
QueryUpdateView,
)
from .views.table import (
TableAutocompleteView,
TableDropView,
TableFragmentView,
TableInsertView,
TableSetColumnTypeView,
TableUpsertView,
table_view,
)
from .views.table_create_alter import (
DatabaseForeignKeyTargetsView,
TableAlterView,
TableCreateView,
TableForeignKeySuggestionsView,
)
app_root = Path(__file__).parent.parent app_root = Path(__file__).parent.parent
logger = logging.getLogger(__name__)
# Context variable to track when code is executing within a datasette.client request # Context variable to track when code is executing within a datasette.client request
_in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False) _in_datasette_client = contextvars.ContextVar("in_datasette_client", default=False)
@ -185,7 +184,7 @@ class PermissionCheck:
"""Represents a logged permission check for debugging purposes.""" """Represents a logged permission check for debugging purposes."""
when: str when: str
actor: dict[str, Any] | None actor: Dict[str, Any] | None
action: str action: str
parent: str | None parent: str | None
child: str | None child: str | None
@ -435,7 +434,7 @@ class Datasette:
if config_dir: if config_dir:
db_files = [] db_files = []
for ext in ("db", "sqlite", "sqlite3"): for ext in ("db", "sqlite", "sqlite3"):
db_files.extend(config_dir.glob(f"*.{ext}")) db_files.extend(config_dir.glob("*.{}".format(ext)))
self.files += tuple(str(f) for f in db_files) self.files += tuple(str(f) for f in db_files)
if ( if (
config_dir config_dir
@ -676,10 +675,10 @@ class Datasette:
def get_jinja_environment(self, request: Request = None) -> Environment: def get_jinja_environment(self, request: Request = None) -> Environment:
environment = self._jinja_env environment = self._jinja_env
if request: if request:
for hook_environment in pm.hook.jinja2_environment_from_request( for environment in pm.hook.jinja2_environment_from_request(
datasette=self, request=request, env=environment datasette=self, request=request, env=environment
): ):
environment = hook_environment pass
return environment return environment
def get_action(self, name_or_abbr: str): def get_action(self, name_or_abbr: str):
@ -733,7 +732,7 @@ class Datasette:
catalog_database_names.update( catalog_database_names.update(
row["database_name"] row["database_name"]
for row in await internal_db.execute( for row in await internal_db.execute(
f"select distinct database_name from {table}" "select distinct database_name from {}".format(table)
) )
if row["database_name"] is not None if row["database_name"] is not None
) )
@ -744,7 +743,7 @@ class Datasette:
for stale_db_name in stale_databases: for stale_db_name in stale_databases:
for table in catalog_table_names: for table in catalog_table_names:
conn.execute( conn.execute(
f"DELETE FROM {table} WHERE database_name = ?", "DELETE FROM {} WHERE database_name = ?".format(table),
[stale_db_name], [stale_db_name],
) )
@ -793,13 +792,17 @@ class Datasette:
action.name in action_names action.name in action_names
and action != action_names[action.name] and action != action_names[action.name]
): ):
raise StartupError(f"Duplicate action name: {action.name}") raise StartupError(
"Duplicate action name: {}".format(action.name)
)
if ( if (
action.abbr action.abbr
and action.abbr in action_abbrs and action.abbr in action_abbrs
and action != action_abbrs[action.abbr] and action != action_abbrs[action.abbr]
): ):
raise StartupError(f"Duplicate action abbr: {action.abbr}") raise StartupError(
"Duplicate action abbr: {}".format(action.abbr)
)
action_names[action.name] = action action_names[action.name] = action
if action.abbr: if action.abbr:
action_abbrs[action.abbr] = action action_abbrs[action.abbr] = action
@ -858,7 +861,7 @@ class Datasette:
actor_id: str, actor_id: str,
*, *,
expires_after: int | None = None, expires_after: int | None = None,
restrictions: TokenRestrictions | None = None, restrictions: "TokenRestrictions | None" = None,
handler: str | None = None, handler: str | None = None,
) -> str: ) -> str:
""" """
@ -915,7 +918,7 @@ class Datasette:
raise KeyError raise KeyError
return matches[0] return matches[0]
if name is None: if name is None:
name = next(iter(self.databases.keys())) name = [key for key in self.databases.keys()][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):
@ -928,7 +931,7 @@ class Datasette:
suggestion = name suggestion = name
i = 2 i = 2
while name in self.databases: while name in self.databases:
name = f"{suggestion}_{i}" name = "{}_{}".format(suggestion, i)
i += 1 i += 1
db.name = name db.name = name
db.route = route or name db.route = route or name
@ -963,14 +966,13 @@ class Datasette:
for db in dbs: for db in dbs:
try: try:
db.close() db.close()
except Exception as e: # noqa: BLE001 except Exception as e:
# Collect the first failure and re-raise after every close() has run
if first_exception is None: if first_exception is None:
first_exception = e first_exception = e
if self.executor is not None: if self.executor is not None:
try: try:
self.executor.shutdown(wait=True, cancel_futures=True) self.executor.shutdown(wait=True, cancel_futures=True)
except Exception as e: # noqa: BLE001 except Exception as e:
if first_exception is None: if first_exception is None:
first_exception = e first_exception = e
if first_exception is not None: if first_exception is not None:
@ -1319,15 +1321,24 @@ class Datasette:
actual = ( actual = (
actual_sqlite_type.value actual_sqlite_type.value
if actual_sqlite_type is not None if actual_sqlite_type is not None
else f"unrecognized {column_detail.type!r}" else "unrecognized {!r}".format(column_detail.type)
) )
raise ValueError( raise ValueError(
f"Column type {ct_cls.name!r} is only applicable to SQLite types {allowed} but {database}.{resource}.{column} " "Column type {!r} is only applicable to SQLite types {} but {}.{}.{} "
f"has SQLite type {actual}" "has SQLite type {}".format(
ct_cls.name,
allowed,
database,
resource,
column,
actual,
)
) )
async def _apply_column_types_config(self): async def _apply_column_types_config(self):
"""Load column_types from datasette.json config into the internal DB.""" """Load column_types from datasette.json config into the internal DB."""
import logging
for db_name, db_conf in (self.config or {}).get("databases", {}).items(): for db_name, db_conf in (self.config or {}).get("databases", {}).items():
for table_name, table_conf in db_conf.get("tables", {}).items(): for table_name, table_conf in db_conf.get("tables", {}).items():
for col_name, ct in table_conf.get("column_types", {}).items(): for col_name, ct in table_conf.get("column_types", {}).items():
@ -1337,7 +1348,7 @@ class Datasette:
col_type = ct["type"] col_type = ct["type"]
config = ct.get("config") config = ct.get("config")
if col_type not in self._column_types: if col_type not in self._column_types:
logger.warning( logging.warning(
"column_types config references unknown type %r " "column_types config references unknown type %r "
"for %s.%s.%s", "for %s.%s.%s",
col_type, col_type,
@ -1350,7 +1361,7 @@ class Datasette:
db_name, table_name, col_name, col_type, config db_name, table_name, col_name, col_type, config
) )
except ValueError as ex: except ValueError as ex:
logger.warning(str(ex)) logging.warning(str(ex))
async def get_column_type(self, database: str, resource: str, column: str): async def get_column_type(self, database: str, resource: str, column: str):
""" """
@ -1403,7 +1414,7 @@ class Datasette:
resource: str, resource: str,
column: str, column: str,
column_type: str, column_type: str,
config: dict | None = None, config: dict = None,
) -> None: ) -> None:
"""Assign a column type. Overwrites any existing assignment.""" """Assign a column type. Overwrites any existing assignment."""
ct_cls = self._column_types.get(column_type) ct_cls = self._column_types.get(column_type)
@ -1487,7 +1498,9 @@ class Datasette:
possible_names = {plugin["name"], plugin["name"].replace("-", "_")} possible_names = {plugin["name"], plugin["name"].replace("-", "_")}
if plugin_name in possible_names: if plugin_name in possible_names:
return _resolve_static_asset_path(plugin["static_path"], path) return _resolve_static_asset_path(plugin["static_path"], path)
raise FileNotFoundError(f"No static assets found for plugin {plugin_name}") raise FileNotFoundError(
"No static assets found for plugin {}".format(plugin_name)
)
def _static_mounted_asset(self, mount_name, path): def _static_mounted_asset(self, mount_name, path):
mount_name = mount_name.strip("/") mount_name = mount_name.strip("/")
@ -1497,7 +1510,7 @@ class Datasette:
_resolve_static_asset_path(dirname, path), _resolve_static_asset_path(dirname, path),
self.urls.path("/{}/{}".format(mount_name, path.lstrip("/"))), self.urls.path("/{}/{}".format(mount_name, path.lstrip("/"))),
) )
raise FileNotFoundError(f"No static mount found for {mount_name}") raise FileNotFoundError("No static mount found for {}".format(mount_name))
def _static_asset_hash(self, filepath): def _static_asset_hash(self, filepath):
filepath = Path(filepath) filepath = Path(filepath)
@ -1588,17 +1601,18 @@ class Datasette:
if await self.allowed(action="view-instance", actor=actor): if await self.allowed(action="view-instance", actor=actor):
crumbs.append({"href": self.urls.instance(), "label": "home"}) crumbs.append({"href": self.urls.instance(), "label": "home"})
# Database link # Database link
if database and await self.allowed( if database:
action="view-database", if await self.allowed(
resource=DatabaseResource(database=database), action="view-database",
actor=actor, resource=DatabaseResource(database=database),
): actor=actor,
crumbs.append( ):
{ crumbs.append(
"href": self.urls.database(database), {
"label": database, "href": self.urls.database(database),
} "label": database,
) }
)
# Table link # Table link
if table: if table:
assert database, "table= requires database=" assert database, "table= requires database="
@ -1617,7 +1631,7 @@ class Datasette:
async def actors_from_ids( async def actors_from_ids(
self, actor_ids: Iterable[str | int] self, actor_ids: Iterable[str | int]
) -> dict[int | str, dict]: ) -> Dict[int | str, Dict]:
result = pm.hook.actors_from_ids(datasette=self, actor_ids=actor_ids) result = pm.hook.actors_from_ids(datasette=self, actor_ids=actor_ids)
if result is None: if result is None:
# Do the default thing # Do the default thing
@ -1626,9 +1640,9 @@ class Datasette:
return result return result
async def track_event(self, event: Event): async def track_event(self, event: Event):
assert isinstance( assert isinstance(event, self.event_classes), "Invalid event type: {}".format(
event, self.event_classes type(event)
), f"Invalid event type: {type(event)}" )
for hook in pm.hook.track_event(datasette=self, event=event): for hook in pm.hook.track_event(datasette=self, event=event):
await await_me_maybe(hook) await await_me_maybe(hook)
@ -1665,7 +1679,7 @@ class Datasette:
self, self,
actor: dict, actor: dict,
action: str, action: str,
resource: Resource | None = None, resource: "Resource" | None = None,
): ):
""" """
Check if actor can see a resource and if it's private. Check if actor can see a resource and if it's private.
@ -1864,7 +1878,10 @@ class Datasette:
if truncated and resources: if truncated and resources:
last_resource = resources[-1] last_resource = resources[-1]
# Use tilde-encoding like table pagination # Use tilde-encoding like table pagination
next_token = f"{tilde_encode(str(last_resource.parent))},{tilde_encode(str(last_resource.child))}" next_token = "{},{}".format(
tilde_encode(str(last_resource.parent)),
tilde_encode(str(last_resource.child)),
)
return PaginatedResources( return PaginatedResources(
resources=resources, resources=resources,
@ -1882,7 +1899,7 @@ class Datasette:
self, self,
*, *,
action: str, action: str,
resource: Resource = None, resource: "Resource" = None,
actor: dict | None = None, actor: dict | None = None,
) -> bool: ) -> bool:
""" """
@ -1913,7 +1930,7 @@ class Datasette:
self, self,
*, *,
actions: Sequence[str], actions: Sequence[str],
resource: Resource = None, resource: "Resource" = None,
actor: dict | None = None, actor: dict | None = None,
) -> dict[str, bool]: ) -> dict[str, bool]:
""" """
@ -1934,11 +1951,11 @@ class Datasette:
) )
# {"edit-schema": True, "drop-table": True, "insert-row": False} # {"edit-schema": True, "drop-table": True, "insert-row": False}
""" """
from datasette.utils.actions_sql import check_permissions_for_actions
from datasette.permissions import ( from datasette.permissions import (
_permission_check_cache, _permission_check_cache,
_skip_permission_checks, _skip_permission_checks,
) )
from datasette.utils.actions_sql import check_permissions_for_actions
# For global actions, resource is None # For global actions, resource is None
parent = resource.parent if resource else None parent = resource.parent if resource else None
@ -2027,7 +2044,7 @@ class Datasette:
self, self,
*, *,
action: str, action: str,
resource: Resource = None, resource: "Resource" = None,
actor: dict | None = None, actor: dict | None = None,
): ):
""" """
@ -2081,15 +2098,13 @@ class Datasette:
db = self.databases[database] db = self.databases[database]
foreign_keys = await db.foreign_keys_for_table(table) foreign_keys = await db.foreign_keys_for_table(table)
# Find the foreign_key for this column # Find the foreign_key for this column
fk = next( try:
( fk = [
foreign_key foreign_key
for foreign_key in foreign_keys for foreign_key in foreign_keys
if foreign_key["column"] == column if foreign_key["column"] == column
), ][0]
None, except IndexError:
)
if fk is None:
return {} return {}
# Ensure user has permission to view the referenced table # Ensure user has permission to view the referenced table
from datasette.resources import TableResource from datasette.resources import TableResource
@ -2177,17 +2192,16 @@ class Datasette:
sqlite_extensions[extension] = result.fetchone()[0] sqlite_extensions[extension] = result.fetchone()[0]
else: else:
sqlite_extensions[extension] = None sqlite_extensions[extension] = None
except Exception: # noqa: BLE001, S110 except Exception:
# Probing for optional SQLite extensions - absence is the normal case
pass pass
# More details on SpatiaLite # More details on SpatiaLite
if "spatialite" in sqlite_extensions: if "spatialite" in sqlite_extensions:
spatialite_details = {} spatialite_details = {}
for fn in SPATIALITE_FUNCTIONS: for fn in SPATIALITE_FUNCTIONS:
try: try:
result = conn.execute(f"select {fn}()") result = conn.execute("select {}()".format(fn))
spatialite_details[fn] = result.fetchone()[0] spatialite_details[fn] = result.fetchone()[0]
except sqlite3.Error as e: except Exception as e:
spatialite_details[fn] = {"error": str(e)} spatialite_details[fn] = {"error": str(e)}
sqlite_extensions["spatialite"] = spatialite_details sqlite_extensions["spatialite"] = spatialite_details
@ -2195,7 +2209,9 @@ class Datasette:
fts_versions = [] fts_versions = []
for fts in ("FTS5", "FTS4", "FTS3"): for fts in ("FTS5", "FTS4", "FTS3"):
try: try:
conn.execute(f"CREATE VIRTUAL TABLE v{fts} USING {fts} (data)") conn.execute(
"CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(fts=fts)
)
fts_versions.append(fts) fts_versions.append(fts)
except sqlite3.OperationalError: except sqlite3.OperationalError:
continue continue
@ -2254,7 +2270,7 @@ class Datasette:
"static": p["static_path"] is not None, "static": p["static_path"] is not None,
"templates": p["templates_path"] is not None, "templates": p["templates_path"] is not None,
"version": p.get("version"), "version": p.get("version"),
"hooks": sorted(set(p["hooks"])), "hooks": list(sorted(set(p["hooks"]))),
} }
for p in ps for p in ps
] ]
@ -2330,15 +2346,13 @@ class Datasette:
async def render_template( async def render_template(
self, self,
templates: list[str] | str | Template, templates: List[str] | str | Template,
context: dict[str, Any] | Context | None = None, context: Dict[str, Any] | Context | None = None,
request: Request | None = None, request: Request | None = None,
view_name: str | None = None, view_name: str | None = None,
): ):
if not self._startup_invoked: if not self._startup_invoked:
raise RuntimeError( raise Exception("render_template() called before await ds.invoke_startup()")
"render_template() called before await ds.invoke_startup()"
)
context = context or {} context = context or {}
if isinstance(templates, Template): if isinstance(templates, Template):
template = templates template = templates
@ -2384,9 +2398,9 @@ class Datasette:
datasette=self, datasette=self,
): ):
extra_vars = await await_me_maybe(extra_vars) extra_vars = await await_me_maybe(extra_vars)
assert isinstance( assert isinstance(extra_vars, dict), "extra_vars is of type {}".format(
extra_vars, dict type(extra_vars)
), f"extra_vars is of type {type(extra_vars)}" )
extra_template_vars.update(extra_vars) extra_template_vars.update(extra_vars)
async def menu_links(): async def menu_links():
@ -2405,27 +2419,29 @@ class Datasette:
# the contract tests fail otherwise # the contract tests fail otherwise
template_context = { template_context = {
**context, **context,
"request": request, **{
"crumb_items": self._crumb_items, "request": request,
"urls": self.urls, "crumb_items": self._crumb_items,
"actor": request.actor if request else None, "urls": self.urls,
"menu_links": menu_links, "actor": request.actor if request else None,
"display_actor": display_actor, "menu_links": menu_links,
"show_logout": request is not None "display_actor": display_actor,
and "ds_actor" in request.cookies "show_logout": request is not None
and request.actor, and "ds_actor" in request.cookies
"zip": zip, and request.actor,
"body_scripts": body_scripts, "zip": zip,
"format_bytes": format_bytes, "body_scripts": body_scripts,
"show_messages": lambda: self._show_messages(request), "format_bytes": format_bytes,
"extra_css_urls": await self._asset_urls( "show_messages": lambda: self._show_messages(request),
"extra_css_urls", template, context, request, view_name "extra_css_urls": await self._asset_urls(
), "extra_css_urls", template, context, request, view_name
"extra_js_urls": await self._asset_urls( ),
"extra_js_urls", template, context, request, view_name "extra_js_urls": await self._asset_urls(
), "extra_js_urls", template, context, request, view_name
"base_url": self.setting("base_url"), ),
"datasette_version": __version__, "base_url": self.setting("base_url"),
"datasette_version": __version__,
},
**extra_template_vars, **extra_template_vars,
} }
if request and request.args.get("_context") and self.setting("template_debug"): if request and request.args.get("_context") and self.setting("template_debug"):
@ -2925,8 +2941,7 @@ class DatasetteRouter:
custom_response custom_response
), "Default forbidden() hook should have been called" ), "Default forbidden() hook should have been called"
return await custom_response.asgi_send(send) return await custom_response.asgi_send(send)
except Exception as exception: # noqa: BLE001 except Exception as exception:
# This IS the top-level error handler - it must catch everything
return await self.handle_exception(request, send, exception) return await self.handle_exception(request, send, exception)
async def handle_401(self, request, send, exception): async def handle_401(self, request, send, exception):
@ -2948,7 +2963,7 @@ class DatasetteRouter:
request.path.replace("~", "~7E").replace("%", "~").replace(".", "~2E") request.path.replace("~", "~7E").replace("%", "~").replace(".", "~2E")
) )
if request.query_string: if request.query_string:
new_path += f"?{request.query_string}" new_path += "?{}".format(request.query_string)
await asgi_send_redirect(send, new_path) await asgi_send_redirect(send, new_path)
return return
# If URL has a trailing slash, redirect to URL without it # If URL has a trailing slash, redirect to URL without it
@ -3158,7 +3173,8 @@ _curly_re = re.compile(r"({.*?})")
def route_pattern_from_filepath(filepath): def route_pattern_from_filepath(filepath):
# Drop the ".html" suffix # Drop the ".html" suffix
filepath = filepath.removesuffix(".html") if filepath.endswith(".html"):
filepath = filepath[: -len(".html")]
re_bits = ["/"] re_bits = ["/"]
for bit in _curly_re.split(filepath): for bit in _curly_re.split(filepath):
if _curly_re.match(bit): if _curly_re.match(bit):

View file

@ -1,8 +1,7 @@
import hashlib
from datasette import hookimpl from datasette import hookimpl
from datasette.utils.asgi import Response, BadRequest
from datasette.utils import to_css_class from datasette.utils import to_css_class
from datasette.utils.asgi import BadRequest, Response import hashlib
_BLOB_COLUMN = "_blob_column" _BLOB_COLUMN = "_blob_column"
_BLOB_HASH = "_blob_hash" _BLOB_HASH = "_blob_hash"

View file

@ -1,45 +1,43 @@
import asyncio import asyncio
import uvicorn
import click
from click import formatting
from click.types import CompositeParamType
from click_default_group import DefaultGroup
import functools import functools
import json import json
import os import os
import pathlib import pathlib
from runpy import run_module
import shutil import shutil
from subprocess import call
import sys import sys
import textwrap import textwrap
import webbrowser import webbrowser
from runpy import run_module
from subprocess import call
import click
import uvicorn
from click import formatting
from click.types import CompositeParamType
from click_default_group import DefaultGroup
from .app import ( from .app import (
Datasette,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
SETTINGS, SETTINGS,
SQLITE_LIMIT_ATTACHED, SQLITE_LIMIT_ATTACHED,
Datasette,
pm, pm,
) )
from .inspect import inspect_tables from .inspect import inspect_tables
from .utils import ( from .utils import (
ConnectionProblem,
LoadExtension, LoadExtension,
SpatialiteConnectionProblem,
SpatialiteNotFound,
StartupError, StartupError,
StaticMount,
ValueAsBooleanError,
check_connection, check_connection,
deep_dict_update, deep_dict_update,
find_spatialite, find_spatialite,
parse_metadata,
ConnectionProblem,
SpatialiteConnectionProblem,
initial_path_for_datasette, initial_path_for_datasette,
pairs_to_nested_config, pairs_to_nested_config,
parse_metadata,
temporary_docker_directory, temporary_docker_directory,
value_as_boolean, value_as_boolean,
SpatialiteNotFound,
StaticMount,
ValueAsBooleanError,
) )
from .utils.sqlite import sqlite3 from .utils.sqlite import sqlite3
from .utils.testing import TestClient from .utils.testing import TestClient
@ -77,7 +75,7 @@ class Setting(CompositeParamType):
# Datasette 1.0, we turn bare setting names into setting.name # Datasette 1.0, we turn bare setting names into setting.name
# Type checking for those older settings # Type checking for those older settings
default = DEFAULT_SETTINGS[name] default = DEFAULT_SETTINGS[name]
name = f"settings.{name}" name = "settings.{}".format(name)
if isinstance(default, bool): if isinstance(default, bool):
try: try:
return name, "true" if value_as_boolean(value) else "false" return name, "true" if value_as_boolean(value) else "false"
@ -173,6 +171,7 @@ async def inspect_(files, sqlite_extensions):
@cli.group() @cli.group()
def publish(): def publish():
"""Publish specified SQLite database files to the internet along with a Datasette-powered interface and API""" """Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"""
pass
# Register publish plugins # Register publish plugins
@ -579,27 +578,27 @@ def serve(
# https://github.com/simonw/datasette/issues/2389 # https://github.com/simonw/datasette/issues/2389
deep_dict_update(config_data, settings_updates) deep_dict_update(config_data, settings_updates)
kwargs = { kwargs = dict(
"immutables": immutable, immutables=immutable,
"cache_headers": not reload, cache_headers=not reload,
"cors": cors, cors=cors,
"inspect_data": inspect_data, inspect_data=inspect_data,
"config": config_data, config=config_data,
"metadata": metadata_data, metadata=metadata_data,
"sqlite_extensions": sqlite_extensions, sqlite_extensions=sqlite_extensions,
"template_dir": template_dir, template_dir=template_dir,
"plugins_dir": plugins_dir, plugins_dir=plugins_dir,
"static_mounts": static, static_mounts=static,
"settings": None, # These are passed in config= now settings=None, # These are passed in config= now
"memory": memory, memory=memory,
"secret": secret, secret=secret,
"version_note": version_note, version_note=version_note,
"pdb": pdb, pdb=pdb,
"crossdb": crossdb, crossdb=crossdb,
"nolock": nolock, nolock=nolock,
"internal": internal, internal=internal,
"default_deny": default_deny, default_deny=default_deny,
} )
# Separate directories from files # Separate directories from files
directories = [f for f in files if os.path.isdir(f)] directories = [f for f in files if os.path.isdir(f)]
@ -622,7 +621,9 @@ def serve(
conn.close() conn.close()
else: else:
raise click.ClickException( raise click.ClickException(
f"Invalid value for '[FILES]...': Path '{file}' does not exist." "Invalid value for '[FILES]...': Path '{}' does not exist.".format(
file
)
) )
# Check for duplicate files by resolving all paths to their absolute forms # Check for duplicate files by resolving all paths to their absolute forms
@ -683,7 +684,7 @@ def serve(
client = TestClient(ds) client = TestClient(ds)
request_headers = {} request_headers = {}
if token: if token:
request_headers["Authorization"] = f"Bearer {token}" request_headers["Authorization"] = "Bearer {}".format(token)
cookies = {} cookies = {}
if actor: if actor:
cookies["ds_actor"] = client.actor_cookie(json.loads(actor)) cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
@ -718,13 +719,9 @@ def serve(
path = run_sync(lambda: initial_path_for_datasette(ds)) path = run_sync(lambda: initial_path_for_datasette(ds))
url = f"http://{host}:{port}{path}" url = f"http://{host}:{port}{path}"
webbrowser.open(url) webbrowser.open(url)
uvicorn_kwargs = { uvicorn_kwargs = dict(
"host": host, host=host, port=port, log_level="info", lifespan="on", workers=1
"port": port, )
"log_level": "info",
"lifespan": "on",
"workers": 1,
}
if uds: if uds:
uvicorn_kwargs["uds"] = uds uvicorn_kwargs["uds"] = uds
if ssl_keyfile: if ssl_keyfile:
@ -888,7 +885,7 @@ async def check_databases(ds):
) )
except ConnectionProblem as e: except ConnectionProblem as e:
raise click.UsageError( raise click.UsageError(
f"Connection to {database.path} failed check: {e.args[0]!s}" f"Connection to {database.path} failed check: {str(e.args[0])}"
) )
# If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning # If --crossdb and more than SQLITE_LIMIT_ATTACHED show warning
if ( if (
@ -896,5 +893,9 @@ async def check_databases(ds):
and len([db for db in ds.databases.values() if not db.is_memory]) and len([db for db in ds.databases.values() if not db.is_memory])
> SQLITE_LIMIT_ATTACHED > SQLITE_LIMIT_ATTACHED
): ):
msg = f"Warning: --crossdb only works with the first {SQLITE_LIMIT_ATTACHED} attached databases" msg = (
"Warning: --crossdb only works with the first {} attached databases".format(
SQLITE_LIMIT_ATTACHED
)
)
click.echo(click.style(msg, bold=True, fg="yellow"), err=True) click.echo(click.style(msg, bold=True, fg="yellow"), err=True)

View file

@ -64,14 +64,14 @@ class ColumnType:
Return an HTML string to render this cell value, or None to Return an HTML string to render this cell value, or None to
fall through to the default render_cell plugin hook chain. fall through to the default render_cell plugin hook chain.
""" """
return return None
async def validate(self, value, datasette): async def validate(self, value, datasette):
""" """
Validate a value before it is written. Return None if valid, Validate a value before it is written. Return None if valid,
or a string error message if invalid. or a string error message if invalid.
""" """
return return None
async def transform_value(self, value, datasette): async def transform_value(self, value, datasette):
""" """

View file

@ -40,12 +40,12 @@ def _origin_tuple(value):
scheme = (parsed.scheme or "").lower() scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower() host = (parsed.hostname or "").lower()
if not scheme or not host: if not scheme or not host:
raise ValueError(f"missing scheme or host in {value!r}") raise ValueError("missing scheme or host in {!r}".format(value))
port = parsed.port # may raise ValueError on bad ports port = parsed.port # may raise ValueError on bad ports
if port is None: if port is None:
port = DEFAULT_PORTS.get(scheme) port = DEFAULT_PORTS.get(scheme)
if port is None: if port is None:
raise ValueError(f"unknown default port for scheme {scheme!r}") raise ValueError("unknown default port for scheme {!r}".format(scheme))
return scheme, host, port return scheme, host, port
@ -125,7 +125,9 @@ class CrossOriginProtectionMiddleware:
return return
await self._forbid( await self._forbid(
send, send,
f"Sec-Fetch-Site was {sec_fetch_site!r}, expected 'same-origin' or 'none'", "Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
sec_fetch_site
),
) )
return return
@ -139,11 +141,11 @@ class CrossOriginProtectionMiddleware:
request_scheme = self._request_scheme(scope) request_scheme = self._request_scheme(scope)
try: try:
origin_tuple = _origin_tuple(origin) origin_tuple = _origin_tuple(origin)
expected_tuple = _origin_tuple(f"{request_scheme}://{host}") expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
except ValueError: except ValueError:
await self._forbid( await self._forbid(
send, send,
f"Malformed Origin {origin!r} or Host {host!r}", "Malformed Origin {!r} or Host {!r}".format(origin, host),
) )
return return
@ -153,7 +155,7 @@ class CrossOriginProtectionMiddleware:
await self._forbid( await self._forbid(
send, send,
f"Origin {origin!r} does not match Host {host!r}", "Origin {!r} does not match Host {!r}".format(origin, host),
) )
def _request_scheme(self, scope): def _request_scheme(self, scope):
@ -161,8 +163,7 @@ class CrossOriginProtectionMiddleware:
try: try:
if self.datasette.setting("force_https_urls"): if self.datasette.setting("force_https_urls"):
return "https" return "https"
except Exception: # noqa: BLE001, S110 except Exception:
# Settings may not be readable this early; fall back to the ASGI scheme
pass pass
return scope.get("scheme") or "http" return scope.get("scheme") or "http"

View file

@ -1,18 +1,16 @@
import asyncio import asyncio
import atexit import atexit
from collections import namedtuple
import inspect import inspect
import os import os
from pathlib import Path
import queue import queue
import sqlite_utils
import sys import sys
import tempfile import tempfile
import threading import threading
import uuid import uuid
from collections import namedtuple
from pathlib import Path
import sqlite_utils
from .inspect import inspect_hash
from .tracer import trace from .tracer import trace
from .utils import ( from .utils import (
call_with_supported_arguments, call_with_supported_arguments,
@ -23,13 +21,14 @@ from .utils import (
get_all_foreign_keys, get_all_foreign_keys,
get_outbound_foreign_keys, get_outbound_foreign_keys,
md5_not_usedforsecurity, md5_not_usedforsecurity,
sqlite3,
sqlite_timelimit, sqlite_timelimit,
table_column_details, sqlite3,
table_columns, table_columns,
table_column_details,
) )
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
from .utils.sqlite import sqlite_hidden_table_names from .utils.sqlite import sqlite_hidden_table_names
from .inspect import inspect_hash
connections = threading.local() connections = threading.local()
@ -100,7 +99,9 @@ class Database:
def _check_not_closed(self): def _check_not_closed(self):
if self._closed: if self._closed:
raise DatasetteClosedError(f"Database {self.name!r} has been closed") raise DatasetteClosedError(
"Database {!r} has been closed".format(self.name)
)
def _remove_pending_execute_future(self, future): def _remove_pending_execute_future(self, future):
with self._pending_execute_futures_lock: with self._pending_execute_futures_lock:
@ -139,7 +140,7 @@ class Database:
if write: if write:
extra_kwargs["isolation_level"] = "IMMEDIATE" extra_kwargs["isolation_level"] = "IMMEDIATE"
if self.memory_name: if self.memory_name:
uri = f"file:{self.memory_name}?mode=memory&cache=shared" uri = "file:{}?mode=memory&cache=shared".format(self.memory_name)
conn = sqlite3.connect( conn = sqlite3.connect(
uri, uri=True, check_same_thread=False, **extra_kwargs uri, uri=True, check_same_thread=False, **extra_kwargs
) )
@ -192,20 +193,21 @@ class Database:
write_thread.join(timeout=10) write_thread.join(timeout=10)
if write_thread.is_alive(): if write_thread.is_alive():
sys.stderr.write( sys.stderr.write(
f"Datasette: write thread for {self.name!r} did not exit within 10s\n" "Datasette: write thread for {!r} did not exit within 10s\n".format(
self.name
)
) )
sys.stderr.flush() sys.stderr.flush()
for future in pending_execute_futures: for future in pending_execute_futures:
try: try:
future.result() future.result()
except Exception: # noqa: BLE001, S110 except Exception:
# Shutdown teardown - a failed pending write must not block close()
pass pass
# Close anything still tracked in _all_file_connections # Close anything still tracked in _all_file_connections
for connection in self._all_file_connections: for connection in self._all_file_connections:
try: try:
connection.close() connection.close()
except Exception: # noqa: BLE001, S110 except Exception:
pass pass
self._all_file_connections = [] self._all_file_connections = []
# Drop per-thread cached read connections we can reach # Drop per-thread cached read connections we can reach
@ -217,13 +219,13 @@ class Database:
if self._read_connection is not None: if self._read_connection is not None:
try: try:
self._read_connection.close() self._read_connection.close()
except Exception: # noqa: BLE001, S110 except Exception:
pass pass
self._read_connection = None self._read_connection = None
if self._write_connection is not None: if self._write_connection is not None:
try: try:
self._write_connection.close() self._write_connection.close()
except Exception: # noqa: BLE001, S110 except Exception:
pass pass
self._write_connection = None self._write_connection = None
if self.is_temp_disk: if self.is_temp_disk:
@ -369,8 +371,7 @@ class Database:
async def _dispatch_events_after_write(): async def _dispatch_events_after_write():
try: try:
await reply_future await reply_future
except Exception: # noqa: BLE001 except Exception:
# The write failed; skip success events regardless of why
# if the write failed, don't emit success events # if the write failed, don't emit success events
return return
for event in pending_events: for event in pending_events:
@ -423,7 +424,9 @@ class Database:
self._write_thread = threading.Thread( self._write_thread = threading.Thread(
target=self._execute_writes, daemon=True target=self._execute_writes, daemon=True
) )
self._write_thread.name = f"_execute_writes for database {self.name}" self._write_thread.name = "_execute_writes for database {}".format(
self.name
)
self._write_thread.start() self._write_thread.start()
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io") task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@ -444,8 +447,7 @@ class Database:
try: try:
conn = self.connect(write=True) conn = self.connect(write=True)
self.ds._prepare_connection(conn, self.name) self.ds._prepare_connection(conn, self.name)
except Exception as e: # noqa: BLE001 except Exception as e:
# Stored and re-raised to whoever queues the next write
conn_exception = e conn_exception = e
while True: while True:
task = self._write_queue.get() task = self._write_queue.get()
@ -453,8 +455,7 @@ class Database:
if conn is not None: if conn is not None:
try: try:
conn.close() conn.close()
except Exception: # noqa: BLE001, S110 except Exception:
# Best-effort close as the write thread exits
pass pass
return return
exception = None exception = None
@ -473,9 +474,8 @@ class Database:
except ValueError: except ValueError:
# Was probably a memory connection # Was probably a memory connection
pass pass
except Exception as e: # noqa: BLE001 except Exception as e:
# Write thread must survive any task failure or the database wedges sys.stderr.write("{}\n".format(e))
sys.stderr.write(f"{e}\n")
sys.stderr.flush() sys.stderr.flush()
exception = e exception = e
else: else:
@ -486,8 +486,8 @@ class Database:
result = task.fn(conn) result = task.fn(conn)
else: else:
result = task.fn(conn) result = task.fn(conn)
except Exception as e: # noqa: BLE001 except Exception as e:
sys.stderr.write(f"{e}\n") sys.stderr.write("{}\n".format(e))
sys.stderr.flush() sys.stderr.flush()
exception = e exception = e
_deliver_write_result(task, result, exception) _deliver_write_result(task, result, exception)
@ -554,7 +554,9 @@ class Database:
raise QueryInterrupted(e, sql, params) raise QueryInterrupted(e, sql, params)
if log_sql_errors: if log_sql_errors:
sys.stderr.write( sys.stderr.write(
f"ERROR: conn={conn}, sql = {sql!r}, params = {params}: {e}\n" "ERROR: conn={}, sql = {}, params = {}: {}\n".format(
conn, repr(sql), params, e
)
) )
sys.stderr.flush() sys.stderr.flush()
raise raise
@ -711,9 +713,9 @@ class Database:
column_names column_names
and len(column_names) == 2 and len(column_names) == 2
and ("id" in column_names or "pk" in column_names) and ("id" in column_names or "pk" in column_names)
and set(column_names) != {"id", "pk"} and not set(column_names) == {"id", "pk"}
): ):
return next(c for c in column_names if c not in ("id", "pk")) return [c for c in column_names if c not in ("id", "pk")][0]
# Couldn't find a label: # Couldn't find a label:
return None return None
@ -855,10 +857,10 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
class WriteTask: class WriteTask:
__slots__ = ( __slots__ = (
"fn", "fn",
"isolated_connection", "task_id",
"loop", "loop",
"reply_future", "reply_future",
"task_id", "isolated_connection",
"transaction", "transaction",
) )
@ -899,7 +901,7 @@ class QueryInterrupted(Exception):
self.params = params self.params = params
def __str__(self): def __str__(self):
return f"QueryInterrupted: {self.e}" return "QueryInterrupted: {}".format(self.e)
class MultipleValues(Exception): class MultipleValues(Exception):

View file

@ -2,8 +2,8 @@ from datasette import hookimpl
from datasette.permissions import Action from datasette.permissions import Action
from datasette.resources import ( from datasette.resources import (
DatabaseResource, DatabaseResource,
QueryResource,
TableResource, TableResource,
QueryResource,
) )

View file

@ -1,9 +1,8 @@
from datasette import hookimpl
import datetime import datetime
import os import os
import time import time
from datasette import hookimpl
def header(key, request): def header(key, request):
key = key.replace("_", "-").encode("utf-8") key = key.replace("_", "-").encode("utf-8")

View file

@ -17,29 +17,18 @@ UNION/INTERSECT operations. The order of evaluation is:
from __future__ import annotations from __future__ import annotations
from .config import config_permissions_sql as config_permissions_sql
from .defaults import (
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)
from .defaults import (
default_action_permissions_sql as default_action_permissions_sql,
)
from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check,
)
from .defaults import (
default_query_permissions_sql as default_query_permissions_sql,
)
from .restrictions import (
ActorRestrictions as ActorRestrictions,
)
# Re-export all hooks and public utilities # Re-export all hooks and public utilities
from .restrictions import ( from .restrictions import (
actor_restrictions_sql as actor_restrictions_sql, actor_restrictions_sql as actor_restrictions_sql,
)
from .restrictions import (
restrictions_allow_action as restrictions_allow_action, restrictions_allow_action as restrictions_allow_action,
ActorRestrictions as ActorRestrictions,
) )
from .root import root_user_permissions_sql as root_user_permissions_sql from .root import root_user_permissions_sql as root_user_permissions_sql
from .config import config_permissions_sql as config_permissions_sql
from .defaults import (
# Avoid "datasette.default_permissions" does not explicitly export attribute
default_allow_sql_check as default_allow_sql_check,
default_action_permissions_sql as default_action_permissions_sql,
default_query_permissions_sql as default_query_permissions_sql,
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
)

View file

@ -6,7 +6,7 @@ Applies permission rules from datasette.yaml configuration.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -55,8 +55,8 @@ class ConfigPermissionProcessor:
def __init__( def __init__(
self, self,
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
): ):
self.datasette = datasette self.datasette = datasette
@ -74,8 +74,8 @@ class ConfigPermissionProcessor:
self.restrictions = actor.get("_r", {}) if actor else {} self.restrictions = actor.get("_r", {}) if actor else {}
# Pre-compute restriction info for efficiency # Pre-compute restriction info for efficiency
self.restricted_databases: set[str] = set() self.restricted_databases: Set[str] = set()
self.restricted_tables: set[tuple[str, str]] = set() self.restricted_tables: Set[Tuple[str, str]] = set()
if self.has_restrictions: if self.has_restrictions:
self.restricted_databases = { self.restricted_databases = {
@ -92,7 +92,7 @@ class ConfigPermissionProcessor:
# Tables implicitly reference their parent databases # Tables implicitly reference their parent databases
self.restricted_databases.update(db for db, _ in self.restricted_tables) self.restricted_databases.update(db for db, _ in self.restricted_tables)
def evaluate_allow_block(self, allow_block: Any) -> bool | None: def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
"""Evaluate an allow block against the current actor.""" """Evaluate an allow block against the current actor."""
if allow_block is None: if allow_block is None:
return None return None
@ -104,8 +104,8 @@ class ConfigPermissionProcessor:
def is_in_restriction_allowlist( def is_in_restriction_allowlist(
self, self,
parent: str | None, parent: Optional[str],
child: str | None, child: Optional[str],
) -> bool: ) -> bool:
"""Check if resource is allowed by actor restrictions.""" """Check if resource is allowed by actor restrictions."""
if not self.has_restrictions: if not self.has_restrictions:
@ -147,9 +147,9 @@ class ConfigPermissionProcessor:
def add_permissions_rule( def add_permissions_rule(
self, self,
parent: str | None, parent: Optional[str],
child: str | None, child: Optional[str],
permissions_block: dict | None, permissions_block: Optional[dict],
scope_desc: str, scope_desc: str,
) -> None: ) -> None:
"""Add a rule from a permissions:{action} block.""" """Add a rule from a permissions:{action} block."""
@ -169,8 +169,8 @@ class ConfigPermissionProcessor:
def add_allow_block_rule( def add_allow_block_rule(
self, self,
parent: str | None, parent: Optional[str],
child: str | None, child: Optional[str],
allow_block: Any, allow_block: Any,
scope_desc: str, scope_desc: str,
) -> None: ) -> None:
@ -202,8 +202,8 @@ class ConfigPermissionProcessor:
def _add_restriction_gate_denies( def _add_restriction_gate_denies(
self, self,
parent: str | None, parent: Optional[str],
child: str | None, child: Optional[str],
is_allowed: bool, is_allowed: bool,
scope_desc: str, scope_desc: str,
) -> None: ) -> None:
@ -235,7 +235,7 @@ class ConfigPermissionProcessor:
if db_name == parent: if db_name == parent:
self.collector.add(db_name, table_name, False, reason) self.collector.add(db_name, table_name, False, reason)
def process(self) -> PermissionSQL | None: def process(self) -> Optional[PermissionSQL]:
"""Process all config rules and return combined PermissionSQL.""" """Process all config rules and return combined PermissionSQL."""
self._process_root_permissions() self._process_root_permissions()
self._process_databases() self._process_databases()
@ -425,10 +425,10 @@ class ConfigPermissionProcessor:
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def config_permissions_sql( async def config_permissions_sql(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
) -> list[PermissionSQL] | None: ) -> Optional[List[PermissionSQL]]:
""" """
Apply permission rules from datasette.yaml configuration. Apply permission rules from datasette.yaml configuration.

View file

@ -6,7 +6,7 @@ Provides default allow rules for standard view/execute actions.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -29,28 +29,29 @@ DEFAULT_ALLOW_ACTIONS = frozenset(
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def default_allow_sql_check( async def default_allow_sql_check(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
) -> PermissionSQL | None: ) -> Optional[PermissionSQL]:
""" """
Enforce the default_allow_sql setting. Enforce the default_allow_sql setting.
When default_allow_sql is false (the default), execute-sql is denied When default_allow_sql is false (the default), execute-sql is denied
unless explicitly allowed by config or other rules. unless explicitly allowed by config or other rules.
""" """
if action == "execute-sql" and not datasette.setting("default_allow_sql"): if action == "execute-sql":
return PermissionSQL.deny(reason="default_allow_sql is false") if not datasette.setting("default_allow_sql"):
return PermissionSQL.deny(reason="default_allow_sql is false")
return None return None
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def default_action_permissions_sql( async def default_action_permissions_sql(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
) -> PermissionSQL | None: ) -> Optional[PermissionSQL]:
""" """
Provide default allow rules for standard view/execute actions. Provide default allow rules for standard view/execute actions.
@ -70,10 +71,10 @@ async def default_action_permissions_sql(
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def default_query_permissions_sql( async def default_query_permissions_sql(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
) -> PermissionSQL | None: ) -> Optional[PermissionSQL]:
actor_id = actor.get("id") if isinstance(actor, dict) else None actor_id = actor.get("id") if isinstance(actor, dict) else None
if action not in {"view-query", "update-query", "delete-query"}: if action not in {"view-query", "update-query", "delete-query"}:

View file

@ -5,7 +5,7 @@ Shared helper utilities for default permission implementations.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, List, Optional, Set
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -13,7 +13,7 @@ if TYPE_CHECKING:
from datasette.permissions import PermissionSQL from datasette.permissions import PermissionSQL
def get_action_name_variants(datasette: Datasette, action: str) -> set[str]: def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
""" """
Get all name variants for an action (full name and abbreviation). Get all name variants for an action (full name and abbreviation).
@ -27,7 +27,7 @@ def get_action_name_variants(datasette: Datasette, action: str) -> set[str]:
return variants return variants
def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool: def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
"""Check if an action (or its abbreviation) is in a list.""" """Check if an action (or its abbreviation) is in a list."""
return bool(get_action_name_variants(datasette, action).intersection(action_list)) return bool(get_action_name_variants(datasette, action).intersection(action_list))
@ -36,8 +36,8 @@ def action_in_list(datasette: Datasette, action: str, action_list: list) -> bool
class PermissionRow: class PermissionRow:
"""A single permission rule row.""" """A single permission rule row."""
parent: str | None parent: Optional[str]
child: str | None child: Optional[str]
allow: bool allow: bool
reason: str reason: str
@ -46,14 +46,14 @@ class PermissionRowCollector:
"""Collects permission rows and converts them to PermissionSQL.""" """Collects permission rows and converts them to PermissionSQL."""
def __init__(self, prefix: str = "row"): def __init__(self, prefix: str = "row"):
self.rows: list[PermissionRow] = [] self.rows: List[PermissionRow] = []
self.prefix = prefix self.prefix = prefix
def add( def add(
self, self,
parent: str | None, parent: Optional[str],
child: str | None, child: Optional[str],
allow: bool | None, allow: Optional[bool],
reason: str, reason: str,
if_not_none: bool = False, if_not_none: bool = False,
) -> None: ) -> None:
@ -62,7 +62,7 @@ class PermissionRowCollector:
return return
self.rows.append(PermissionRow(parent, child, allow, reason)) self.rows.append(PermissionRow(parent, child, allow, reason))
def to_permission_sql(self) -> PermissionSQL | None: def to_permission_sql(self) -> Optional[PermissionSQL]:
"""Convert collected rows to a PermissionSQL object.""" """Convert collected rows to a PermissionSQL object."""
if not self.rows: if not self.rows:
return None return None

View file

@ -8,7 +8,7 @@ contains allowlists of resources the actor can access.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, List, Optional, Set, Tuple
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -23,12 +23,12 @@ from .helpers import action_in_list, get_action_name_variants
class ActorRestrictions: class ActorRestrictions:
"""Parsed actor restrictions from the _r key.""" """Parsed actor restrictions from the _r key."""
global_actions: list[str] # _r.a - globally allowed actions global_actions: List[str] # _r.a - globally allowed actions
database_actions: dict # _r.d - {db_name: [actions]} database_actions: dict # _r.d - {db_name: [actions]}
table_actions: dict # _r.r - {db_name: {table: [actions]}} table_actions: dict # _r.r - {db_name: {table: [actions]}}
@classmethod @classmethod
def from_actor(cls, actor: dict | None) -> ActorRestrictions | None: def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
"""Parse restrictions from actor dict. Returns None if no restrictions.""" """Parse restrictions from actor dict. Returns None if no restrictions."""
if not actor: if not actor:
return None return None
@ -44,11 +44,11 @@ class ActorRestrictions:
table_actions=restrictions.get("r", {}), table_actions=restrictions.get("r", {}),
) )
def is_action_globally_allowed(self, datasette: Datasette, action: str) -> bool: def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
"""Check if action is in the global allowlist.""" """Check if action is in the global allowlist."""
return action_in_list(datasette, action, self.global_actions) return action_in_list(datasette, action, self.global_actions)
def get_allowed_databases(self, datasette: Datasette, action: str) -> set[str]: def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
"""Get database names where this action is allowed.""" """Get database names where this action is allowed."""
allowed = set() allowed = set()
for db_name, db_actions in self.database_actions.items(): for db_name, db_actions in self.database_actions.items():
@ -57,8 +57,8 @@ class ActorRestrictions:
return allowed return allowed
def get_allowed_tables( def get_allowed_tables(
self, datasette: Datasette, action: str self, datasette: "Datasette", action: str
) -> set[tuple[str, str]]: ) -> Set[Tuple[str, str]]:
"""Get (database, table) pairs where this action is allowed.""" """Get (database, table) pairs where this action is allowed."""
allowed = set() allowed = set()
for db_name, tables in self.table_actions.items(): for db_name, tables in self.table_actions.items():
@ -70,10 +70,10 @@ class ActorRestrictions:
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def actor_restrictions_sql( async def actor_restrictions_sql(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
action: str, action: str,
) -> list[PermissionSQL] | None: ) -> Optional[List[PermissionSQL]]:
""" """
Handle actor restriction-based permission rules. Handle actor restriction-based permission rules.
@ -140,10 +140,10 @@ async def actor_restrictions_sql(
def restrictions_allow_action( def restrictions_allow_action(
datasette: Datasette, datasette: "Datasette",
restrictions: dict, restrictions: dict,
action: str, action: str,
resource: str | tuple[str, str] | None, resource: Optional[str | Tuple[str, str]],
) -> bool: ) -> bool:
""" """
Check if restrictions allow the requested action on the requested resource. Check if restrictions allow the requested action on the requested resource.

View file

@ -6,7 +6,7 @@ Grants full permissions to the root user when --root flag is used.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -17,9 +17,9 @@ from datasette.permissions import PermissionSQL
@hookimpl(specname="permission_resources_sql") @hookimpl(specname="permission_resources_sql")
async def root_user_permissions_sql( async def root_user_permissions_sql(
datasette: Datasette, datasette: "Datasette",
actor: dict | None, actor: Optional[dict],
) -> PermissionSQL | None: ) -> Optional[PermissionSQL]:
""" """
Grant root user full permissions when --root flag is used. Grant root user full permissions when --root flag is used.
""" """

View file

@ -7,7 +7,7 @@ to datasette.verify_token() so all registered handlers are tried.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from datasette.app import Datasette from datasette.app import Datasette
@ -17,13 +17,15 @@ from datasette.tokens import SignedTokenHandler
@hookimpl @hookimpl
def register_token_handler(datasette: Datasette): def register_token_handler(datasette: "Datasette"):
"""Register the default signed token handler.""" """Register the default signed token handler."""
return SignedTokenHandler() return SignedTokenHandler()
@hookimpl(specname="actor_from_request") @hookimpl(specname="actor_from_request")
async def actor_from_signed_api_token(datasette: Datasette, request) -> dict | None: async def actor_from_signed_api_token(
datasette: "Datasette", request
) -> Optional[dict]:
""" """
Authenticate requests using API tokens by delegating to all registered Authenticate requests using API tokens by delegating to all registered
token handlers via datasette.verify_token(). token handlers via datasette.verify_token().

View file

@ -20,7 +20,7 @@ def table_actions(datasette, actor, database, table, request):
"label": "Alter table", "label": "Alter table",
"description": "Change columns and primary key for this table.", "description": "Change columns and primary key for this table.",
"attrs": { "attrs": {
"aria-label": f"Alter table {table}", "aria-label": "Alter table {}".format(table),
"data-table-action": "alter-table", "data-table-action": "alter-table",
}, },
} }

View file

@ -1,8 +1,7 @@
from abc import ABC, abstractproperty from abc import ABC, abstractproperty
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from datasette.hookspecs import hookimpl from datasette.hookspecs import hookimpl
from datetime import datetime, timezone
@dataclass @dataclass

View file

@ -1,13 +1,12 @@
import json import json
import urllib import urllib
from datasette import hookimpl from datasette import hookimpl
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from datasette.utils import ( from datasette.utils import (
detect_json1,
escape_sqlite, escape_sqlite,
path_with_added_args, path_with_added_args,
path_with_removed_args, path_with_removed_args,
detect_json1,
sqlite3, sqlite3,
) )
@ -31,7 +30,7 @@ def load_facet_configs(request, table_config):
assert ( assert (
len(facet_config.values()) == 1 len(facet_config.values()) == 1
), "Metadata config dicts should be {type: config}" ), "Metadata config dicts should be {type: config}"
type, facet_config = next(iter(facet_config.items())) type, facet_config = list(facet_config.items())[0]
if isinstance(facet_config, str): if isinstance(facet_config, str):
facet_config = {"simple": facet_config} facet_config = {"simple": facet_config}
facet_configs.setdefault(type, []).append( facet_configs.setdefault(type, []).append(
@ -161,13 +160,18 @@ class ColumnFacet(Facet):
for column in columns: for column in columns:
if column in already_enabled: if column in already_enabled:
continue continue
suggested_facet_sql = f""" suggested_facet_sql = """
with limited as (select * from ({self.sql}) limit {self.suggest_consider}) with limited as (select * from ({sql}) limit {suggest_consider})
select {escape_sqlite(column)} as value, count(*) as n from limited select {column} as value, count(*) as n from limited
where value is not null where value is not null
group by value group by value
limit {facet_size + 1} limit {limit}
""" """.format(
column=escape_sqlite(column),
sql=self.sql,
limit=facet_size + 1,
suggest_consider=self.suggest_consider,
)
distinct_values = None distinct_values = None
try: try:
distinct_values = await self.ds.execute( distinct_values = await self.ds.execute(
@ -263,7 +267,7 @@ class ColumnFacet(Facet):
for row in facet_rows: for row in facet_rows:
column_qs = column column_qs = column
if column.startswith("_"): if column.startswith("_"):
column_qs = f"{column}__exact" column_qs = "{}__exact".format(column)
selected = (column_qs, str(row["value"])) in qs_pairs selected = (column_qs, str(row["value"])) in qs_pairs
if selected: if selected:
toggle_path = path_with_removed_args( toggle_path = path_with_removed_args(
@ -338,12 +342,12 @@ class ArrayFacet(Facet):
for v in await self.ds.execute( for v in await self.ds.execute(
self.database, self.database,
( (
f"select {escape_sqlite(column)} from ({self.sql}) " "select {column} from ({sql}) "
f"where {escape_sqlite(column)} is not null " "where {column} is not null "
f"and {escape_sqlite(column)} != '' " "and {column} != '' "
f"and json_array_length({escape_sqlite(column)}) > 0 " "and json_array_length({column}) > 0 "
"limit 100" "limit 100"
), ).format(column=escape_sqlite(column), sql=self.sql),
self.params, self.params,
truncate=False, truncate=False,
custom_time_limit=self.ds.setting( custom_time_limit=self.ds.setting(
@ -384,14 +388,14 @@ class ArrayFacet(Facet):
source = source_and_config["source"] source = source_and_config["source"]
column = config.get("column") or config["simple"] column = config.get("column") or config["simple"]
# https://github.com/simonw/datasette/issues/448 # https://github.com/simonw/datasette/issues/448
facet_sql = f""" facet_sql = """
with inner as ({self.sql}), with inner as ({sql}),
deduped_array_items as ( deduped_array_items as (
select select
distinct j.value, distinct j.value,
inner.* inner.*
from from
json_each([inner].{escape_sqlite(column)}) j json_each([inner].{col}) j
join inner join inner
) )
select select
@ -402,8 +406,12 @@ class ArrayFacet(Facet):
group by group by
value value
order by order by
count(*) desc, value limit {facet_size + 1} count(*) desc, value limit {limit}
""" """.format(
col=escape_sqlite(column),
sql=self.sql,
limit=facet_size + 1,
)
try: try:
facet_rows_results = await self.ds.execute( facet_rows_results = await self.ds.execute(
self.database, self.database,

View file

@ -1,11 +1,8 @@
import json
from typing import ClassVar
from datasette import hookimpl from datasette import hookimpl
from datasette.resources import DatabaseResource from datasette.resources import DatabaseResource
from datasette.utils.asgi import BadRequest
from datasette.views.base import DatasetteError from datasette.views.base import DatasetteError
from datasette.utils.asgi import BadRequest
import json
from .utils import detect_json1, escape_sqlite, path_with_removed_args from .utils import detect_json1, escape_sqlite, path_with_removed_args
@ -102,9 +99,9 @@ def search_filters(request, database, table, datasette):
fts_table=escape_sqlite(fts_table), fts_table=escape_sqlite(fts_table),
search_col=escape_sqlite(search_col), search_col=escape_sqlite(search_col),
match_clause=( match_clause=(
f":search_{i}" ":search_{}".format(i)
if search_mode_raw if search_mode_raw
else f"escape_fts(:search_{i})" else "escape_fts(:search_{})".format(i)
), ),
) )
) )
@ -137,11 +134,11 @@ def through_filters(request, database, table, datasette):
value = through_data["value"] value = through_data["value"]
db = datasette.get_database(database) db = datasette.get_database(database)
outgoing_foreign_keys = await db.foreign_keys_for_table(through_table) outgoing_foreign_keys = await db.foreign_keys_for_table(through_table)
fk_to_us = next( try:
(fk for fk in outgoing_foreign_keys if fk["other_table"] == table), fk_to_us = [
None, fk for fk in outgoing_foreign_keys if fk["other_table"] == table
) ][0]
if fk_to_us is None: except IndexError:
raise DatasetteError( raise DatasetteError(
"Invalid _through - could not find corresponding foreign key" "Invalid _through - could not find corresponding foreign key"
) )
@ -368,7 +365,7 @@ class Filters:
), ),
] ]
) )
_filters_by_key: ClassVar[dict[str, Filter]] = {f.key: f for f in _filters} _filters_by_key = {f.key: f for f in _filters}
def __init__(self, pairs): def __init__(self, pairs):
self.pairs = pairs self.pairs = pairs

View file

@ -1,10 +1,9 @@
from datasette.utils.sqlite import sqlite3
from datasette.utils import documented
import itertools import itertools
import random import random
import string import string
from datasette.utils import documented
from datasette.utils.sqlite import sqlite3
__all__ = [ __all__ = [
"EXTRA_DATABASE_SQL", "EXTRA_DATABASE_SQL",
"TABLES", "TABLES",
@ -347,7 +346,9 @@ CREATE VIEW searchable_view_configured_by_metadata AS
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n' + '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
+ "\n".join( + "\n".join(
[ [
f'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");' 'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format(
a=a, b=b, c=c, content=content
)
for a, b, c, content in generate_compound_rows(1001) for a, b, c, content in generate_compound_rows(1001)
] ]
) )

View file

@ -1,5 +1,4 @@
from datasette import Response, hookimpl from datasette import hookimpl, Response
from .utils import add_cors_headers from .utils import add_cors_headers

View file

@ -1,21 +1,16 @@
import traceback from datasette import hookimpl, Response
from markupsafe import Markup
from datasette import Response, hookimpl
from .utils import add_cors_headers, error_body from .utils import add_cors_headers, error_body
from .utils.asgi import ( from .utils.asgi import (
Base400, Base400,
) )
from .views.base import DatasetteError from .views.base import DatasetteError
from markupsafe import Markup
import traceback
# Debugger imports are deliberate - they back the "pdb" setting, which drops
# into a debugger on unhandled exceptions
try: try:
import ipdb as pdb # noqa: T100 import ipdb as pdb
except ImportError: except ImportError:
import pdb # noqa: T100 import pdb
try: try:
import rich import rich
@ -74,7 +69,7 @@ def handle_exception(datasette, request, exception):
dict( dict(
info, info,
urls=datasette.urls, urls=datasette.urls,
menu_links=list, menu_links=lambda: [],
) )
), ),
status=status, status=status,

View file

@ -1,4 +1,5 @@
from pluggy import HookimplMarker, HookspecMarker from pluggy import HookimplMarker
from pluggy import HookspecMarker
hookspec = HookspecMarker("datasette") hookspec = HookspecMarker("datasette")
hookimpl = HookimplMarker("datasette") hookimpl = HookimplMarker("datasette")

View file

@ -1,13 +1,13 @@
import hashlib import hashlib
from .utils import ( from .utils import (
detect_spatialite,
detect_fts, detect_fts,
detect_primary_keys, detect_primary_keys,
detect_spatialite,
escape_sqlite, escape_sqlite,
get_all_foreign_keys, get_all_foreign_keys,
sqlite3,
table_columns, table_columns,
sqlite3,
) )
HASH_BLOCK_SIZE = 1024 * 1024 HASH_BLOCK_SIZE = 1024 * 1024
@ -95,10 +95,10 @@ def inspect_tables(conn, database_metadata):
""") """)
] ]
for t, table_info in tables.items(): for t in tables.keys():
for hidden_table in hidden_tables: for hidden_table in hidden_tables:
if t == hidden_table or t.startswith(hidden_table): if t == hidden_table or t.startswith(hidden_table):
table_info["hidden"] = True tables[t]["hidden"] = True
continue continue
return tables return tables

View file

@ -21,7 +21,7 @@ class JumpSQL:
search_text: str | None = None, search_text: str | None = None,
display_name: str | None = None, display_name: str | None = None,
item_type: str = "menu", item_type: str = "menu",
) -> JumpSQL: ) -> "JumpSQL":
if search_text is None: if search_text is None:
search_text = " ".join( search_text = " ".join(
text for text in (label, display_name, description) if text is not None text for text in (label, display_name, description) if text is not None

View file

@ -1,7 +1,7 @@
import contextvars
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, NamedTuple from typing import Any, NamedTuple
import contextvars
# Context variable to track when permission checks should be skipped # Context variable to track when permission checks should be skipped
_skip_permission_checks = contextvars.ContextVar( _skip_permission_checks = contextvars.ContextVar(
@ -72,8 +72,8 @@ class Resource(ABC):
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return "{}(parent={!r}, child={!r})".format(
f"{self.__class__.__name__}(parent={self.parent!r}, child={self.child!r})" self.__class__.__name__, self.parent, self.child
) )
@property @property
@ -129,6 +129,7 @@ class Resource(ABC):
Must return two columns: parent, child Must return two columns: parent, child
""" """
pass
class AllowedResource(NamedTuple): class AllowedResource(NamedTuple):

View file

@ -1,14 +1,20 @@
import importlib import importlib
import importlib.metadata as importlib_metadata
import importlib.resources as importlib_resources
import os import os
import sys
from pprint import pprint
import pluggy import pluggy
from pprint import pprint
import sys
from . import hookspecs from . import hookspecs
if sys.version_info >= (3, 9):
import importlib.resources as importlib_resources
else:
import importlib_resources
if sys.version_info >= (3, 10):
import importlib.metadata as importlib_metadata
else:
import importlib_metadata
DEFAULT_PLUGINS = ( DEFAULT_PLUGINS = (
"datasette.publish.heroku", "datasette.publish.heroku",
"datasette.publish.cloudrun", "datasette.publish.cloudrun",
@ -79,7 +85,7 @@ if DATASETTE_LOAD_PLUGINS is not None:
# Ensure name can be found in plugin_to_distinfo later: # Ensure name can be found in plugin_to_distinfo later:
pm._plugin_distinfo.append((mod, distribution)) pm._plugin_distinfo.append((mod, distribution))
except importlib_metadata.PackageNotFoundError: except importlib_metadata.PackageNotFoundError:
sys.stderr.write(f"Plugin {package_name} could not be found\n") sys.stderr.write("Plugin {} could not be found\n".format(package_name))
# Load default plugins # Load default plugins

View file

@ -1,17 +1,15 @@
from datasette import hookimpl
import click
import json import json
import os import os
import re import re
from subprocess import CalledProcessError, check_call, check_output from subprocess import CalledProcessError, check_call, check_output
import click
from datasette import hookimpl
from ..utils import temporary_docker_directory
from .common import ( from .common import (
add_common_publish_arguments_and_options, add_common_publish_arguments_and_options,
fail_if_publish_binary_not_installed, fail_if_publish_binary_not_installed,
) )
from ..utils import temporary_docker_directory
@hookimpl @hookimpl
@ -221,7 +219,7 @@ def publish_subcommand(publish):
check_call( check_call(
"gcloud builds submit --tag {}{}".format( "gcloud builds submit --tag {}{}".format(
image_id, f" --timeout {timeout}" if timeout else "" image_id, " --timeout {}".format(timeout) if timeout else ""
), ),
shell=True, shell=True,
) )
@ -233,7 +231,7 @@ def publish_subcommand(publish):
("--min-instances", min_instances), ("--min-instances", min_instances),
): ):
if value is not None: if value is not None:
extra_deploy_options.append(f"{option} {value}") extra_deploy_options.append("{} {}".format(option, value))
check_call( check_call(
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format( "gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
image_id, image_id,
@ -260,16 +258,24 @@ def _ensure_artifact_registry(artifact_project, artifact_region, artifact_reposi
) from exc ) from exc
describe_cmd = ( describe_cmd = (
f"gcloud artifacts repositories describe {artifact_repository} --project {artifact_project} " "gcloud artifacts repositories describe {repo} --project {project} "
f"--location {artifact_region} --quiet" "--location {location} --quiet"
).format(
repo=artifact_repository,
project=artifact_project,
location=artifact_region,
) )
try: try:
check_call(describe_cmd, shell=True) check_call(describe_cmd, shell=True)
return return
except CalledProcessError: except CalledProcessError:
create_cmd = ( create_cmd = (
f"gcloud artifacts repositories create {artifact_repository} --repository-format=docker " "gcloud artifacts repositories create {repo} --repository-format=docker "
f'--location {artifact_region} --project {artifact_project} --description "Datasette Cloud Run images" --quiet' '--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
).format(
repo=artifact_repository,
location=artifact_region,
project=artifact_project,
) )
try: try:
check_call(create_cmd, shell=True) check_call(create_cmd, shell=True)

View file

@ -1,11 +1,9 @@
from ..utils import StaticMount
import click
import os import os
import shutil import shutil
import sys import sys
import click
from ..utils import StaticMount
def add_common_publish_arguments_and_options(subcommand): def add_common_publish_arguments_and_options(subcommand):
for decorator in reversed( for decorator in reversed(
@ -78,7 +76,9 @@ def fail_if_publish_binary_not_installed(binary, publish_target, install_link):
"""Exit (with error message) if ``binary` isn't installed""" """Exit (with error message) if ``binary` isn't installed"""
if not shutil.which(binary): if not shutil.which(binary):
click.secho( click.secho(
f"Publishing to {publish_target} requires {binary} to be installed and configured", "Publishing to {publish_target} requires {binary} to be installed and configured".format(
publish_target=publish_target, binary=binary
),
bg="red", bg="red",
fg="white", fg="white",
bold=True, bold=True,

View file

@ -1,21 +1,19 @@
from contextlib import contextmanager
from datasette import hookimpl
import click
import json import json
import os import os
import pathlib import pathlib
import shlex import shlex
import shutil import shutil
import tempfile
from contextlib import contextmanager
from subprocess import call, check_output from subprocess import call, check_output
import tempfile
import click
from datasette import hookimpl
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
from .common import ( from .common import (
add_common_publish_arguments_and_options, add_common_publish_arguments_and_options,
fail_if_publish_binary_not_installed, fail_if_publish_binary_not_installed,
) )
from datasette.utils import link_or_copy, link_or_copy_directory, parse_metadata
@hookimpl @hookimpl
@ -236,7 +234,7 @@ def temporary_heroku_directory(
extras.extend(["--static", f"{mount_point}:{mount_point}"]) extras.extend(["--static", f"{mount_point}:{mount_point}"])
quoted_files = " ".join( quoted_files = " ".join(
[f"-i {shlex.quote(file_name)}" for file_name in file_names] ["-i {}".format(shlex.quote(file_name)) for file_name in file_names]
) )
procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format( procfile_cmd = "web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json {extras}".format(
quoted_files=quoted_files, extras=" ".join(extras) quoted_files=quoted_files, extras=" ".join(extras)

View file

@ -1,13 +1,12 @@
import json import json
from datasette.extras import extra_names_from_request from datasette.extras import extra_names_from_request
from datasette.utils import ( from datasette.utils import (
CustomJSONEncoder,
error_body, error_body,
path_from_row_pks,
remove_infinites,
sqlite3,
value_as_boolean, value_as_boolean,
remove_infinites,
CustomJSONEncoder,
path_from_row_pks,
sqlite3,
) )
from datasette.utils.asgi import Response from datasette.utils.asgi import Response

View file

@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any import json
from typing import Any, Iterable
from .utils import tilde_encode, urlsafe_components from .utils import tilde_encode, urlsafe_components
@ -387,7 +386,7 @@ async def count_queries(
OR q.sql LIKE :query_search OR q.sql LIKE :query_search
) )
""") """)
params["query_search"] = f"%{q}%" params["query_search"] = "%{}%".format(q)
if is_write is not None: if is_write is not None:
where_clauses.append("q.is_write = :query_is_write") where_clauses.append("q.is_write = :query_is_write")
params["query_is_write"] = int(bool(is_write)) params["query_is_write"] = int(bool(is_write))
@ -463,7 +462,7 @@ async def list_queries(
except ValueError: except ValueError:
components = [] components = []
if database is None and len(components) == 3: if database is None and len(components) == 3:
where_clauses.append(f""" where_clauses.append("""
( (
q.database_name > :cursor_database q.database_name > :cursor_database
OR ( OR (
@ -477,12 +476,12 @@ async def list_queries(
) )
) )
) )
""") """.format(sort_key_sql=sort_key_sql))
params["cursor_database"] = components[0] params["cursor_database"] = components[0]
params["cursor_sort_key"] = components[1] params["cursor_sort_key"] = components[1]
params["cursor_name"] = components[2] params["cursor_name"] = components[2]
elif database is not None and len(components) == 2: elif database is not None and len(components) == 2:
where_clauses.append(f""" where_clauses.append("""
( (
{sort_key_sql} > :cursor_sort_key {sort_key_sql} > :cursor_sort_key
OR ( OR (
@ -490,7 +489,7 @@ async def list_queries(
AND q.name > :cursor_name AND q.name > :cursor_name
) )
) )
""") """.format(sort_key_sql=sort_key_sql))
params["cursor_sort_key"] = components[0] params["cursor_sort_key"] = components[0]
params["cursor_name"] = components[1] params["cursor_name"] = components[1]
@ -503,7 +502,7 @@ async def list_queries(
OR q.sql LIKE :query_search OR q.sql LIKE :query_search
) )
""") """)
params["query_search"] = f"%{q}%" params["query_search"] = "%{}%".format(q)
if is_write is not None: if is_write is not None:
where_clauses.append("q.is_write = :query_is_write") where_clauses.append("q.is_write = :query_is_write")
params["query_is_write"] = int(bool(is_write)) params["query_is_write"] = int(bool(is_write))

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import dataclasses import dataclasses
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
import itsdangerous import itsdangerous
@ -50,24 +50,24 @@ class TokenRestrictions:
database: dict[str, list[str]] = dataclasses.field(default_factory=dict) database: dict[str, list[str]] = dataclasses.field(default_factory=dict)
resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict) resource: dict[str, dict[str, list[str]]] = dataclasses.field(default_factory=dict)
def allow_all(self, action: str) -> TokenRestrictions: def allow_all(self, action: str) -> "TokenRestrictions":
"""Allow an action across all databases and resources.""" """Allow an action across all databases and resources."""
self.all.append(action) self.all.append(action)
return self return self
def allow_database(self, database: str, action: str) -> TokenRestrictions: def allow_database(self, database: str, action: str) -> "TokenRestrictions":
"""Allow an action on a specific database.""" """Allow an action on a specific database."""
self.database.setdefault(database, []).append(action) self.database.setdefault(database, []).append(action)
return self return self
def allow_resource( def allow_resource(
self, database: str, resource: str, action: str self, database: str, resource: str, action: str
) -> TokenRestrictions: ) -> "TokenRestrictions":
"""Allow an action on a specific resource within a database.""" """Allow an action on a specific resource within a database."""
self.resource.setdefault(database, {}).setdefault(resource, []).append(action) self.resource.setdefault(database, {}).setdefault(resource, []).append(action)
return self return self
def abbreviated(self, datasette: Datasette) -> dict | None: def abbreviated(self, datasette: "Datasette") -> Optional[dict]:
""" """
Return the abbreviated ``_r`` dictionary shape for this set of Return the abbreviated ``_r`` dictionary shape for this set of
restrictions, using action abbreviations registered with ``datasette``. restrictions, using action abbreviations registered with ``datasette``.
@ -112,16 +112,16 @@ class TokenHandler:
async def create_token( async def create_token(
self, self,
datasette: Datasette, datasette: "Datasette",
actor_id: str, actor_id: str,
*, *,
expires_after: int | None = None, expires_after: Optional[int] = None,
restrictions: TokenRestrictions | None = None, restrictions: Optional[TokenRestrictions] = None,
) -> str: ) -> str:
"""Create and return a token string for the given actor.""" """Create and return a token string for the given actor."""
raise NotImplementedError raise NotImplementedError
async def verify_token(self, datasette: Datasette, token: str) -> dict | None: async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
""" """
Verify a token and return an actor dict. Verify a token and return an actor dict.
@ -142,11 +142,11 @@ class SignedTokenHandler(TokenHandler):
async def create_token( async def create_token(
self, self,
datasette: Datasette, datasette: "Datasette",
actor_id: str, actor_id: str,
*, *,
expires_after: int | None = None, expires_after: Optional[int] = None,
restrictions: TokenRestrictions | None = None, restrictions: Optional[TokenRestrictions] = None,
) -> str: ) -> str:
if not datasette.setting("allow_signed_tokens"): if not datasette.setting("allow_signed_tokens"):
raise ValueError( raise ValueError(
@ -163,7 +163,7 @@ class SignedTokenHandler(TokenHandler):
token["_r"] = abbreviated token["_r"] = abbreviated
return "dstok_{}".format(datasette.sign(token, namespace="token")) return "dstok_{}".format(datasette.sign(token, namespace="token"))
async def verify_token(self, datasette: Datasette, token: str) -> dict | None: async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
prefix = "dstok_" prefix = "dstok_"
if not token.startswith(prefix): if not token.startswith(prefix):
@ -200,8 +200,9 @@ class SignedTokenHandler(TokenHandler):
): ):
duration = max_signed_tokens_ttl duration = max_signed_tokens_ttl
if duration and time.time() - created > duration: if duration:
raise TokenInvalid("Token has expired") if time.time() - created > duration:
raise TokenInvalid("Token has expired")
actor = {"id": decoded["a"], "token": "dstok"} actor = {"id": decoded["a"], "token": "dstok"}

View file

@ -1,11 +1,10 @@
import asyncio import asyncio
import json
import time
import traceback
from contextlib import contextmanager from contextlib import contextmanager
from contextvars import ContextVar from contextvars import ContextVar
from markupsafe import escape from markupsafe import escape
import time
import json
import traceback
tracers = {} tracers = {}
@ -133,17 +132,17 @@ class AsgiTracer:
"num_traces": len(traces), "num_traces": len(traces),
"traces": traces, "traces": traces,
} }
content_type = next( try:
( content_type = [
v.decode("utf8") v.decode("utf8")
for k, v in response_headers for k, v in response_headers
if k.lower() == b"content-type" if k.lower() == b"content-type"
), ][0]
"", except IndexError:
) content_type = ""
if "text/html" in content_type and b"</body>" in accumulated_body: if "text/html" in content_type and b"</body>" in accumulated_body:
extra = escape(json.dumps(trace_info, indent=2)) extra = escape(json.dumps(trace_info, indent=2))
extra_html = f"<pre>{extra}</pre></body>".encode() extra_html = f"<pre>{extra}</pre></body>".encode("utf8")
accumulated_body = accumulated_body.replace(b"</body>", extra_html) accumulated_body = accumulated_body.replace(b"</body>", extra_html)
elif "json" in content_type and accumulated_body.startswith(b"{"): elif "json" in content_type and accumulated_body.startswith(b"{"):
data = json.loads(accumulated_body.decode("utf8")) data = json.loads(accumulated_body.decode("utf8"))

View file

@ -1,7 +1,6 @@
from .utils import tilde_encode, path_with_format, PrefixedUrlString
import urllib import urllib
from .utils import PrefixedUrlString, path_with_format, tilde_encode
class Urls: class Urls:
def __init__(self, ds): def __init__(self, ds):
@ -9,7 +8,8 @@ class Urls:
def path(self, path, format=None): def path(self, path, format=None):
if not isinstance(path, PrefixedUrlString): if not isinstance(path, PrefixedUrlString):
path = path.removeprefix("/") if path.startswith("/"):
path = path[1:]
path = self.ds.setting("base_url") + path path = self.ds.setting("base_url") + path
if format is not None: if format is not None:
path = path_with_format(path=path, format=format) path = path_with_format(path=path, format=format)
@ -56,7 +56,6 @@ class Urls:
return PrefixedUrlString(path) return PrefixedUrlString(path)
def row_blob(self, database, table, row_path, column): def row_blob(self, database, table, row_path, column):
return ( return self.table(database, table) + "/{}.blob?_blob_column={}".format(
self.table(database, table) row_path, urllib.parse.quote_plus(column)
+ f"/{row_path}.blob?_blob_column={urllib.parse.quote_plus(column)}"
) )

View file

@ -1,31 +1,29 @@
import asyncio import asyncio
import base64
import binascii import binascii
from contextlib import contextmanager
import aiofiles
import click
from collections import OrderedDict, namedtuple, Counter
import copy import copy
import dataclasses import dataclasses
import base64
import hashlib import hashlib
import inspect import inspect
import json import json
import os
import re
import secrets
import shlex
import shutil
import tempfile
import time
import types
import typing
import urllib
from collections import Counter, OrderedDict, namedtuple
from collections.abc import Iterable
from contextlib import contextmanager
import aiofiles
import click
import markupsafe import markupsafe
import mergedeep import mergedeep
import os
import re
import shlex
import tempfile
import typing
import time
import types
import secrets
import shutil
from typing import Iterable, List, Tuple
import urllib
import yaml import yaml
from .shutil_backport import copytree from .shutil_backport import copytree
from .sqlite import sqlite3, supports_table_xinfo from .sqlite import sqlite3, supports_table_xinfo
@ -38,7 +36,7 @@ if typing.TYPE_CHECKING:
class PaginatedResources: class PaginatedResources:
"""Paginated results from allowed_resources query.""" """Paginated results from allowed_resources query."""
resources: list["Resource"] resources: List["Resource"]
next: str | None # Keyset token for next page (None if no more results) next: str | None # Keyset token for next page (None if no more results)
_datasette: typing.Any = dataclasses.field(default=None, repr=False) _datasette: typing.Any = dataclasses.field(default=None, repr=False)
_action: str = dataclasses.field(default=None, repr=False) _action: str = dataclasses.field(default=None, repr=False)
@ -85,132 +83,22 @@ class PaginatedResources:
# From https://www.sqlite.org/lang_keywords.html # From https://www.sqlite.org/lang_keywords.html
reserved_words = { reserved_words = set(
"abort", (
"action", "abort action add after all alter analyze and as asc attach autoincrement "
"add", "before begin between by cascade case cast check collate column commit "
"after", "conflict constraint create cross current_date current_time "
"all", "current_timestamp database default deferrable deferred delete desc detach "
"alter", "distinct drop each else end escape except exclusive exists explain fail "
"analyze", "for foreign from full glob group having if ignore immediate in index "
"and", "indexed initially inner insert instead intersect into is isnull join key "
"as", "left like limit match natural no not notnull null of offset on or order "
"asc", "outer plan pragma primary query raise recursive references regexp reindex "
"attach", "release rename replace restrict right rollback row savepoint select set "
"autoincrement", "table temp temporary then to transaction trigger union unique update using "
"before", "vacuum values view virtual when where with without"
"begin", ).split()
"between", )
"by",
"cascade",
"case",
"cast",
"check",
"collate",
"column",
"commit",
"conflict",
"constraint",
"create",
"cross",
"current_date",
"current_time",
"current_timestamp",
"database",
"default",
"deferrable",
"deferred",
"delete",
"desc",
"detach",
"distinct",
"drop",
"each",
"else",
"end",
"escape",
"except",
"exclusive",
"exists",
"explain",
"fail",
"for",
"foreign",
"from",
"full",
"glob",
"group",
"having",
"if",
"ignore",
"immediate",
"in",
"index",
"indexed",
"initially",
"inner",
"insert",
"instead",
"intersect",
"into",
"is",
"isnull",
"join",
"key",
"left",
"like",
"limit",
"match",
"natural",
"no",
"not",
"notnull",
"null",
"of",
"offset",
"on",
"or",
"order",
"outer",
"plan",
"pragma",
"primary",
"query",
"raise",
"recursive",
"references",
"regexp",
"reindex",
"release",
"rename",
"replace",
"restrict",
"right",
"rollback",
"row",
"savepoint",
"select",
"set",
"table",
"temp",
"temporary",
"then",
"to",
"transaction",
"trigger",
"union",
"unique",
"update",
"using",
"vacuum",
"values",
"view",
"virtual",
"when",
"where",
"with",
"without",
}
APT_GET_DOCKERFILE_EXTRAS = r""" APT_GET_DOCKERFILE_EXTRAS = r"""
RUN apt-get update && \ RUN apt-get update && \
@ -270,7 +158,7 @@ functions_marked_as_documented = []
def documented(fn=None, *, label=None): def documented(fn=None, *, label=None):
def decorate(fn): def decorate(fn):
fn._datasette_docs_label = label or f"internals_utils_{fn.__name__}" fn._datasette_docs_label = label or "internals_utils_{}".format(fn.__name__)
functions_marked_as_documented.append(fn) functions_marked_as_documented.append(fn)
return fn return fn
@ -472,7 +360,7 @@ disallawed_sql_res = [
( (
re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"), re.compile(f"pragma(?!_({'|'.join(allowed_pragmas)}))"),
"Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format( "Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
", ".join(f"pragma_{pragma}()" for pragma in allowed_pragmas) ", ".join("pragma_{}()".format(pragma) for pragma in allowed_pragmas)
), ),
) )
] ]
@ -646,7 +534,10 @@ CMD {cmd}""".format(
else "" else ""
), ),
environment_variables="\n".join( environment_variables="\n".join(
[f"ENV {key} '{value}'" for key, value in environment_variables.items()] [
"ENV {} '{}'".format(key, value)
for key, value in environment_variables.items()
]
), ),
install_from=" ".join(install), install_from=" ".join(install),
files=" ".join(files), files=" ".join(files),
@ -749,7 +640,7 @@ def get_outbound_foreign_keys(conn, table):
fks = [] fks = []
for info in infos: for info in infos:
if info is not None: if info is not None:
id, seq, table_name, from_, to_, _on_update, _on_delete, _match = info id, seq, table_name, from_, to_, on_update, on_delete, match = info
fks.append( fks.append(
{ {
"column": from_, "column": from_,
@ -850,7 +741,7 @@ def detect_json1(conn=None):
try: try:
conn.execute("SELECT json('{}')") conn.execute("SELECT json('{}')")
return True return True
except sqlite3.Error: except Exception:
return False return False
finally: finally:
if close_conn: if close_conn:
@ -930,7 +821,9 @@ def is_url(value):
if not value.startswith("http://") and not value.startswith("https://"): if not value.startswith("http://") and not value.startswith("https://"):
return False return False
# Any whitespace at all is invalid # Any whitespace at all is invalid
return not whitespace_re.search(value) if whitespace_re.search(value):
return False
return True
css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$") css_class_re = re.compile(r"^[a-zA-Z]+[_a-zA-Z0-9-]*$")
@ -983,9 +876,7 @@ def module_from_path(path, name):
mod.__file__ = path mod.__file__ = path
with open(path, "r") as file: with open(path, "r") as file:
code = compile(file.read(), path, "exec", dont_inherit=True) code = compile(file.read(), path, "exec", dont_inherit=True)
# Executing the file is the whole point - this is how --plugins-dir loads exec(code, mod.__dict__)
# plugins and how metadata/config .py files are evaluated
exec(code, mod.__dict__) # noqa: S102
return mod return mod
@ -1142,7 +1033,9 @@ def escape_fts(query):
query += '"' query += '"'
bits = _escape_fts_re.split(query) bits = _escape_fts_re.split(query)
bits = [b for b in bits if b and b != '""'] bits = [b for b in bits if b and b != '""']
return " ".join(f'"{bit}"' if not bit.startswith('"') else bit for bit in bits) return " ".join(
'"{}"'.format(bit) if not bit.startswith('"') else bit for bit in bits
)
class MultiParams: class MultiParams:
@ -1154,7 +1047,7 @@ class MultiParams:
data[key], (list, tuple) data[key], (list, tuple)
), "dictionary data should be a dictionary of key => [list]" ), "dictionary data should be a dictionary of key => [list]"
self._data = data self._data = data
elif isinstance(data, (list, tuple)): elif isinstance(data, list) or isinstance(data, tuple):
new_data = {} new_data = {}
for item in data: for item in data:
assert ( assert (
@ -1244,7 +1137,9 @@ def _gather_arguments(fn, kwargs):
for parameter in parameters: for parameter in parameters:
if parameter not in kwargs: if parameter not in kwargs:
raise TypeError( raise TypeError(
f"{fn} requires parameters {tuple(parameters)}, missing: {set(parameters) - set(kwargs.keys())}" "{} requires parameters {}, missing: {}".format(
fn, tuple(parameters), set(parameters) - set(kwargs.keys())
)
) )
call_with.append(kwargs[parameter]) call_with.append(kwargs[parameter])
return call_with return call_with
@ -1313,9 +1208,9 @@ def resolve_env_secrets(config, environ):
"""Create copy that recursively replaces {"$env": "NAME"} with values from environ""" """Create copy that recursively replaces {"$env": "NAME"} with values from environ"""
if isinstance(config, dict): if isinstance(config, dict):
if list(config.keys()) == ["$env"]: if list(config.keys()) == ["$env"]:
return environ.get(next(iter(config.values()))) return environ.get(list(config.values())[0])
elif list(config.keys()) == ["$file"]: elif list(config.keys()) == ["$file"]:
with open(next(iter(config.values()))) as fp: with open(list(config.values())[0]) as fp:
return fp.read() return fp.read()
else: else:
return { return {
@ -1411,7 +1306,7 @@ _named_param_re = re.compile(r":(\w+)")
@documented @documented
def named_parameters(sql: str) -> list[str]: def named_parameters(sql: str) -> List[str]:
""" """
Given a SQL statement, return a list of named parameters that are used in the statement Given a SQL statement, return a list of named parameters that are used in the statement
@ -1424,7 +1319,7 @@ def named_parameters(sql: str) -> list[str]:
return _named_param_re.findall(sql) return _named_param_re.findall(sql)
async def derive_named_parameters(db: "Database", sql: str) -> list[str]: async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
""" """
This undocumented but stable method exists for backwards compatibility This undocumented but stable method exists for backwards compatibility
with plugins that were using it before it switched to named_parameters() with plugins that were using it before it switched to named_parameters()
@ -1448,9 +1343,9 @@ def parse_size_limit(value, default, maximum, name="_size"):
if size < 0: if size < 0:
raise ValueError raise ValueError
except ValueError: except ValueError:
raise ValueError(f"{name} must be a positive integer") raise ValueError("{} must be a positive integer".format(name))
if size > maximum: if size > maximum:
raise ValueError(f"{name} must be <= {maximum}") raise ValueError("{} must be <= {}".format(name, maximum))
return size return size
@ -1508,7 +1403,7 @@ class TildeEncoder(dict):
elif b == _space: elif b == _space:
res = "+" res = "+"
else: else:
res = f"~{b:02X}" res = "~{:02X}".format(b)
self[b] = res self[b] = res
return res return res
@ -1603,7 +1498,7 @@ def _combine(base: dict, update: dict) -> dict:
return base return base
def pairs_to_nested_config(pairs: list[tuple[str, typing.Any]]) -> dict: def pairs_to_nested_config(pairs: typing.List[typing.Tuple[str, typing.Any]]) -> dict:
""" """
Parse a list of key-value pairs into a nested dictionary. Parse a list of key-value pairs into a nested dictionary.
""" """
@ -1618,7 +1513,7 @@ def make_slot_function(name, datasette, request, **kwargs):
from datasette.plugins import pm from datasette.plugins import pm
method = getattr(pm.hook, name, None) method = getattr(pm.hook, name, None)
assert method is not None, f"No hook found for {name}" assert method is not None, "No hook found for {}".format(name)
async def inner(): async def inner():
html_bits = [] html_bits = []
@ -1642,7 +1537,7 @@ def prune_empty_dicts(d: dict):
d.pop(key, None) d.pop(key, None)
def move_plugins_and_allow(source: dict, destination: dict) -> tuple[dict, dict]: def move_plugins_and_allow(source: dict, destination: dict) -> Tuple[dict, dict]:
""" """
Move 'plugins' and 'allow' keys from source to destination dictionary. Creates Move 'plugins' and 'allow' keys from source to destination dictionary. Creates
hierarchy in destination if needed. After moving, recursively remove any keys hierarchy in destination if needed. After moving, recursively remove any keys

View file

@ -1,29 +1,28 @@
import json import json
import re from typing import Optional
from http.cookies import Morsel, SimpleCookie
from mimetypes import guess_type
from pathlib import Path
from urllib.parse import parse_qs, parse_qsl, urlunparse
import aiofiles
import aiofiles.os
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
from datasette.utils.multipart import ( from datasette.utils.multipart import (
DEFAULT_MAX_FIELD_SIZE, parse_form_data,
DEFAULT_MAX_FIELDS, MultipartParseError,
FormData,
DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_MAX_FIELDS,
DEFAULT_MAX_FILES, DEFAULT_MAX_FILES,
DEFAULT_MAX_PARTS,
DEFAULT_MAX_FIELD_SIZE,
DEFAULT_MAX_MEMORY_FILE_SIZE, DEFAULT_MAX_MEMORY_FILE_SIZE,
DEFAULT_MAX_PART_HEADER_BYTES, DEFAULT_MAX_PART_HEADER_BYTES,
DEFAULT_MAX_PART_HEADER_LINES, DEFAULT_MAX_PART_HEADER_LINES,
DEFAULT_MAX_PARTS,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_MIN_FREE_DISK_BYTES, DEFAULT_MIN_FREE_DISK_BYTES,
FormData,
MultipartParseError,
parse_form_data,
) )
from mimetypes import guess_type
from urllib.parse import parse_qs, urlunparse, parse_qsl
from pathlib import Path
from http.cookies import SimpleCookie, Morsel
import aiofiles
import aiofiles.os
import re
# Workaround for adding samesite support to pre 3.8 python # Workaround for adding samesite support to pre 3.8 python
Morsel._reserved["samesite"] = "SameSite" Morsel._reserved["samesite"] = "SameSite"
@ -89,7 +88,7 @@ class Request:
self.max_post_body_bytes = max_post_body_bytes self.max_post_body_bytes = max_post_body_bytes
def __repr__(self): def __repr__(self):
return f'<asgi.Request method="{self.method}" url="{self.url}">' return '<asgi.Request method="{}" url="{}">'.format(self.method, self.url)
@property @property
def method(self): def method(self):
@ -168,7 +167,7 @@ class Request:
if max_bytes is None: if max_bytes is None:
max_bytes = self.max_post_body_bytes max_bytes = self.max_post_body_bytes
too_large = PayloadTooLarge( too_large = PayloadTooLarge(
f"Request body exceeded maximum size of {max_bytes} bytes" "Request body exceeded maximum size of {} bytes".format(max_bytes)
) )
if max_bytes: if max_bytes:
# Reject early if the client declares an oversized body # Reject early if the client declares an oversized body
@ -207,7 +206,7 @@ class Request:
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE, max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS, max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES, max_files: int = DEFAULT_MAX_FILES,
max_parts: int | None = DEFAULT_MAX_PARTS, max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE, max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE, max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES, max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
@ -530,9 +529,9 @@ class Response:
httponly=False, httponly=False,
samesite="lax", samesite="lax",
): ):
assert ( assert samesite in SAMESITE_VALUES, "samesite should be one of {}".format(
samesite in SAMESITE_VALUES SAMESITE_VALUES
), f"samesite should be one of {SAMESITE_VALUES}" )
cookie = SimpleCookie() cookie = SimpleCookie()
cookie[key] = value cookie[key] = value
for prop_name, prop_value in ( for prop_name, prop_value in (

View file

@ -13,7 +13,7 @@ Originally shared here: https://www.djangosnippets.org/snippets/1431/
""" """
class BaseConverter: class BaseConverter(object):
decimal_digits = "0123456789" decimal_digits = "0123456789"
def __init__(self, digits): def __init__(self, digits):

View file

@ -1,6 +1,6 @@
import inspect import inspect
import types import types
from typing import Any, NamedTuple from typing import NamedTuple, Any
class CallableStatus(NamedTuple): class CallableStatus(NamedTuple):
@ -19,7 +19,7 @@ def check_callable(obj: Any) -> CallableStatus:
if isinstance(obj, types.FunctionType): if isinstance(obj, types.FunctionType):
return CallableStatus(True, inspect.iscoroutinefunction(obj)) return CallableStatus(True, inspect.iscoroutinefunction(obj))
if callable(obj): if hasattr(obj, "__call__"):
return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__)) return CallableStatus(True, inspect.iscoroutinefunction(obj.__call__))
assert False, f"obj {obj!r} is somehow callable with no __call__ method" assert False, "obj {} is somehow callable with no __call__ method".format(repr(obj))

View file

@ -207,8 +207,7 @@ async def populate_schema_tables(internal_db, db, schema_version):
columns = table_column_details(conn, table_name) columns = table_column_details(conn, table_name)
columns_to_insert.extend( columns_to_insert.extend(
{ {
"database_name": database_name, **{"database_name": database_name, "table_name": table_name},
"table_name": table_name,
**column._asdict(), **column._asdict(),
} }
for column in columns for column in columns
@ -218,8 +217,7 @@ async def populate_schema_tables(internal_db, db, schema_version):
).fetchall() ).fetchall()
foreign_keys_to_insert.extend( foreign_keys_to_insert.extend(
{ {
"database_name": database_name, **{"database_name": database_name, "table_name": table_name},
"table_name": table_name,
**dict(foreign_key), **dict(foreign_key),
} }
for foreign_key in foreign_keys for foreign_key in foreign_keys
@ -229,8 +227,7 @@ async def populate_schema_tables(internal_db, db, schema_version):
).fetchall() ).fetchall()
indexes_to_insert.extend( indexes_to_insert.extend(
{ {
"database_name": database_name, **{"database_name": database_name, "table_name": table_name},
"table_name": table_name,
**dict(index), **dict(index),
} }
for index in indexes for index in indexes
@ -262,7 +259,7 @@ async def populate_schema_tables(internal_db, db, schema_version):
"catalog_tables", "catalog_tables",
): ):
conn.execute( conn.execute(
f"DELETE FROM {table} WHERE database_name = ?", "DELETE FROM {} WHERE database_name = ?".format(table),
[database_name], [database_name],
) )
conn.execute( conn.execute(

View file

@ -11,10 +11,15 @@ Supports:
import asyncio import asyncio
import shutil import shutil
import tempfile import tempfile
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import ( from typing import (
Any, Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
) )
from urllib.parse import parse_qsl from urllib.parse import parse_qsl
@ -24,7 +29,7 @@ DEFAULT_MAX_REQUEST_SIZE = 100 * 1024 * 1024 # 100MB
DEFAULT_MAX_FIELDS = 1000 DEFAULT_MAX_FIELDS = 1000
DEFAULT_MAX_FILES = 100 DEFAULT_MAX_FILES = 100
# If max_parts is not specified, it defaults to max_fields + max_files # If max_parts is not specified, it defaults to max_fields + max_files
DEFAULT_MAX_PARTS: int | None = None DEFAULT_MAX_PARTS: Optional[int] = None
DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB DEFAULT_MAX_FIELD_SIZE = 100 * 1024 # 100KB
DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB DEFAULT_MAX_MEMORY_FILE_SIZE = 1024 * 1024 # 1MB
DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB DEFAULT_MAX_PART_HEADER_BYTES = 16 * 1024 # 16KB
@ -35,6 +40,8 @@ DEFAULT_MIN_FREE_DISK_BYTES = 50 * 1024 * 1024 # 50MB
class MultipartParseError(Exception): class MultipartParseError(Exception):
"""Raised when multipart parsing fails.""" """Raised when multipart parsing fails."""
pass
@dataclass @dataclass
class UploadedFile: class UploadedFile:
@ -50,7 +57,7 @@ class UploadedFile:
name: str name: str
filename: str filename: str
content_type: str | None content_type: Optional[str]
size: int size: int
_file: tempfile.SpooledTemporaryFile = field(repr=False) _file: tempfile.SpooledTemporaryFile = field(repr=False)
@ -79,8 +86,7 @@ class UploadedFile:
def __del__(self): def __del__(self):
try: try:
self._file.close() self._file.close()
except Exception: # noqa: BLE001, S110 except Exception:
# __del__ must never raise
pass pass
@ -92,27 +98,27 @@ class FormData:
""" """
def __init__(self): def __init__(self):
self._data: list[tuple[str, str | UploadedFile]] = [] self._data: List[Tuple[str, Union[str, UploadedFile]]] = []
def append(self, key: str, value: str | UploadedFile) -> None: def append(self, key: str, value: Union[str, UploadedFile]) -> None:
"""Add a key-value pair.""" """Add a key-value pair."""
self._data.append((key, value)) self._data.append((key, value))
def __getitem__(self, key: str) -> str | UploadedFile: def __getitem__(self, key: str) -> Union[str, UploadedFile]:
"""Get the first value for a key.""" """Get the first value for a key."""
for k, v in self._data: for k, v in self._data:
if k == key: if k == key:
return v return v
raise KeyError(key) raise KeyError(key)
def get(self, key: str, default: Any = None) -> str | UploadedFile | None: def get(self, key: str, default: Any = None) -> Optional[Union[str, UploadedFile]]:
"""Get the first value for a key, or default if not found.""" """Get the first value for a key, or default if not found."""
try: try:
return self[key] return self[key]
except KeyError: except KeyError:
return default return default
def getlist(self, key: str) -> list[str | UploadedFile]: def getlist(self, key: str) -> List[Union[str, UploadedFile]]:
"""Get all values for a key.""" """Get all values for a key."""
return [v for k, v in self._data if k == key] return [v for k, v in self._data if k == key]
@ -136,15 +142,15 @@ class FormData:
"""Return unique keys.""" """Return unique keys."""
return list(self) return list(self)
def items(self) -> list[tuple[str, str | UploadedFile]]: def items(self) -> List[Tuple[str, Union[str, UploadedFile]]]:
"""Return all key-value pairs.""" """Return all key-value pairs."""
return list(self._data) return list(self._data)
def values(self) -> list[str | UploadedFile]: def values(self) -> List[Union[str, UploadedFile]]:
"""Return all values.""" """Return all values."""
return [v for _, v in self._data] return [v for _, v in self._data]
def _uploaded_files(self) -> list[UploadedFile]: def _uploaded_files(self) -> List[UploadedFile]:
"""Return UploadedFile instances contained in this form.""" """Return UploadedFile instances contained in this form."""
return [v for _, v in self._data if isinstance(v, UploadedFile)] return [v for _, v in self._data if isinstance(v, UploadedFile)]
@ -157,7 +163,7 @@ class FormData:
for uploaded in self._uploaded_files(): for uploaded in self._uploaded_files():
try: try:
uploaded.close_sync() uploaded.close_sync()
except Exception: # noqa: BLE001, S110 except Exception:
# Best-effort cleanup; ignore close errors # Best-effort cleanup; ignore close errors
pass pass
@ -166,7 +172,7 @@ class FormData:
for uploaded in self._uploaded_files(): for uploaded in self._uploaded_files():
try: try:
await uploaded.close() await uploaded.close()
except Exception: # noqa: BLE001, S110 except Exception:
# Best-effort cleanup; ignore close errors # Best-effort cleanup; ignore close errors
pass pass
@ -183,13 +189,13 @@ class FormData:
await self.aclose() await self.aclose()
def parse_content_disposition(header: str) -> dict[str, str | None]: def parse_content_disposition(header: str) -> Dict[str, Optional[str]]:
""" """
Parse Content-Disposition header value. Parse Content-Disposition header value.
Returns dict with 'name', 'filename' keys (filename may be None). Returns dict with 'name', 'filename' keys (filename may be None).
""" """
result: dict[str, str | None] = {"name": None, "filename": None} result: Dict[str, Optional[str]] = {"name": None, "filename": None}
# Split on semicolons, handling quoted strings # Split on semicolons, handling quoted strings
parts = [] parts = []
@ -232,8 +238,7 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
from urllib.parse import unquote from urllib.parse import unquote
result["filename"] = unquote(encoded, encoding="utf-8") result["filename"] = unquote(encoded, encoding="utf-8")
except Exception: # noqa: BLE001, S110 except Exception:
# Malformed RFC 5987 filename* - fall back to the plain filename
pass pass
continue continue
@ -245,19 +250,20 @@ def parse_content_disposition(header: str) -> dict[str, str | None]:
if key == "name": if key == "name":
result["name"] = value result["name"] = value
# Only set filename if filename* hasn't already set it elif key == "filename":
elif key == "filename" and result["filename"] is None: # Only set if filename* hasn't already set it
# Strip path components (security) if result["filename"] is None:
# Handle both Unix and Windows paths # Strip path components (security)
value = value.replace("\\", "/") # Handle both Unix and Windows paths
if "/" in value: value = value.replace("\\", "/")
value = value.rsplit("/", 1)[-1] if "/" in value:
result["filename"] = value value = value.rsplit("/", 1)[-1]
result["filename"] = value
return result return result
def parse_content_type(header: str) -> tuple[str, dict[str, str]]: def parse_content_type(header: str) -> Tuple[str, Dict[str, str]]:
""" """
Parse Content-Type header value. Parse Content-Type header value.
@ -301,7 +307,7 @@ class MultipartParser:
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE, max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS, max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES, max_files: int = DEFAULT_MAX_FILES,
max_parts: int | None = DEFAULT_MAX_PARTS, max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE, max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE, max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES, max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,
@ -342,12 +348,12 @@ class MultipartParser:
self._tempdir = tempfile.gettempdir() self._tempdir = tempfile.gettempdir()
# Current part state # Current part state
self.current_headers: dict[str, str] = {} self.current_headers: Dict[str, str] = {}
self.current_file: tempfile.SpooledTemporaryFile | None = None self.current_file: Optional[tempfile.SpooledTemporaryFile] = None
self.current_body = bytearray() self.current_body = bytearray()
self.current_name: str | None = None self.current_name: Optional[str] = None
self.current_filename: str | None = None self.current_filename: Optional[str] = None
self.current_content_type: str | None = None self.current_content_type: Optional[str] = None
def feed(self, chunk: bytes) -> None: def feed(self, chunk: bytes) -> None:
"""Feed a chunk of data to the parser.""" """Feed a chunk of data to the parser."""
@ -448,7 +454,7 @@ class MultipartParser:
# Parse header # Parse header
try: try:
line_str = line.decode("utf-8", errors="replace") line_str = line.decode("utf-8", errors="replace")
except UnicodeDecodeError: except Exception:
line_str = line.decode("latin-1") line_str = line.decode("latin-1")
if ":" in line_str: if ":" in line_str:
@ -475,9 +481,7 @@ class MultipartParser:
if self.file_count > self.max_files: if self.file_count > self.max_files:
raise MultipartParseError("Too many files") raise MultipartParseError("Too many files")
if self.handle_files: if self.handle_files:
# Outlives this method - it is filled in across parser callbacks self.current_file = tempfile.SpooledTemporaryFile(
# and then handed to the UploadedFile the caller consumes
self.current_file = tempfile.SpooledTemporaryFile( # noqa: SIM115
max_size=self.max_memory_file_size max_size=self.max_memory_file_size
) )
else: else:
@ -640,7 +644,7 @@ async def parse_form_data(
max_request_size: int = DEFAULT_MAX_REQUEST_SIZE, max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
max_fields: int = DEFAULT_MAX_FIELDS, max_fields: int = DEFAULT_MAX_FIELDS,
max_files: int = DEFAULT_MAX_FILES, max_files: int = DEFAULT_MAX_FILES,
max_parts: int | None = DEFAULT_MAX_PARTS, max_parts: Optional[int] = DEFAULT_MAX_PARTS,
max_field_size: int = DEFAULT_MAX_FIELD_SIZE, max_field_size: int = DEFAULT_MAX_FIELD_SIZE,
max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE, max_memory_file_size: int = DEFAULT_MAX_MEMORY_FILE_SIZE,
max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES, max_part_header_bytes: int = DEFAULT_MAX_PART_HEADER_BYTES,

View file

@ -2,9 +2,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any, Dict, Iterable, List, Sequence, Tuple
import sqlite3 import sqlite3
from collections.abc import Iterable, Sequence
from typing import Any
from datasette.permissions import PermissionSQL from datasette.permissions import PermissionSQL
from datasette.plugins import pm from datasette.plugins import pm
@ -16,7 +15,7 @@ SKIP_PERMISSION_CHECKS = object()
async def gather_permission_sql_from_hooks( async def gather_permission_sql_from_hooks(
*, datasette, actor: dict | None, action: str *, datasette, actor: dict | None, action: str
) -> list[PermissionSQL] | object: ) -> List[PermissionSQL] | object:
"""Collect PermissionSQL objects from the permission_resources_sql hook. """Collect PermissionSQL objects from the permission_resources_sql hook.
Ensures that each returned PermissionSQL has a populated ``source``. Ensures that each returned PermissionSQL has a populated ``source``.
@ -35,7 +34,7 @@ async def gather_permission_sql_from_hooks(
hookimpls = hook_caller.get_hookimpls() hookimpls = hook_caller.get_hookimpls()
hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action)) hook_results = list(hook_caller(datasette=datasette, actor=actor, action=action))
collected: list[PermissionSQL] = [] collected: List[PermissionSQL] = []
actor_json = json.dumps(actor) if actor is not None else None actor_json = json.dumps(actor) if actor is not None else None
actor_id = actor.get("id") if isinstance(actor, dict) else None actor_id = actor.get("id") if isinstance(actor, dict) else None
@ -72,7 +71,7 @@ def _iter_permission_sql_from_result(
if isinstance(result, PermissionSQL): if isinstance(result, PermissionSQL):
return [result] return [result]
if isinstance(result, (list, tuple)): if isinstance(result, (list, tuple)):
collected: list[PermissionSQL] = [] collected: List[PermissionSQL] = []
for item in result: for item in result:
collected.extend(_iter_permission_sql_from_result(item, action=action)) collected.extend(_iter_permission_sql_from_result(item, action=action))
return collected return collected
@ -91,7 +90,7 @@ def _iter_permission_sql_from_result(
def build_rules_union( def build_rules_union(
actor: dict | None, plugins: Sequence[PermissionSQL] actor: dict | None, plugins: Sequence[PermissionSQL]
) -> tuple[str, dict[str, Any]]: ) -> Tuple[str, Dict[str, Any]]:
""" """
Compose plugin SQL into a UNION ALL. Compose plugin SQL into a UNION ALL.
@ -103,10 +102,10 @@ def build_rules_union(
The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent The system reserves these parameter names: :actor, :actor_id, :action, :filter_parent
Plugin parameters should be prefixed with a unique identifier (e.g., source name). Plugin parameters should be prefixed with a unique identifier (e.g., source name).
""" """
parts: list[str] = [] parts: List[str] = []
actor_json = json.dumps(actor) if actor else None actor_json = json.dumps(actor) if actor else None
actor_id = actor.get("id") if actor else None actor_id = actor.get("id") if actor else None
params: dict[str, Any] = {"actor": actor_json, "actor_id": actor_id} params: Dict[str, Any] = {"actor": actor_json, "actor_id": actor_id}
for p in plugins: for p in plugins:
# No namespacing - just use plugin params as-is # No namespacing - just use plugin params as-is
@ -142,10 +141,10 @@ async def resolve_permissions_from_catalog(
plugins: Sequence[Any], plugins: Sequence[Any],
action: str, action: str,
candidate_sql: str, candidate_sql: str,
candidate_params: dict[str, Any] | None = None, candidate_params: Dict[str, Any] | None = None,
*, *,
implicit_deny: bool = True, implicit_deny: bool = True,
) -> list[dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Resolve permissions by embedding the provided *candidate_sql* in a CTE. Resolve permissions by embedding the provided *candidate_sql* in a CTE.
@ -169,8 +168,8 @@ async def resolve_permissions_from_catalog(
- parent, child, allow, reason, source_plugin, depth - parent, child, allow, reason, source_plugin, depth
- resource (rendered "/parent/child" or "/parent" or "/") - resource (rendered "/parent/child" or "/parent" or "/")
""" """
resolved_plugins: list[PermissionSQL] = [] resolved_plugins: List[PermissionSQL] = []
restriction_sqls: list[str] = [] restriction_sqls: List[str] = []
for plugin in plugins: for plugin in plugins:
if callable(plugin) and not isinstance(plugin, PermissionSQL): if callable(plugin) and not isinstance(plugin, PermissionSQL):
@ -399,11 +398,11 @@ async def resolve_permissions_with_candidates(
db, db,
actor: dict | None, actor: dict | None,
plugins: Sequence[Any], plugins: Sequence[Any],
candidates: list[tuple[str, str | None]], candidates: List[Tuple[str, str | None]],
action: str, action: str,
*, *,
implicit_deny: bool = True, implicit_deny: bool = True,
) -> list[dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Resolve permissions without any external candidate table by embedding Resolve permissions without any external candidate table by embedding
the candidates as a UNION of parameterized SELECTs in a CTE. the candidates as a UNION of parameterized SELECTs in a CTE.
@ -412,8 +411,8 @@ async def resolve_permissions_with_candidates(
actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action actor: actor dict (or None), made available as :actor (JSON), :actor_id, and :action
""" """
# Build a small CTE for candidates. # Build a small CTE for candidates.
cand_rows_sql: list[str] = [] cand_rows_sql: List[str] = []
cand_params: dict[str, Any] = {} cand_params: Dict[str, Any] = {}
for i, (parent, child) in enumerate(candidates): for i, (parent, child) in enumerate(candidates):
pkey = f"cand_p_{i}" pkey = f"cand_p_{i}"
ckey = f"cand_c_{i}" ckey = f"cand_c_{i}"

View file

@ -6,7 +6,7 @@ https://github.com/python/cpython/blob/v3.8.3/LICENSE
""" """
import os import os
from shutil import Error, copy, copy2, copystat from shutil import copy, copy2, copystat, Error
def _copytree( def _copytree(

View file

@ -413,12 +413,12 @@ def analyze_sql_tables(
database=None, database=None,
table=None, table=None,
sqlite_schema=sqlite_schema, sqlite_schema=sqlite_schema,
target=f"{arg1} {arg2}" if arg2 is not None else arg1, target="{} {}".format(arg1, arg2) if arg2 is not None else arg1,
source=source, source=source,
) )
return sqlite3.SQLITE_OK return sqlite3.SQLITE_OK
action_name = _AUTHORIZER_ACTION_NAMES.get(action, f"SQLITE_{action}") action_name = _AUTHORIZER_ACTION_NAMES.get(action, "SQLITE_{}".format(action))
record( record(
"unknown", "unknown",
"unknown", "unknown",
@ -521,7 +521,9 @@ def analyze_sql_tables(
and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS and key.target in _SQLITE_INTERNAL_SCHEMA_FUNCTIONS
): ):
return True return True
return bool(key_is_drop_table_delete(key)) if key_is_drop_table_delete(key):
return True
return False
def table_kind_for(key: OperationKey) -> SQLiteTableType | None: def table_kind_for(key: OperationKey) -> SQLiteTableType | None:
if ( if (

View file

@ -100,7 +100,7 @@ def sqlite_hidden_table_names(conn, *, schema: str | None = "main") -> list[str]
schema_table = _sqlite_schema_table(schema) schema_table = _sqlite_schema_table(schema)
try: try:
rows = conn.execute( rows = conn.execute(
f"select name, sql from {schema_table} where type = 'table'" "select name, sql from {} where type = 'table'".format(schema_table)
).fetchall() ).fetchall()
except sqlite3.DatabaseError: except sqlite3.DatabaseError:
return [] return []
@ -127,7 +127,7 @@ def _sqlite_table_type_from_schema(
schema_table = _sqlite_schema_table(schema) schema_table = _sqlite_schema_table(schema)
try: try:
row = conn.execute( row = conn.execute(
f"select type, sql from {schema_table} where name = ?", "select type, sql from {} where name = ?".format(schema_table),
(table,), (table,),
).fetchone() ).fetchone()
except sqlite3.DatabaseError: except sqlite3.DatabaseError:
@ -155,7 +155,7 @@ def _is_known_shadow_table(
schema_table = _sqlite_schema_table(schema) schema_table = _sqlite_schema_table(schema)
try: try:
rows = conn.execute( rows = conn.execute(
f"select name, sql from {schema_table} where type = 'table'" "select name, sql from {} where type = 'table'".format(schema_table)
).fetchall() ).fetchall()
except sqlite3.DatabaseError: except sqlite3.DatabaseError:
return False return False
@ -174,7 +174,7 @@ def _sqlite_schema_table(schema: str | None) -> str:
return "sqlite_master" return "sqlite_master"
if schema == "temp": if schema == "temp":
return "sqlite_temp_master" return "sqlite_temp_master"
return f"{_quote_identifier(schema)}.sqlite_master" return "{}.sqlite_master".format(_quote_identifier(schema))
def _quote_identifier(value: str) -> str: def _quote_identifier(value: str) -> str:

View file

@ -1,7 +1,6 @@
import json
from urllib.parse import urlencode
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from urllib.parse import urlencode
import json
# These wrapper classes pre-date the introduction of # These wrapper classes pre-date the introduction of
# datasette.client and httpx to Datasette. They could # datasette.client and httpx to Datasette. They could

View file

@ -1,7 +1,7 @@
from dataclasses import dataclass
import dataclasses import dataclasses
import types import types
import typing import typing
from dataclasses import dataclass
@dataclass(frozen=True) @dataclass(frozen=True)
@ -74,14 +74,16 @@ class Context:
extra_class = table_extra_registry.classes_by_name[name] extra_class = table_extra_registry.classes_by_name[name]
except KeyError: except KeyError:
raise KeyError( raise KeyError(
f"{cls.__name__}.{name} is declared with from_extra() but there is no " "{}.{} is declared with from_extra() but there is no "
"registered extra of that name" "registered extra of that name".format(cls.__name__, name)
) )
if cls.extras_scope is not None and not extra_class.available_for( if cls.extras_scope is not None and not extra_class.available_for(
cls.extras_scope cls.extras_scope
): ):
raise ValueError( raise ValueError(
f"{cls.__name__}.{name} is declared with from_extra() but the {name} extra is " "{}.{} is declared with from_extra() but the {} extra is "
f"not available for scope {cls.extras_scope}" "not available for scope {}".format(
cls.__name__, name, name, cls.extras_scope
)
) )
return extra_class.description or "" return extra_class.description or ""

View file

@ -2,20 +2,20 @@ import csv
import hashlib import hashlib
import sys import sys
from datasette.utils.asgi import Request
from datasette.utils import ( from datasette.utils import (
add_cors_headers,
EscapeHtmlWriter, EscapeHtmlWriter,
InvalidSql, InvalidSql,
LimitedWriter, LimitedWriter,
add_cors_headers,
path_from_row_pks, path_from_row_pks,
path_with_format, path_with_format,
sqlite3, sqlite3,
) )
from datasette.utils.asgi import ( from datasette.utils.asgi import (
AsgiStream, AsgiStream,
BadRequest,
Request,
Response, Response,
BadRequest,
) )
@ -129,10 +129,12 @@ class BaseView:
template = environment.select_template(templates) template = environment.select_template(templates)
template_context = { template_context = {
**context, **context,
"select_templates": [ **{
f"{'*' if template_name == template.name else ''}{template_name}" "select_templates": [
for template_name in templates f"{'*' if template_name == template.name else ''}{template_name}"
], for template_name in templates
],
},
} }
headers = {} headers = {}
if self.has_json_alternate: if self.has_json_alternate:
@ -149,7 +151,9 @@ class BaseView:
template_context["alternate_url_json"] = alternate_url_json template_context["alternate_url_json"] = alternate_url_json
headers.update( headers.update(
{ {
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"' "Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
} }
) )
return Response.html( return Response.html(
@ -180,7 +184,9 @@ async def stream_csv(datasette, fetch_data, request, database):
stream = request.args.get("_stream") stream = request.args.get("_stream")
# Do not calculate facets or counts: # Do not calculate facets or counts:
extra_parameters = [ extra_parameters = [
f"{key}=1" for key in ("_nofacet", "_nocount") if not request.args.get(key) "{}=1".format(key)
for key in ("_nofacet", "_nocount")
if not request.args.get(key)
] ]
if extra_parameters: if extra_parameters:
# Replace request object with a new one with modified scope # Replace request object with a new one with modified scope
@ -210,6 +216,9 @@ async def stream_csv(datasette, fetch_data, request, database):
except (sqlite3.OperationalError, InvalidSql) as e: except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400) raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
except DatasetteError: except DatasetteError:
raise raise
@ -316,9 +325,8 @@ async def stream_csv(datasette, fetch_data, request, database):
else: else:
new_row.append(cell) new_row.append(cell)
await writer.writerow(new_row) await writer.writerow(new_row)
except Exception as ex: # noqa: BLE001 except Exception as ex:
# Streaming CSV: report the error into the response body and stop sys.stderr.write("Caught this error: {}\n".format(ex))
sys.stderr.write(f"Caught this error: {ex}\n")
sys.stderr.flush() sys.stderr.flush()
await r.write(str(ex)) await r.write(str(ex))
return return

View file

@ -1,52 +1,49 @@
from dataclasses import asdict, dataclass, field
from urllib.parse import parse_qsl, urlencode
import asyncio import asyncio
import hashlib import hashlib
import itertools import itertools
import json import json
import markupsafe
import os import os
import textwrap import textwrap
from dataclasses import asdict, dataclass, field
from urllib.parse import parse_qsl, urlencode
import markupsafe
from datasette.extras import extra_names_from_request, ExtraScope
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from datasette.extras import ExtraScope, extra_names_from_request
from datasette.plugins import pm
from datasette.resources import DatabaseResource, QueryResource from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import StoredQuery, stored_query_to_dict from datasette.stored_queries import StoredQuery, stored_query_to_dict
from datasette.write_sql import QueryWriteRejected
from datasette.utils import ( from datasette.utils import (
InvalidSql,
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
call_with_supported_arguments,
error_body, error_body,
call_with_supported_arguments,
named_parameters as derive_named_parameters,
format_bytes, format_bytes,
is_url,
make_slot_function, make_slot_function,
tilde_decode,
to_css_class,
validate_sql_select,
is_url,
path_with_added_args, path_with_added_args,
path_with_format, path_with_format,
path_with_removed_args, path_with_removed_args,
sqlite3, sqlite3,
tilde_decode,
to_css_class,
truncate_url, truncate_url,
validate_sql_select, InvalidSql,
) )
from datasette.utils import ( from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
named_parameters as derive_named_parameters, from datasette.plugins import pm
)
from datasette.utils.asgi import AsgiFileDownload, Forbidden, NotFound, Response
from datasette.write_sql import QueryWriteRejected
from . import Context
from .base import DatasetteError, View, stream_csv from .base import DatasetteError, View, stream_csv
from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns
from .table_create_alter import _create_table_ui_context
from .table_extras import ( from .table_extras import (
QueryExtraContext, QueryExtraContext,
resolve_query_extras, resolve_query_extras,
table_extra_registry, table_extra_registry,
) )
from .table_create_alter import _create_table_ui_context
from . import Context
@dataclass @dataclass
@ -103,7 +100,7 @@ class DatabaseView(View):
return response return response
if format_ not in ("html", "json"): if format_ not in ("html", "json"):
raise NotFound(f"Invalid format: {format_}") raise NotFound("Invalid format: {}".format(format_))
metadata = await datasette.get_database_metadata(database) metadata = await datasette.get_database_metadata(database)
@ -167,7 +164,7 @@ class DatabaseView(View):
"label": "Create table", "label": "Create table",
"description": "Create a new table in this database.", "description": "Create a new table in this database.",
"attrs": { "attrs": {
"aria-label": f"Create table in {database}", "aria-label": "Create table in {}".format(database),
"data-database-action": "create-table", "data-database-action": "create-table",
}, },
} }
@ -274,7 +271,9 @@ class DatabaseView(View):
view_name="database", view_name="database",
), ),
headers={ headers={
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"' "Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
}, },
) )
@ -557,7 +556,7 @@ async def database_download(request, datasette):
if datasette.cors: if datasette.cors:
add_cors_headers(headers) add_cors_headers(headers)
if db.hash: if db.hash:
etag = f'"{db.hash}"' etag = '"{}"'.format(db.hash)
headers["Etag"] = etag headers["Etag"] = etag
# Has user seen this already? # Has user seen this already?
if_none_match = request.headers.get("if-none-match") if_none_match = request.headers.get("if-none-match")
@ -665,9 +664,8 @@ class QueryView(View):
).first() ).first()
if message_result: if message_result:
message = message_result[0] message = message_result[0]
except Exception as ex: # noqa: BLE001 except Exception as ex:
# Stored-query on_success_message_sql is user-authored message = "Error running on_success_message_sql: {}".format(ex)
message = f"Error running on_success_message_sql: {ex}"
message_type = datasette.ERROR message_type = datasette.ERROR
if not message: if not message:
if stored_query.on_success_message: if stored_query.on_success_message:
@ -681,8 +679,7 @@ class QueryView(View):
redirect_url = stored_query.on_success_redirect redirect_url = stored_query.on_success_redirect
ok = True ok = True
except Exception as ex: # noqa: BLE001 except Exception as ex:
# Stored-query execution is user-authored SQL
message = stored_query.on_error_message or str(ex) message = stored_query.on_error_message or str(ex)
message_type = datasette.ERROR message_type = datasette.ERROR
redirect_url = stored_query.on_error_redirect redirect_url = stored_query.on_error_redirect
@ -816,16 +813,16 @@ class QueryView(View):
rows = results.rows rows = results.rows
except QueryInterrupted as ex: except QueryInterrupted as ex:
raise DatasetteError( raise DatasetteError(
textwrap.dedent(f""" textwrap.dedent("""
<p>SQL query took too long. The time limit is controlled by the <p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a> <a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p> configuration option.</p>
<textarea style="width: 90%">{markupsafe.escape(ex.sql)}</textarea> <textarea style="width: 90%">{}</textarea>
<script> <script>
let ta = document.querySelector("textarea"); let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px"; ta.style.height = ta.scrollHeight + "px";
</script> </script>
""").strip(), """.format(markupsafe.escape(ex.sql))).strip(),
title="SQL Interrupted", title="SQL Interrupted",
status=400, status=400,
message_is_html=True, message_is_html=True,
@ -841,6 +838,8 @@ class QueryView(View):
columns = [] columns = []
except (sqlite3.OperationalError, InvalidSql) as ex: except (sqlite3.OperationalError, InvalidSql) as ex:
raise DatasetteError(str(ex), title="Invalid SQL", status=400) raise DatasetteError(str(ex), title="Invalid SQL", status=400)
except sqlite3.OperationalError as ex:
raise DatasetteError(str(ex))
except DatasetteError: except DatasetteError:
raise raise
@ -862,7 +861,7 @@ class QueryView(View):
return data, None, None return data, None, None
return await stream_csv(datasette, fetch_data_for_csv, request, db.name) return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
elif format_ in datasette.renderers: elif format_ in datasette.renderers.keys():
if not sql: if not sql:
raise DatasetteError("?sql= is required", status=400) raise DatasetteError("?sql= is required", status=400)
data = {"ok": True, "rows": rows, "columns": columns} data = {"ok": True, "rows": rows, "columns": columns}
@ -954,7 +953,9 @@ class QueryView(View):
} }
headers.update( headers.update(
{ {
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"' "Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
} }
) )
metadata = await query_metadata() metadata = await query_metadata()
@ -1035,7 +1036,9 @@ class QueryView(View):
+ "?" + "?"
+ urlencode( + urlencode(
{ {
"sql": sql, **{
"sql": sql,
},
**named_parameter_values, **named_parameter_values,
} }
) )
@ -1137,7 +1140,7 @@ class QueryView(View):
headers=headers, headers=headers,
) )
else: else:
assert False, f"Invalid format: {format_}" assert False, "Invalid format: {}".format(format_)
if datasette.cors: if datasette.cors:
add_cors_headers(r.headers) add_cors_headers(r.headers)
return r return r
@ -1238,7 +1241,7 @@ async def display_rows(datasette, database, request, rows, columns):
'<a class="blob-download" href="{}"{}>&lt;Binary:&nbsp;{:,}&nbsp;byte{}&gt;</a>'.format( '<a class="blob-download" href="{}"{}>&lt;Binary:&nbsp;{:,}&nbsp;byte{}&gt;</a>'.format(
blob_url, blob_url,
( (
f' title="{formatted}"' ' title="{}"'.format(formatted)
if "bytes" not in formatted if "bytes" not in formatted
else "" else ""
), ),

View file

@ -8,8 +8,8 @@ from datasette.utils.asgi import Response
from .base import BaseView from .base import BaseView
from .database import display_rows as display_query_rows from .database import display_rows as display_query_rows
from .query_helpers import ( from .query_helpers import (
SQL_PARAMETER_FORM_PREFIX,
QueryValidationError, QueryValidationError,
SQL_PARAMETER_FORM_PREFIX,
_analysis_is_write, _analysis_is_write,
_analysis_rows, _analysis_rows,
_analysis_rows_with_permissions, _analysis_rows_with_permissions,
@ -31,7 +31,15 @@ WRITE_TEMPLATE_LABELS = {
"delete": "Delete rows", "delete": "Delete rows",
} }
WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS) WRITE_TEMPLATE_OPERATIONS = tuple(WRITE_TEMPLATE_LABELS)
CREATE_TABLE_TEMPLATE_SQL = "create table new_table (\n id integer primary key,\n name text\n -- created text default (datetime('now'))\n)" CREATE_TABLE_TEMPLATE_SQL = "\n".join(
(
"create table new_table (",
" id integer primary key,",
" name text",
" -- created text default (datetime('now'))",
")",
)
)
def _parameter_names(columns): def _parameter_names(columns):
@ -41,11 +49,11 @@ def _parameter_names(columns):
base = re.sub(r"[^a-z0-9_]+", "_", column.lower()) base = re.sub(r"[^a-z0-9_]+", "_", column.lower())
base = base.strip("_") or "value" base = base.strip("_") or "value"
if base[0].isdigit(): if base[0].isdigit():
base = f"p_{base}" base = "p_{}".format(base)
name = base name = base
index = 2 index = 2
while name in seen: while name in seen:
name = f"{base}_{index}" name = "{}_{}".format(base, index)
index += 1 index += 1
seen.add(name) seen.add(name)
names[column] = name names[column] = name
@ -57,7 +65,7 @@ def _quote_identifier(identifier):
def _preferred_where_column(table, columns): def _preferred_where_column(table, columns):
lower_table_id = f"{table.lower()}_id" lower_table_id = "{}_id".format(table.lower())
return ( return (
next((column for column in columns if column.lower() == "id"), None) next((column for column in columns if column.lower() == "id"), None)
or next( or next(
@ -82,15 +90,17 @@ def _insert_template_sql(table, columns):
auto_pk = _auto_incrementing_primary_key(columns) auto_pk = _auto_incrementing_primary_key(columns)
insert_columns = [column for column in column_names if column != auto_pk] insert_columns = [column for column in column_names if column != auto_pk]
if not insert_columns: if not insert_columns:
return f"insert into {_quote_identifier(table)}\ndefault values" return "insert into {}\ndefault values".format(_quote_identifier(table))
names = _parameter_names(insert_columns) names = _parameter_names(insert_columns)
return "\n".join( return "\n".join(
( (
f"insert into {_quote_identifier(table)} (", "insert into {} (".format(_quote_identifier(table)),
",\n".join(f" {_quote_identifier(column)}" for column in insert_columns), ",\n".join(
" {}".format(_quote_identifier(column)) for column in insert_columns
),
")", ")",
"values (", "values (",
",\n".join(f" :{names[column]}" for column in insert_columns), ",\n".join(" :{}".format(names[column]) for column in insert_columns),
")", ")",
) )
) )
@ -104,14 +114,18 @@ def _update_template_sql(table, columns):
if not set_columns: if not set_columns:
return "\n".join( return "\n".join(
( (
f"update {_quote_identifier(table)}", "update {}".format(_quote_identifier(table)),
f"set {_quote_identifier(where_column)} = :new_{names[where_column]}", "set {} = :new_{}".format(
f"where {_quote_identifier(where_column)} = :{names[where_column]}", _quote_identifier(where_column), names[where_column]
),
"where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
) )
) )
return "\n".join( return "\n".join(
( (
f"update {_quote_identifier(table)}", "update {}".format(_quote_identifier(table)),
"set " "set "
+ ",\n".join( + ",\n".join(
"{}{} = :{}".format( "{}{} = :{}".format(
@ -121,7 +135,9 @@ def _update_template_sql(table, columns):
) )
for index, column in enumerate(set_columns) for index, column in enumerate(set_columns)
), ),
f"where {_quote_identifier(where_column)} = :{names[where_column]}", "where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
) )
) )
@ -132,8 +148,10 @@ def _delete_template_sql(table, columns):
where_column = _preferred_where_column(table, column_names) where_column = _preferred_where_column(table, column_names)
return "\n".join( return "\n".join(
( (
f"delete from {_quote_identifier(table)}", "delete from {}".format(_quote_identifier(table)),
f"where {_quote_identifier(where_column)} = :{names[where_column]}", "where {} = :{}".format(
_quote_identifier(where_column), names[where_column]
),
) )
) )

View file

@ -2,11 +2,11 @@ import json
from datasette.plugins import pm from datasette.plugins import pm
from datasette.utils import ( from datasette.utils import (
UNSTABLE_API_MESSAGE,
CustomJSONEncoder,
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
make_slot_function, make_slot_function,
CustomJSONEncoder,
UNSTABLE_API_MESSAGE,
) )
from datasette.utils.asgi import Response from datasette.utils.asgi import Response
from datasette.version import __version__ from datasette.version import __version__
@ -46,15 +46,15 @@ class IndexView(BaseView):
databases = [] databases = []
# Iterate over allowed databases instead of all databases # Iterate over allowed databases instead of all databases
for name, allowed_db in allowed_db_dict.items(): for name in allowed_db_dict.keys():
db = self.ds.databases[name] db = self.ds.databases[name]
database_private = allowed_db.private database_private = allowed_db_dict[name].private
# Get allowed tables/views for this database # Get allowed tables/views for this database
allowed_for_db = tables_by_db.get(name, {}) allowed_for_db = tables_by_db.get(name, {})
# Get table names from allowed set instead of db.table_names() # Get table names from allowed set instead of db.table_names()
table_names = [child_name for child_name in allowed_for_db] table_names = [child_name for child_name in allowed_for_db.keys()]
hidden_table_names = set(await db.hidden_table_names()) hidden_table_names = set(await db.hidden_table_names())
@ -99,7 +99,7 @@ class IndexView(BaseView):
# We will be sorting by number of relationships, so populate that field # We will be sorting by number of relationships, so populate that field
all_foreign_keys = await db.get_all_foreign_keys() all_foreign_keys = await db.get_all_foreign_keys()
for table, foreign_keys in all_foreign_keys.items(): for table, foreign_keys in all_foreign_keys.items():
if table in tables: if table in tables.keys():
count = len(foreign_keys["incoming"] + foreign_keys["outgoing"]) count = len(foreign_keys["incoming"] + foreign_keys["outgoing"])
tables[table]["num_relationships_for_sorting"] = count tables[table]["num_relationships_for_sorting"] = count
@ -121,7 +121,8 @@ class IndexView(BaseView):
# Only add views if this is less than TRUNCATE_AT # Only add views if this is less than TRUNCATE_AT
if len(tables_and_views_truncated) < TRUNCATE_AT: if len(tables_and_views_truncated) < TRUNCATE_AT:
num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated) num_views_to_add = TRUNCATE_AT - len(tables_and_views_truncated)
tables_and_views_truncated.extend(views[:num_views_to_add]) for view in views[:num_views_to_add]:
tables_and_views_truncated.append(view)
databases.append( databases.append(
{ {

View file

@ -5,19 +5,6 @@ from datasette.resources import DatabaseResource
from datasette.stored_queries import ( from datasette.stored_queries import (
StoredQuery, StoredQuery,
) )
from datasette.utils import (
InvalidSql,
escape_sqlite,
parse_size_limit,
path_from_row_pks,
sqlite3,
validate_sql_select,
)
from datasette.utils import (
named_parameters as derive_named_parameters,
)
from datasette.utils.asgi import Forbidden
from datasette.utils.sql_analysis import Operation, SQLAnalysis
from datasette.write_sql import ( from datasette.write_sql import (
IgnoreWriteSqlOperation, IgnoreWriteSqlOperation,
QueryWriteRejected, QueryWriteRejected,
@ -25,6 +12,17 @@ from datasette.write_sql import (
decision_for_write_sql_operation, decision_for_write_sql_operation,
operation_is_write, operation_is_write,
) )
from datasette.utils import (
parse_size_limit,
named_parameters as derive_named_parameters,
escape_sqlite,
path_from_row_pks,
sqlite3,
validate_sql_select,
InvalidSql,
)
from datasette.utils.asgi import Forbidden
from datasette.utils.sql_analysis import Operation, SQLAnalysis
_query_name_re = re.compile(r"^[^/\.\n]+$") _query_name_re = re.compile(r"^[^/\.\n]+$")
@ -93,7 +91,7 @@ def _as_optional_bool(value, name):
return True return True
if lowered in {"0", "false", "f", "no", "off"}: if lowered in {"0", "false", "f", "no", "off"}:
return False return False
raise QueryValidationError(f"{name} must be 0 or 1") raise QueryValidationError("{} must be 0 or 1".format(name))
def _query_list_limit(value, default, maximum): def _query_list_limit(value, default, maximum):
@ -173,7 +171,7 @@ async def _json_or_form_payload(request):
try: try:
return json.loads(body or b"{}"), True return json.loads(body or b"{}"), True
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise QueryValidationError(f"Invalid JSON: {e}") raise QueryValidationError("Invalid JSON: {}".format(e))
return await request.post_vars(), False return await request.post_vars(), False
@ -194,7 +192,7 @@ async def _analyze_user_query(datasette, db, sql, *, actor):
try: try:
analysis = await db.analyze_sql(sql, params) analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex: except sqlite3.DatabaseError as ex:
raise QueryValidationError(f"Could not analyze query: {ex}") from ex raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
is_write = _analysis_is_write(analysis) is_write = _analysis_is_write(analysis)
if is_write: if is_write:
@ -295,7 +293,8 @@ def _coerce_execute_write_payload(data, is_json):
for key, value in data.items(): for key, value in data.items():
if key in {"sql", "csrftoken", "_json"}: if key in {"sql", "csrftoken", "_json"}:
continue continue
key = key.removeprefix(SQL_PARAMETER_FORM_PREFIX) if key.startswith(SQL_PARAMETER_FORM_PREFIX):
key = key[len(SQL_PARAMETER_FORM_PREFIX) :]
params[key] = value params[key] = value
if not isinstance(params, dict): if not isinstance(params, dict):
raise QueryValidationError("params must be a dictionary") raise QueryValidationError("params must be a dictionary")
@ -315,7 +314,7 @@ async def _prepare_execute_write(datasette, db, sql, params, actor):
try: try:
analysis = await db.analyze_sql(sql, params) analysis = await db.analyze_sql(sql, params)
except sqlite3.DatabaseError as ex: except sqlite3.DatabaseError as ex:
raise QueryValidationError(f"Could not analyze query: {ex}") from ex raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
if not _analysis_is_write(analysis): if not _analysis_is_write(analysis):
raise QueryValidationError( raise QueryValidationError(
"Use /-/query for read-only SQL; this endpoint only executes writes" "Use /-/query for read-only SQL; this endpoint only executes writes"
@ -497,7 +496,7 @@ async def _inserted_row_url(datasette, db, analysis, cursor):
) )
try: try:
result = await db.execute( result = await db.execute(
f"select {select} from {escape_sqlite(table)} where rowid = ?", "select {} from {} where rowid = ?".format(select, escape_sqlite(table)),
[lastrowid], [lastrowid],
) )
except sqlite3.DatabaseError: except sqlite3.DatabaseError:

View file

@ -8,35 +8,34 @@ from dataclasses import dataclass, field
import markupsafe import markupsafe
import sqlite_utils import sqlite_utils
from datasette.utils.asgi import NotFound, Forbidden, PayloadTooLarge, Response
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from datasette.events import DeleteRowEvent, UpdateRowEvent from datasette.events import UpdateRowEvent, DeleteRowEvent
from datasette.extras import ExtraScope, extra_names_from_request
from datasette.plugins import pm
from datasette.resources import TableResource from datasette.resources import TableResource
from .base import BaseView, DatasetteError, stream_csv
from datasette.utils import ( from datasette.utils import (
CustomJSONEncoder,
CustomRow,
InvalidSql,
WriteJsonValueError,
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
call_with_supported_arguments, call_with_supported_arguments,
CustomJSONEncoder,
CustomRow,
decode_write_json_row, decode_write_json_row,
escape_sqlite, InvalidSql,
make_slot_function, make_slot_function,
path_from_row_pks, path_from_row_pks,
path_with_format, path_with_format,
path_with_removed_args, path_with_removed_args,
sqlite3,
to_css_class, to_css_class,
escape_sqlite,
sqlite3,
WriteJsonValueError,
) )
from datasette.utils.asgi import Forbidden, NotFound, PayloadTooLarge, Response from datasette.plugins import pm
from datasette.extras import extra_names_from_request, ExtraScope
from . import Context, from_extra from . import Context, from_extra
from .base import BaseView, DatasetteError, stream_csv
from .table import ( from .table import (
_table_page_data,
display_columns_and_rows, display_columns_and_rows,
_table_page_data,
row_label_from_label_column, row_label_from_label_column,
) )
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
@ -188,16 +187,16 @@ class RowView(BaseView):
data, extra_template_data, templates = response_or_template_contexts data, extra_template_data, templates = response_or_template_contexts
except QueryInterrupted as ex: except QueryInterrupted as ex:
raise DatasetteError( raise DatasetteError(
textwrap.dedent(f""" textwrap.dedent("""
<p>SQL query took too long. The time limit is controlled by the <p>SQL query took too long. The time limit is controlled by the
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a> <a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
configuration option.</p> configuration option.</p>
<textarea style="width: 90%">{markupsafe.escape(ex.sql)}</textarea> <textarea style="width: 90%">{}</textarea>
<script> <script>
let ta = document.querySelector("textarea"); let ta = document.querySelector("textarea");
ta.style.height = ta.scrollHeight + "px"; ta.style.height = ta.scrollHeight + "px";
</script> </script>
""").strip(), """.format(markupsafe.escape(ex.sql))).strip(),
title="SQL Interrupted", title="SQL Interrupted",
status=400, status=400,
message_is_html=True, message_is_html=True,
@ -208,13 +207,15 @@ class RowView(BaseView):
) )
except (sqlite3.OperationalError, InvalidSql) as e: except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400) raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
except DatasetteError: except DatasetteError:
raise raise
end = time.perf_counter() end = time.perf_counter()
data["query_ms"] = (end - start) * 1000 data["query_ms"] = (end - start) * 1000
if format_ in self.ds.renderers: if format_ in self.ds.renderers.keys():
# Dispatch request to the correct output format renderer # Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming) # (CSV is not handled here due to streaming)
result = call_with_supported_arguments( result = call_with_supported_arguments(
@ -257,7 +258,7 @@ class RowView(BaseView):
if status_code is not None: if status_code is not None:
response.status = status_code response.status = status_code
else: else:
raise NotFound(f"Invalid format: {format_}") raise NotFound("Invalid format: {}".format(format_))
ttl = request.args.get("_ttl", None) ttl = request.args.get("_ttl", None)
if ttl is None or not ttl.isdigit(): if ttl is None or not ttl.isdigit():
@ -372,7 +373,9 @@ class RowView(BaseView):
view_name=self.name, view_name=self.name,
), ),
headers={ headers={
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"' "Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
}, },
) )
@ -497,7 +500,7 @@ class RowView(BaseView):
row_action_label = pk_path row_action_label = pk_path
if row_label and row_label != pk_path: if row_label and row_label != pk_path:
row_action_label = f"{pk_path} {row_label}" row_action_label = "{} {}".format(pk_path, row_label)
row_action_permissions = {} row_action_permissions = {}
if is_table and db.is_mutable: if is_table and db.is_mutable:
@ -510,7 +513,7 @@ class RowView(BaseView):
row_actions = [] row_actions = []
if row_action_permissions.get("update-row"): if row_action_permissions.get("update-row"):
attrs = { attrs = {
"aria-label": f"Edit row {row_action_label}", "aria-label": "Edit row {}".format(row_action_label),
"data-row": row_path, "data-row": row_path,
"data-row-action": "edit", "data-row-action": "edit",
} }
@ -526,7 +529,7 @@ class RowView(BaseView):
) )
if row_action_permissions.get("delete-row"): if row_action_permissions.get("delete-row"):
attrs = { attrs = {
"aria-label": f"Delete row {row_action_label}", "aria-label": "Delete row {}".format(row_action_label),
"data-row": row_path, "data-row": row_path,
"data-row-action": "delete", "data-row-action": "delete",
} }
@ -676,7 +679,7 @@ class RowView(BaseView):
key, key,
",".join(pk_values), ",".join(pk_values),
) )
foreign_key_tables.append({**fk, "count": count, "link": link}) foreign_key_tables.append({**fk, **{"count": count, "link": link}})
return foreign_key_tables return foreign_key_tables
@ -702,21 +705,23 @@ async def _row_flash_message(db, action, resolved, row=None):
if label: if label:
label = _truncated_row_flash_label(label) label = _truncated_row_flash_label(label)
if label and label != pk_label: if label and label != pk_label:
return f"{action} row {pk_label} ({label})" return "{} row {} ({})".format(action, pk_label, label)
return f"{action} row {pk_label}" return "{} row {}".format(action, pk_label)
async def _resolve_row_and_check_permission(datasette, request, permission): async def _resolve_row_and_check_permission(datasette, request, permission):
from datasette.app import DatabaseNotFound, RowNotFound, TableNotFound from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
try: try:
resolved = await datasette.resolve_row(request) resolved = await datasette.resolve_row(request)
except DatabaseNotFound as e: except DatabaseNotFound as e:
return False, Response.error([f"Database not found: {e.database_name}"], 404) return False, Response.error(
["Database not found: {}".format(e.database_name)], 404
)
except TableNotFound as e: except TableNotFound as e:
return False, Response.error([f"Table not found: {e.table}"], 404) return False, Response.error(["Table not found: {}".format(e.table)], 404)
except RowNotFound as e: except RowNotFound as e:
return False, Response.error([f"Record not found: {e.pk_values}"], 404) return False, Response.error(["Record not found: {}".format(e.pk_values)], 404)
# Ensure user has permission to delete this row # Ensure user has permission to delete this row
if not await datasette.allowed( if not await datasette.allowed(
@ -748,8 +753,7 @@ class RowDeleteView(BaseView):
try: try:
await resolved.db.execute_write_fn(delete_row, request=request) await resolved.db.execute_write_fn(delete_row, request=request)
except Exception as e: # noqa: BLE001 except Exception as e:
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
return Response.error([str(e)], 400) return Response.error([str(e)], 400)
await self.ds.track_event( await self.ds.track_event(
@ -789,7 +793,7 @@ class RowUpdateView(BaseView):
try: try:
data = await request.json() data = await request.json()
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return Response.error([f"Invalid JSON: {e}"]) return Response.error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e: except PayloadTooLarge as e:
return Response.error([str(e)], 413) return Response.error([str(e)], 413)
@ -832,8 +836,7 @@ class RowUpdateView(BaseView):
try: try:
await resolved.db.execute_write_fn(update_row, request=request) await resolved.db.execute_write_fn(update_row, request=request)
except Exception as e: # noqa: BLE001 except Exception as e:
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
return Response.error([str(e)], 400) return Response.error([str(e)], 400)
result = {"ok": True} result = {"ok": True}

View file

@ -1,25 +1,23 @@
import json import json
import logging import logging
import secrets
import urllib
from datasette.events import CreateTokenEvent, LoginEvent, LogoutEvent
from datasette.jump import JumpSQL, namespace_sql_params from datasette.jump import JumpSQL, namespace_sql_params
from datasette.plugins import pm from datasette.plugins import pm
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
from datasette.resources import DatabaseResource, TableResource from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import Response, Forbidden
from datasette.utils import ( from datasette.utils import (
UNSTABLE_API_MESSAGE, UNSTABLE_API_MESSAGE,
actor_matches_allow, actor_matches_allow,
parse_size_limit,
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
error_body, error_body,
parse_size_limit,
tilde_decode,
tilde_encode, tilde_encode,
tilde_decode,
) )
from datasette.utils.asgi import Forbidden, Response
from .base import BaseView, View from .base import BaseView, View
import secrets
import urllib
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -181,7 +179,9 @@ class AutocompleteDebugView(BaseView):
) )
context.update( context.update(
{ {
"autocomplete_url": f"{self.ds.urls.table(database_name, table_name)}/-/autocomplete", "autocomplete_url": "{}/-/autocomplete".format(
self.ds.urls.table(database_name, table_name)
),
"label_column": await db.label_column_for_table(table_name), "label_column": await db.label_column_for_table(table_name),
} }
) )
@ -420,11 +420,8 @@ class AllowedResourcesView(BaseView):
row["reason"] = resource.reasons row["reason"] = resource.reasons
allowed_rows.append(row) allowed_rows.append(row)
except Exception: # noqa: BLE001 except Exception:
# Returns empty results if the catalog tables don't exist yet, but # If catalog tables don't exist yet, return empty results
# also swallows the AttributeError raised for instance-level actions
# such as view-instance, which have no resource_class.
# TODO: handle that case explicitly and narrow this to sqlite3.Error
return ( return (
{ {
"ok": True, "ok": True,
@ -526,7 +523,7 @@ class PermissionRulesView(BaseView):
from datasette.utils.actions_sql import build_permission_rules_sql from datasette.utils.actions_sql import build_permission_rules_sql
union_sql, union_params, _restriction_sqls = await build_permission_rules_sql( union_sql, union_params, restriction_sqls = await build_permission_rules_sql(
self.ds, actor, action self.ds, actor, action
) )
await self.ds.refresh_schemas() await self.ds.refresh_schemas()
@ -939,7 +936,7 @@ class ApiExplorerView(BaseView):
tables.append({"name": table, "links": table_links}) tables.append({"name": table, "links": table_links})
table_links.append( table_links.append(
{ {
"label": f"Get rows for {table}", "label": "Get rows for {}".format(table),
"method": "GET", "method": "GET",
"path": self.ds.urls.table(name, table, format="json"), "path": self.ds.urls.table(name, table, format="json"),
} }
@ -959,7 +956,7 @@ class ApiExplorerView(BaseView):
{ {
"path": self.ds.urls.table(name, table) + "/-/insert", "path": self.ds.urls.table(name, table) + "/-/insert",
"method": "POST", "method": "POST",
"label": f"Insert rows into {table}", "label": "Insert rows into {}".format(table),
"json": { "json": {
"rows": [ "rows": [
{ {
@ -973,7 +970,7 @@ class ApiExplorerView(BaseView):
{ {
"path": self.ds.urls.table(name, table) + "/-/upsert", "path": self.ds.urls.table(name, table) + "/-/upsert",
"method": "POST", "method": "POST",
"label": f"Upsert rows into {table}", "label": "Upsert rows into {}".format(table),
"json": { "json": {
"rows": [ "rows": [
{ {
@ -1003,7 +1000,7 @@ class ApiExplorerView(BaseView):
table_links.append( table_links.append(
{ {
"path": self.ds.urls.table(name, table) + "/-/drop", "path": self.ds.urls.table(name, table) + "/-/drop",
"label": f"Drop table {table}", "label": "Drop table {}".format(table),
"json": {"confirm": False}, "json": {"confirm": False},
"method": "POST", "method": "POST",
} }
@ -1020,7 +1017,7 @@ class ApiExplorerView(BaseView):
database_links.append( database_links.append(
{ {
"path": self.ds.urls.database(name) + "/-/create", "path": self.ds.urls.database(name) + "/-/create",
"label": f"Create table in {name}", "label": "Create table in {}".format(name),
"json": { "json": {
"table": "new_table", "table": "new_table",
"columns": [ "columns": [

View file

@ -124,7 +124,7 @@ class QueryListView(BaseView):
pairs.append(("_next", page.next)) pairs.append(("_next", page.next))
next_url = self.ds.absolute_url( next_url = self.ds.absolute_url(
request, request,
f"{request.path}?{urlencode(pairs)}", "{}?{}".format(request.path, urlencode(pairs)),
) )
current_filters = { current_filters = {
@ -415,7 +415,7 @@ class QueryDefinitionView(BaseView):
query_name = tilde_decode(request.url_vars["query"]) query_name = tilde_decode(request.url_vars["query"])
query = await self.ds.get_query(db.name, query_name) query = await self.ds.get_query(db.name, query_name)
if query is None: if query is None:
return Response.error([f"Query not found: {query_name}"], 404) return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed( if not await self.ds.allowed(
action="view-query", action="view-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
@ -439,7 +439,7 @@ class QueryUpdateView(BaseView):
query_name = tilde_decode(request.url_vars["query"]) query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name) existing = await self.ds.get_query(db.name, query_name)
if existing is None: if existing is None:
return Response.error([f"Query not found: {query_name}"], 404) return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed( if not await self.ds.allowed(
action="update-query", action="update-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
@ -532,7 +532,7 @@ class QueryEditView(BaseView):
async def get(self, request): async def get(self, request):
db, query_name, existing = await self._load(request) db, query_name, existing = await self._load(request)
if existing is None: if existing is None:
return Response.error([f"Query not found: {query_name}"], 404) return Response.error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission( await self.ds.ensure_permission(
action="update-query", action="update-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
@ -545,7 +545,7 @@ class QueryEditView(BaseView):
async def post(self, request): async def post(self, request):
db, query_name, existing = await self._load(request) db, query_name, existing = await self._load(request)
if existing is None: if existing is None:
return Response.error([f"Query not found: {query_name}"], 404) return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed( if not await self.ds.allowed(
action="update-query", action="update-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
@ -629,7 +629,7 @@ class QueryDeleteView(BaseView):
async def get(self, request): async def get(self, request):
db, query_name, existing = await self._load(request) db, query_name, existing = await self._load(request)
if existing is None: if existing is None:
return Response.error([f"Query not found: {query_name}"], 404) return Response.error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission( await self.ds.ensure_permission(
action="delete-query", action="delete-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
@ -653,7 +653,7 @@ class QueryDeleteView(BaseView):
async def post(self, request): async def post(self, request):
db, query_name, existing = await self._load(request) db, query_name, existing = await self._load(request)
if existing is None: if existing is None:
return Response.error([f"Query not found: {query_name}"], 404) return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed( if not await self.ds.allowed(
action="delete-query", action="delete-query",
resource=QueryResource(db.name, query_name), resource=QueryResource(db.name, query_name),
@ -665,13 +665,13 @@ class QueryDeleteView(BaseView):
["Trusted queries cannot be deleted using the API"], 403 ["Trusted queries cannot be deleted using the API"], 403
) )
_data, is_json = await _json_or_form_payload(request) data, is_json = await _json_or_form_payload(request)
await self.ds.remove_query(db.name, query_name) await self.ds.remove_query(db.name, query_name)
if is_json: if is_json:
return Response.json({"ok": True}) return Response.json({"ok": True})
self.ds.add_message( self.ds.add_message(
request, request,
f"Query “{existing.title or query_name}” deleted", "Query “{}” deleted".format(existing.title or query_name),
self.ds.INFO, self.ds.INFO,
) )
return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name))) return Response.redirect(self.ds.urls.path(self.ds.urls.database(db.name)))

View file

@ -3,51 +3,48 @@ import itertools
import json import json
import urllib import urllib
import urllib.parse import urllib.parse
from dataclasses import dataclass, field
import markupsafe import markupsafe
import sqlite_utils
from datasette import tracer
from datasette.column_types import SQLiteType from datasette.column_types import SQLiteType
from datasette.database import QueryInterrupted from datasette.extras import extra_names_from_request
from datasette.plugins import pm
from datasette.events import ( from datasette.events import (
AlterTableEvent, AlterTableEvent,
DropTableEvent, DropTableEvent,
InsertRowsEvent, InsertRowsEvent,
UpsertRowsEvent, UpsertRowsEvent,
) )
from datasette.extras import ExtraScope, extra_names_from_request from datasette.database import QueryInterrupted
from datasette.filters import Filters from datasette import tracer
from datasette.plugins import pm
from datasette.resources import DatabaseResource, TableResource from datasette.resources import DatabaseResource, TableResource
from datasette.utils import ( from datasette.utils import (
CustomJSONEncoder,
CustomRow,
InvalidSql,
WriteJsonValueError,
add_cors_headers, add_cors_headers,
append_querystring,
await_me_maybe, await_me_maybe,
call_with_supported_arguments, call_with_supported_arguments,
CustomJSONEncoder,
CustomRow,
append_querystring,
compound_keys_after_sql, compound_keys_after_sql,
decode_write_json_rows, decode_write_json_rows,
format_bytes,
make_slot_function,
tilde_encode,
escape_sqlite, escape_sqlite,
filters_should_redirect, filters_should_redirect,
format_bytes,
is_url, is_url,
make_slot_function,
path_from_row_pks, path_from_row_pks,
path_with_added_args, path_with_added_args,
path_with_format, path_with_format,
path_with_removed_args, path_with_removed_args,
path_with_replaced_args, path_with_replaced_args,
sqlite3,
tilde_encode,
to_css_class, to_css_class,
truncate_url, truncate_url,
urlsafe_components, urlsafe_components,
value_as_boolean, value_as_boolean,
InvalidSql,
WriteJsonValueError,
sqlite3,
) )
from datasette.utils.asgi import ( from datasette.utils.asgi import (
BadRequest, BadRequest,
@ -57,7 +54,11 @@ from datasette.utils.asgi import (
Request, Request,
Response, Response,
) )
from datasette.filters import Filters
import sqlite_utils
from dataclasses import dataclass, field
from datasette.extras import ExtraScope
from . import Context, from_extra from . import Context, from_extra
from .base import BaseView, DatasetteError, stream_csv from .base import BaseView, DatasetteError, stream_csv
from .database import QueryView from .database import QueryView
@ -535,7 +536,7 @@ async def _table_insert_ui(
columns.append(column_data) columns.append(column_data)
data = { data = {
"path": f"{datasette.urls.table(database_name, table_name)}/-/insert", "path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name, "tableName": table_name,
"columns": columns, "columns": columns,
"bulkColumns": bulk_columns, "bulkColumns": bulk_columns,
@ -543,8 +544,8 @@ async def _table_insert_ui(
"maxInsertRows": datasette.setting("max_insert_rows"), "maxInsertRows": datasette.setting("max_insert_rows"),
} }
if can_update: if can_update:
data["upsertPath"] = ( data["upsertPath"] = "{}/-/upsert".format(
f"{datasette.urls.table(database_name, table_name)}/-/upsert" datasette.urls.table(database_name, table_name)
) )
return data return data
@ -603,7 +604,7 @@ async def _table_alter_ui(
columns.append(column_data) columns.append(column_data)
data = { data = {
"path": f"{datasette.urls.table(database_name, table_name)}/-/alter", "path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name, "tableName": table_name,
"columns": columns, "columns": columns,
"primaryKeys": pks, "primaryKeys": pks,
@ -629,7 +630,9 @@ async def _table_alter_ui(
actor=request.actor, actor=request.actor,
) )
if can_drop_table: if can_drop_table:
data["dropPath"] = f"{datasette.urls.table(database_name, table_name)}/-/drop" data["dropPath"] = "{}/-/drop".format(
datasette.urls.table(database_name, table_name)
)
return data return data
@ -725,10 +728,12 @@ async def display_columns_and_rows(
row_label = row_label_from_label_column(row, label_column) row_label = row_label_from_label_column(row, label_column)
row_action_label = pk_path row_action_label = pk_path
if row_label and row_label != pk_path: if row_label and row_label != pk_path:
row_action_label = f"{pk_path} {row_label}" row_action_label = "{} {}".format(pk_path, row_label)
table_path = datasette.urls.table(database_name, table_name) table_path = datasette.urls.table(database_name, table_name)
row_link = ( row_link = '<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
f'<a href="{table_path}/{row_path}">{markupsafe.escape(pk_path)!s}</a>' table_path=table_path,
flat_pks=str(markupsafe.escape(pk_path)),
flat_pks_quoted=row_path,
) )
edit_icon = ( edit_icon = (
'<svg class="row-inline-action-icon" aria-hidden="true" ' '<svg class="row-inline-action-icon" aria-hidden="true" '
@ -755,16 +760,22 @@ async def display_columns_and_rows(
if row_action_permissions.get("update-row"): if row_action_permissions.get("update-row"):
row_actions.append( row_actions.append(
'<button type="button" class="row-inline-action row-inline-action-edit" ' '<button type="button" class="row-inline-action row-inline-action-edit" '
f'aria-label="Edit row {markupsafe.escape(row_action_label)}" title="Edit row" ' 'aria-label="Edit row {row_label}" title="Edit row" '
'data-row-action="edit">' 'data-row-action="edit">'
f"{edit_icon}</button>" "{edit_icon}</button>".format(
edit_icon=edit_icon,
row_label=markupsafe.escape(row_action_label),
)
) )
if row_action_permissions.get("delete-row"): if row_action_permissions.get("delete-row"):
row_actions.append( row_actions.append(
'<button type="button" class="row-inline-action row-inline-action-delete" ' '<button type="button" class="row-inline-action row-inline-action-delete" '
f'aria-label="Delete row {markupsafe.escape(row_action_label)}" title="Delete row" ' 'aria-label="Delete row {row_label}" title="Delete row" '
'data-row-action="delete">' 'data-row-action="delete">'
f"{delete_icon}</button>" "{delete_icon}</button>".format(
delete_icon=delete_icon,
row_label=markupsafe.escape(row_action_label),
)
) )
if row_actions: if row_actions:
row_link = ( row_link = (
@ -832,7 +843,11 @@ async def display_columns_and_rows(
path_from_row_pks(row, pks, not pks), path_from_row_pks(row, pks, not pks),
column, column,
), ),
(f' title="{formatted}"' if "bytes" not in formatted else ""), (
' title="{}"'.format(formatted)
if "bytes" not in formatted
else ""
),
len(value), len(value),
"" if len(value) == 1 else "s", "" if len(value) == 1 else "s",
) )
@ -944,7 +959,7 @@ class TableInsertView(BaseView):
try: try:
data = await request.json() data = await request.json()
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return _errors([f"Invalid JSON: {e}"]) return _errors(["Invalid JSON: {}".format(e)])
if not isinstance(data, dict): if not isinstance(data, dict):
return _errors(["JSON must be a dictionary"]) return _errors(["JSON must be a dictionary"])
keys = data.keys() keys = data.keys()
@ -972,7 +987,9 @@ class TableInsertView(BaseView):
# Does this exceed max_insert_rows? # Does this exceed max_insert_rows?
max_insert_rows = self.ds.setting("max_insert_rows") max_insert_rows = self.ds.setting("max_insert_rows")
if len(rows) > max_insert_rows: if len(rows) > max_insert_rows:
return _errors([f"Too many rows, maximum allowed is {max_insert_rows}"]) return _errors(
["Too many rows, maximum allowed is {}".format(max_insert_rows)]
)
# Validate other parameters # Validate other parameters
extras = { extras = {
@ -1030,7 +1047,7 @@ class TableInsertView(BaseView):
# Table must exist (may handle table creation in the future) # Table must exist (may handle table creation in the future)
db = self.ds.get_database(database_name) db = self.ds.get_database(database_name)
if not await db.table_exists(table_name): if not await db.table_exists(table_name):
return Response.error([f"Table not found: {table_name}"], 404) return Response.error(["Table not found: {}".format(table_name)], 404)
if upsert: if upsert:
# Must have insert-row AND upsert-row permissions # Must have insert-row AND upsert-row permissions
@ -1153,15 +1170,14 @@ class TableInsertView(BaseView):
try: try:
rows = await db.execute_write_fn(insert_or_upsert_rows, request=request) rows = await db.execute_write_fn(insert_or_upsert_rows, request=request)
except Exception as e: # noqa: BLE001 except Exception as e:
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
return Response.error([str(e)]) return Response.error([str(e)])
result = {"ok": True} result = {"ok": True}
if should_return: if should_return:
if upsert: if upsert:
# Fetch based on initial input IDs # Fetch based on initial input IDs
where_clause = " OR ".join( where_clause = " OR ".join(
["({})".format(" AND ".join(f"{pk} = ?" for pk in pks))] ["({})".format(" AND ".join("{} = ?".format(pk) for pk in pks))]
* len(row_pk_values_for_later) * len(row_pk_values_for_later)
) )
args = list(itertools.chain.from_iterable(row_pk_values_for_later)) args = list(itertools.chain.from_iterable(row_pk_values_for_later))
@ -1251,7 +1267,7 @@ class TableSetColumnTypeView(BaseView):
try: try:
data = await request.json() data = await request.json()
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return Response.error([f"Invalid JSON: {e}"], 400) return Response.error(["Invalid JSON: {}".format(e)], 400)
except PayloadTooLarge as e: except PayloadTooLarge as e:
return Response.error([str(e)], 413) return Response.error([str(e)], 413)
@ -1278,7 +1294,7 @@ class TableSetColumnTypeView(BaseView):
database_name, table_name database_name, table_name
) )
if column not in column_details: if column not in column_details:
return Response.error([f"Column not found: {column}"], 400) return Response.error(["Column not found: {}".format(column)], 400)
column_type_data = data["column_type"] column_type_data = data["column_type"]
if column_type_data is None: if column_type_data is None:
@ -1319,7 +1335,7 @@ class TableSetColumnTypeView(BaseView):
return Response.error(['"column_type.config" must be a dictionary'], 400) return Response.error(['"column_type.config" must be a dictionary'], 400)
if column_type not in self.ds._column_types: if column_type not in self.ds._column_types:
return Response.error([f"Unknown column type: {column_type}"], 400) return Response.error(["Unknown column type: {}".format(column_type)], 400)
try: try:
await self.ds.set_column_type( await self.ds.set_column_type(
@ -1357,7 +1373,7 @@ class TableDropView(BaseView):
# Table must exist # Table must exist
db = self.ds.get_database(database_name) db = self.ds.get_database(database_name)
if not await db.table_exists(table_name): if not await db.table_exists(table_name):
return Response.error([f"Table not found: {table_name}"], 404) return Response.error(["Table not found: {}".format(table_name)], 404)
if not await self.ds.allowed( if not await self.ds.allowed(
action="drop-table", action="drop-table",
resource=TableResource(database=database_name, table=table_name), resource=TableResource(database=database_name, table=table_name),
@ -1382,7 +1398,7 @@ class TableDropView(BaseView):
"database": database_name, "database": database_name,
"table": table_name, "table": table_name,
"row_count": ( "row_count": (
await db.execute(f"select count(*) from [{table_name}]") await db.execute("select count(*) from [{}]".format(table_name))
).single_value(), ).single_value(),
"message": 'Pass "confirm": true to confirm', "message": 'Pass "confirm": true to confirm',
}, },
@ -1401,7 +1417,7 @@ class TableDropView(BaseView):
) )
self.ds.add_message( self.ds.add_message(
request, request,
f"Table {table_name} dropped", "Table {} dropped".format(table_name),
self.ds.WARNING, self.ds.WARNING,
) )
return Response.json({"ok": True}, status=200) return Response.json({"ok": True}, status=200)
@ -1461,28 +1477,32 @@ def _prefix_range_end(value):
def _autocomplete_like(column): def _autocomplete_like(column):
return f"{escape_sqlite(column)} like :like escape char(92)" return "{} like :like escape char(92)".format(escape_sqlite(column))
def _autocomplete_prefix_like(column): def _autocomplete_prefix_like(column):
return f"{escape_sqlite(column)} like :prefix escape char(92)" return "{} like :prefix escape char(92)".format(escape_sqlite(column))
def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True): def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True):
clauses = [] clauses = []
if exact_pk: if exact_pk:
clauses.append( clauses.append(
f"case when cast({escape_sqlite(pks[0])} as text) = :q then 0 else 1 end" "case when cast({} as text) = :q then 0 else 1 end".format(
escape_sqlite(pks[0])
)
) )
if label_column: if label_column:
label_like = _autocomplete_like(label_column) label_like = _autocomplete_like(label_column)
if label_matches_first: if label_matches_first:
clauses.append(f"case when {label_like} then 0 else 1 end") clauses.append("case when {} then 0 else 1 end".format(label_like))
clauses.append( clauses.append(
f"case when {label_like} then length(cast({escape_sqlite(label_column)} as text)) end" "case when {} then length(cast({} as text)) end".format(
label_like, escape_sqlite(label_column)
)
) )
else: else:
clauses.append(f"length(cast({escape_sqlite(pks[0])} as text))") clauses.append("length(cast({} as text))".format(escape_sqlite(pks[0])))
clauses.extend(escape_sqlite(pk) for pk in pks) clauses.extend(escape_sqlite(pk) for pk in pks)
return ", ".join(clauses) return ", ".join(clauses)
@ -1549,8 +1569,8 @@ class TableAutocompleteView(BaseView):
return Response.json({"ok": True, "rows": []}) return Response.json({"ok": True, "rows": []})
params = { params = {
"q": q, "q": q,
"like": f"%{_escape_like(q)}%", "like": "%{}%".format(_escape_like(q)),
"prefix": f"{_escape_like(q)}%", "prefix": "{}%".format(_escape_like(q)),
} }
like_columns = pks[:] like_columns = pks[:]
@ -1564,13 +1584,18 @@ class TableAutocompleteView(BaseView):
where_sql = "1 = 1" where_sql = "1 = 1"
order_by = _autocomplete_initial_order_by(pks) order_by = _autocomplete_initial_order_by(pks)
sql = f""" sql = """
select {select_sql} select {select_sql}
from {escape_sqlite(table_name)} from {table}
where {where_sql} where {where}
order by {order_by} order by {order_by}
limit 10 limit 10
""" """.format(
select_sql=select_sql,
table=escape_sqlite(table_name),
where=where_sql,
order_by=order_by,
)
try: try:
results = await db.execute( results = await db.execute(
@ -1582,14 +1607,21 @@ class TableAutocompleteView(BaseView):
if prefix_end: if prefix_end:
params["prefix_end"] = prefix_end params["prefix_end"] = prefix_end
first_pk = escape_sqlite(pks[0]) first_pk = escape_sqlite(pks[0])
fallback_where = f"{first_pk} >= :q and {first_pk} < :prefix_end and {fallback_where}" fallback_where = (
fallback_sql = f""" "{first_pk} >= :q and {first_pk} < :prefix_end and {like}"
).format(first_pk=first_pk, like=fallback_where)
fallback_sql = """
select {select_sql} select {select_sql}
from {escape_sqlite(table_name)} from {table}
where {fallback_where} where {where}
order by {_autocomplete_pk_order_by(pks)} order by {order_by}
limit 10 limit 10
""" """.format(
select_sql=select_sql,
table=escape_sqlite(table_name),
where=fallback_where,
order_by=_autocomplete_pk_order_by(pks),
)
try: try:
results = await db.execute( results = await db.execute(
fallback_sql, fallback_sql,
@ -1745,7 +1777,7 @@ async def table_view_traced(datasette, request):
) )
if isinstance(view_data, Response): if isinstance(view_data, Response):
return view_data return view_data
data, rows, columns, _expanded_columns, sql, next_url = view_data data, rows, columns, expanded_columns, sql, next_url = view_data
# Handle formats from plugins # Handle formats from plugins
if format_ == "csv": if format_ == "csv":
@ -1756,8 +1788,8 @@ async def table_view_traced(datasette, request):
rows, rows,
columns, columns,
expanded_columns, expanded_columns,
_sql, sql,
_next_url, next_url,
) = await table_view_data( ) = await table_view_data(
datasette, datasette,
request, request,
@ -1774,7 +1806,7 @@ async def table_view_traced(datasette, request):
return data, None, None return data, None, None
return await stream_csv(datasette, fetch_data, request, resolved.db.name) return await stream_csv(datasette, fetch_data, request, resolved.db.name)
elif format_ in datasette.renderers: elif format_ in datasette.renderers.keys():
# Dispatch request to the correct output format renderer # Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming) # (CSV is not handled here due to streaming)
result = call_with_supported_arguments( result = call_with_supported_arguments(
@ -1832,7 +1864,9 @@ async def table_view_traced(datasette, request):
) )
headers.update( headers.update(
{ {
"Link": f'<{alternate_url_json}>; rel="alternate"; type="application/json+datasette"' "Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
alternate_url_json
)
} }
) )
table_context = TableContext( table_context = TableContext(
@ -1917,7 +1951,7 @@ async def table_view_traced(datasette, request):
headers=headers, headers=headers,
) )
else: else:
assert False, f"Invalid format: {format_}" assert False, "Invalid format: {}".format(format_)
if next_url: if next_url:
r.headers["link"] = f'<{next_url}>; rel="next"' r.headers["link"] = f'<{next_url}>; rel="next"'
return r return r
@ -2108,7 +2142,9 @@ async def table_view_data(
extra_desc_only=( extra_desc_only=(
"" ""
if sort if sort
else f" or {escape_sqlite(sort or sort_desc)} is null" else " or {column2} is null".format(
column2=escape_sqlite(sort or sort_desc)
)
), ),
next_clauses=" and ".join(next_by_pk_clauses), next_clauses=" and ".join(next_by_pk_clauses),
) )
@ -2150,11 +2186,22 @@ async def table_view_data(
# Facets are calculated against SQL without order by or limit # Facets are calculated against SQL without order by or limit
sql_no_order_no_limit = ( sql_no_order_no_limit = (
f"select {select_all_columns} from {escape_sqlite(table_name)} {where_clause}" "select {select_all_columns} from {table_name} {where}".format(
select_all_columns=select_all_columns,
table_name=escape_sqlite(table_name),
where=where_clause,
)
) )
# This is the SQL that populates the main table on the page # This is the SQL that populates the main table on the page
sql = f"select {select_specified_columns} from {escape_sqlite(table_name)} {where_clause}{order_by} limit {page_size + 1}{offset}" sql = "select {select_specified_columns} from {table_name} {where}{order_by} limit {page_size}{offset}".format(
select_specified_columns=select_specified_columns,
table_name=escape_sqlite(table_name),
where=where_clause,
order_by=order_by,
page_size=page_size + 1,
offset=offset,
)
if request.args.get("_timelimit"): if request.args.get("_timelimit"):
extra_args["custom_time_limit"] = int(request.args.get("_timelimit")) extra_args["custom_time_limit"] = int(request.args.get("_timelimit"))
@ -2165,6 +2212,9 @@ async def table_view_data(
except (sqlite3.OperationalError, InvalidSql) as e: except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400) raise DatasetteError(str(e), title="Invalid SQL", status=400)
except sqlite3.OperationalError as e:
raise DatasetteError(str(e))
columns = [r[0] for r in results.description] columns = [r[0] for r in results.description]
rows = list(results.rows) rows = list(results.rows)
@ -2211,8 +2261,7 @@ async def table_view_data(
new_rows = [] new_rows = []
for row in rows: for row in rows:
new_row = CustomRow(columns) new_row = CustomRow(columns)
# CustomRow/sqlite3.Row iterate over values, so .keys() is required for column in row.keys():
for column in row.keys(): # noqa: SIM118
value = row[column] value = row[column]
if (column, value) in expanded_labels and value is not None: if (column, value) in expanded_labels and value is not None:
new_row[column] = { new_row[column] = {
@ -2249,7 +2298,7 @@ async def table_view_data(
# Data formats reject unknown extras; the HTML path (which passes # Data formats reject unknown extras; the HTML path (which passes
# extra_extras={"_html"}) resolves internal extras of its own # extra_extras={"_html"}) resolves internal extras of its own
table_extra_registry.validate_requested(extras, ExtraScope.TABLE) table_extra_registry.validate_requested(extras, ExtraScope.TABLE)
if any(k for k in request.args if k == "_facet" or k.startswith("_facet_")): if any(k for k in request.args.keys() if k == "_facet" or k.startswith("_facet_")):
extras.add("facet_results") extras.add("facet_results")
if request.args.get("_shape") == "object": if request.args.get("_shape") == "object":
extras.add("primary_keys") extras.add("primary_keys")
@ -2430,13 +2479,20 @@ async def _next_value_and_url(
except IndexError: except IndexError:
# sort/sort_desc column missing from SELECT - look up value by PK instead # sort/sort_desc column missing from SELECT - look up value by PK instead
prefix_where_clause = " and ".join( prefix_where_clause = " and ".join(
f"[{pk}] = :pk{i}" for i, pk in enumerate(pks) "[{}] = :pk{}".format(pk, i) for i, pk in enumerate(pks)
)
prefix_lookup_sql = "select [{}] from [{}] where {}".format(
sort or sort_desc, table_name, prefix_where_clause
) )
prefix_lookup_sql = f"select [{sort or sort_desc}] from [{table_name}] where {prefix_where_clause}"
prefix = ( prefix = (
await db.execute( await db.execute(
prefix_lookup_sql, prefix_lookup_sql,
{**{f"pk{i}": rows[-2][pk] for i, pk in enumerate(pks)}}, {
**{
"pk{}".format(i): rows[-2][pk]
for i, pk in enumerate(pks)
}
},
) )
).single_value() ).single_value()
if isinstance(prefix, dict) and "value" in prefix: if isinstance(prefix, dict) and "value" in prefix:

View file

@ -1,9 +1,9 @@
import json import json
import re import re
import time import time
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal, Union
import sqlite_utils from datasette.database import QueryInterrupted
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
ConfigDict, ConfigDict,
@ -13,18 +13,18 @@ from pydantic import (
model_validator, model_validator,
) )
from pydantic_core import PydanticCustomError from pydantic_core import PydanticCustomError
import sqlite_utils
from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT
from datasette.column_types import SQLiteType from datasette.column_types import SQLiteType
from datasette.database import QueryInterrupted
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.resources import DatabaseResource, TableResource from datasette.resources import DatabaseResource, TableResource
from datasette.utils import ( from datasette.utils import (
WriteJsonValueError,
decode_write_json_rows, decode_write_json_rows,
escape_sqlite, escape_sqlite,
get_outbound_foreign_keys, get_outbound_foreign_keys,
table_column_details, table_column_details,
WriteJsonValueError,
) )
from datasette.utils.asgi import NotFound, PayloadTooLarge, Response from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
from datasette.utils.sqlite import sqlite_hidden_table_names from datasette.utils.sqlite import sqlite_hidden_table_names
@ -136,14 +136,14 @@ def _foreign_key_name_reasons(source_column, target):
singular_table = _singular(table) singular_table = _singular(table)
column = target["fk_column"].lower() column = target["fk_column"].lower()
possible_names = { possible_names = {
f"{table}_{column}", "{}_{}".format(table, column),
f"{singular_table}_{column}", "{}_{}".format(singular_table, column),
} }
if column == "id": if column == "id":
possible_names.update( possible_names.update(
{ {
f"{table}_id", "{}_id".format(table),
f"{singular_table}_id", "{}_id".format(singular_table),
} }
) )
return ["name_match"] if source in possible_names else [] return ["name_match"] if source in possible_names else []
@ -262,8 +262,10 @@ async def _create_table_ui_context(
if not database_action_permissions.get("create-table"): if not database_action_permissions.get("create-table"):
return None return None
data = { data = {
"path": f"{datasette.urls.database(database_name)}/-/create", "path": "{}/-/create".format(datasette.urls.database(database_name)),
"foreignKeyTargetsPath": f"{datasette.urls.database(database_name)}/-/foreign-key-targets", "foreignKeyTargetsPath": "{}/-/foreign-key-targets".format(
datasette.urls.database(database_name)
),
"databaseName": database_name, "databaseName": database_name,
"columnTypes": CREATE_TABLE_COLUMN_TYPES, "columnTypes": CREATE_TABLE_COLUMN_TYPES,
"defaultExpressions": default_expression_options(), "defaultExpressions": default_expression_options(),
@ -396,15 +398,15 @@ def default_expr_for_sql(expression):
def _quoted_options(options): def _quoted_options(options):
if len(options) == 1: if len(options) == 1:
return f"'{options[0]}'" return "'{}'".format(options[0])
return "{} or '{}'".format( return "{} or '{}'".format(
", ".join(f"'{option}'" for option in options[:-1]), ", ".join("'{}'".format(option) for option in options[:-1]),
options[-1], options[-1],
) )
def _default_expr_error_message(): def _default_expr_error_message():
return f"Input should be {_quoted_options(list(DEFAULT_EXPRESSIONS))}" return "Input should be {}".format(_quoted_options(list(DEFAULT_EXPRESSIONS)))
def default_expression_options(): def default_expression_options():
@ -713,16 +715,18 @@ class SetForeignKeysOperation(_StrictPydanticModel):
AlterTableOperation = Annotated[ AlterTableOperation = Annotated[
AddColumnOperation Union[
| RenameColumnOperation AddColumnOperation,
| RenameTableOperation RenameColumnOperation,
| AlterColumnOperation RenameTableOperation,
| DropColumnOperation AlterColumnOperation,
| SetPrimaryKeyOperation DropColumnOperation,
| ReorderColumnsOperation SetPrimaryKeyOperation,
| AddForeignKeyOperation ReorderColumnsOperation,
| DropForeignKeyOperation AddForeignKeyOperation,
| SetForeignKeysOperation, DropForeignKeyOperation,
SetForeignKeysOperation,
],
Field(discriminator="op"), Field(discriminator="op"),
] ]
@ -736,7 +740,7 @@ def _pydantic_errors(validation_error):
for error in validation_error.errors(): for error in validation_error.errors():
location = ".".join(str(item) for item in error["loc"]) location = ".".join(str(item) for item in error["loc"])
message = error["msg"] message = error["msg"]
errors.append(f"{location}: {message}" if location else message) errors.append("{}: {}".format(location, message) if location else message)
return errors return errors
@ -757,7 +761,7 @@ def _create_table_pydantic_errors(validation_error):
output.append(message) output.append(message)
continue continue
location = ".".join(str(item) for item in error["loc"]) location = ".".join(str(item) for item in error["loc"])
output.append(f"{location}: {message}" if location else message) output.append("{}: {}".format(location, message) if location else message)
return output return output
@ -806,7 +810,7 @@ class TableCreateView(BaseView):
try: try:
data = await request.json() data = await request.json()
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return Response.error([f"Invalid JSON: {e}"]) return Response.error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e: except PayloadTooLarge as e:
return Response.error([str(e)], 413) return Response.error([str(e)], 413)
@ -821,13 +825,14 @@ class TableCreateView(BaseView):
ignore = create_request.ignore ignore = create_request.ignore
replace = create_request.replace replace = create_request.replace
# Replacing rows requires update-row permission if replace:
if replace and not await self.ds.allowed( # Must have update-row permission
action="update-row", if not await self.ds.allowed(
resource=DatabaseResource(database=database_name), action="update-row",
actor=request.actor, resource=DatabaseResource(database=database_name),
): actor=request.actor,
return Response.error(["Permission denied: need update-row"], 403) ):
return Response.error(["Permission denied: need update-row"], 403)
table_name = create_request.table table_name = create_request.table
table_exists = await db.table_exists(table_name) table_exists = await db.table_exists(table_name)
@ -873,14 +878,9 @@ class TableCreateView(BaseView):
actual_pks = await db.primary_keys(table_name) actual_pks = await db.primary_keys(table_name)
# if pk passed and table already exists check it does not change # if pk passed and table already exists check it does not change
bad_pks = False bad_pks = False
if ( if len(actual_pks) == 1 and pk and pk != actual_pks[0]:
len(actual_pks) == 1 bad_pks = True
and pk elif len(actual_pks) > 1 and pks and set(pks) != set(actual_pks):
and pk != actual_pks[0]
or len(actual_pks) > 1
and pks
and set(pks) != set(actual_pks)
):
bad_pks = True bad_pks = True
if bad_pks: if bad_pks:
return Response.error(["pk cannot be changed for existing table"]) return Response.error(["pk cannot be changed for existing table"])
@ -925,8 +925,7 @@ class TableCreateView(BaseView):
try: try:
schema = await db.execute_write_fn(create_table, request=request) schema = await db.execute_write_fn(create_table, request=request)
except Exception as e: # noqa: BLE001 except Exception as e:
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
return Response.error([str(e)]) return Response.error([str(e)])
if initial_schema is not None and initial_schema != schema: if initial_schema is not None and initial_schema != schema:
@ -1172,7 +1171,7 @@ class TableAlterView(BaseView):
try: try:
data = await request.json() data = await request.json()
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return Response.error([f"Invalid JSON: {e}"], 400) return Response.error(["Invalid JSON: {}".format(e)], 400)
except PayloadTooLarge as e: except PayloadTooLarge as e:
return Response.error([str(e)], 413) return Response.error([str(e)], 413)
@ -1312,7 +1311,10 @@ class TableAlterView(BaseView):
and rename_table_to != current_table_name and rename_table_to != current_table_name
): ):
operation_conn.execute( operation_conn.execute(
f"alter table {escape_sqlite(current_table_name)} rename to {escape_sqlite(rename_table_to)}" "alter table {} rename to {}".format(
escape_sqlite(current_table_name),
escape_sqlite(rename_table_to),
)
) )
current_table_name = rename_table_to current_table_name = rename_table_to
@ -1327,8 +1329,7 @@ class TableAlterView(BaseView):
before_schema, after_schema, after_table_name = await db.execute_write_fn( before_schema, after_schema, after_table_name = await db.execute_write_fn(
alter_table, request=request alter_table, request=request
) )
except Exception as e: # noqa: BLE001 except Exception as e:
# TODO: narrow to expected write errors so Datasette bugs surface as 500s
return Response.error([str(e)], 400) return Response.error([str(e)], 400)
altered = before_schema != after_schema altered = before_schema != after_schema

View file

@ -1,6 +1,5 @@
import itertools import itertools
from dataclasses import dataclass from dataclasses import dataclass
from typing import ClassVar
from datasette.column_types import SQLiteType from datasette.column_types import SQLiteType
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
@ -102,7 +101,7 @@ class QueryExtraContext:
class CountSqlExtra(Extra): class CountSqlExtra(Extra):
description = "SQL query string used to calculate the total count for the current table view, including active filters." description = "SQL query string used to calculate the total count for the current table view, including active filters."
example = ExtraExample("/fixtures/facetable.json?_size=0&_extra=count_sql") example = ExtraExample("/fixtures/facetable.json?_size=0&_extra=count_sql")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
return context.count_sql return context.count_sql
@ -111,7 +110,7 @@ class CountSqlExtra(Extra):
class CountExtra(Extra): class CountExtra(Extra):
description = "Total count of rows matching these filters" description = "Total count of rows matching these filters"
example = ExtraExample("/fixtures/facetable.json?_extra=count") example = ExtraExample("/fixtures/facetable.json?_extra=count")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
expensive = True expensive = True
async def resolve(self, context): async def resolve(self, context):
@ -129,7 +128,9 @@ class CountExtra(Extra):
pass pass
if context.count_sql and count is None and not context.nocount: if context.count_sql and count is None and not context.nocount:
count_sql_limited = f"select count(*) from (select * {context.from_sql} limit {context.db.count_limit + 1})" count_sql_limited = "select count(*) from (select * {} limit {})".format(
context.from_sql, context.db.count_limit + 1
)
try: try:
count_rows = list( count_rows = list(
await context.db.execute(count_sql_limited, context.from_sql_params) await context.db.execute(count_sql_limited, context.from_sql_params)
@ -159,7 +160,7 @@ def count_is_truncated(datasette, db, database_name, table_name, count_sql, coun
class CountTruncatedExtra(Extra): class CountTruncatedExtra(Extra):
description = "True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count." description = "True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count."
example = ExtraExample("/fixtures/facetable.json?_extra=count,count_truncated") example = ExtraExample("/fixtures/facetable.json?_extra=count,count_truncated")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
expensive = True expensive = True
async def resolve(self, context, count): async def resolve(self, context, count):
@ -174,7 +175,7 @@ class CountTruncatedExtra(Extra):
class FacetInstancesProvider(Provider): class FacetInstancesProvider(Provider):
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context, count): async def resolve(self, context, count):
facet_instances = [] facet_instances = []
@ -215,7 +216,7 @@ class FacetResultsExtra(Extra):
}, },
note="Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.", note="Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.",
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
expensive = True expensive = True
docs_note = "See :ref:`facets` for details of how facets work." docs_note = "See :ref:`facets` for details of how facets work."
@ -258,7 +259,7 @@ class FacetsTimedOutExtra(Extra):
"if every facet calculation completed." "if every facet calculation completed."
), ),
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context, facet_results): async def resolve(self, context, facet_results):
return facet_results["timed_out"] return facet_results["timed_out"]
@ -275,7 +276,7 @@ class SuggestedFacetsExtra(Extra):
], ],
note="Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.", note="Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.",
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
expensive = True expensive = True
docs_note = ( docs_note = (
"Suggestions are controlled by the :ref:`setting_suggest_facets` setting." "Suggestions are controlled by the :ref:`setting_suggest_facets` setting."
@ -303,7 +304,7 @@ class HumanDescriptionEnExtra(Extra):
example = ExtraExample( example = ExtraExample(
"/fixtures/facetable.json?state=CA&_sort=pk&_extra=human_description_en" "/fixtures/facetable.json?state=CA&_sort=pk&_extra=human_description_en"
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
human_description_en = context.filters.human_description_en( human_description_en = context.filters.human_description_en(
@ -323,7 +324,7 @@ class HumanDescriptionEnExtra(Extra):
class ColumnsExtra(Extra): class ColumnsExtra(Extra):
description = "List of column names returned by this table, row or query." description = "List of column names returned by this table, row or query."
example = ExtraExample("/fixtures/facetable.json?_extra=columns") example = ExtraExample("/fixtures/facetable.json?_extra=columns")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=columns" "/fixtures/simple_primary_key/1.json?_extra=columns"
), ),
@ -331,11 +332,7 @@ class ColumnsExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=columns" "/fixtures/-/query.json?sql=select+1+as+one&_extra=columns"
), ),
} }
scopes: ClassVar[set[ExtraScope]] = { scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
async def resolve(self, context): async def resolve(self, context):
return context.columns return context.columns
@ -344,7 +341,7 @@ class ColumnsExtra(Extra):
class AllColumnsExtra(Extra): class AllColumnsExtra(Extra):
description = "List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering." description = "List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering."
example = ExtraExample("/fixtures/facetable.json?_col=pk&_extra=all_columns") example = ExtraExample("/fixtures/facetable.json?_col=pk&_extra=all_columns")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
return list(context.table_columns) return list(context.table_columns)
@ -353,12 +350,12 @@ class AllColumnsExtra(Extra):
class PrimaryKeysExtra(Extra): class PrimaryKeysExtra(Extra):
description = "List of primary key column names for this table, or an empty list if the table has no explicit primary key." description = "List of primary key column names for this table, or an empty list if the table has no explicit primary key."
example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys") example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=primary_keys" "/fixtures/simple_primary_key/1.json?_extra=primary_keys"
) )
} }
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW} scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context): async def resolve(self, context):
return context.pks return context.pks
@ -396,12 +393,12 @@ class ColumnDetailsExtra(Extra):
"virtual generated columns and ``3`` for stored generated columns." "virtual generated columns and ``3`` for stored generated columns."
) )
example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details") example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/binary_data/1.json?_extra=column_details" "/fixtures/binary_data/1.json?_extra=column_details"
) )
} }
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW} scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context): async def resolve(self, context):
column_details = await context.datasette._get_resource_column_details( column_details = await context.datasette._get_resource_column_details(
@ -415,7 +412,7 @@ class ColumnDetailsExtra(Extra):
class ActionsExtra(Extra): class ActionsExtra(Extra):
description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.' description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
# Returns an async function for the HTML templates - not JSON serializable # Returns an async function for the HTML templates - not JSON serializable
public = False public = False
@ -487,7 +484,7 @@ async def precompute_database_action_permissions(datasette, actor, database_name
class IsViewExtra(Extra): class IsViewExtra(Extra):
description = "Whether this resource is a view instead of a table" description = "Whether this resource is a view instead of a table"
example = ExtraExample("/fixtures/simple_view.json?_extra=is_view") example = ExtraExample("/fixtures/simple_view.json?_extra=is_view")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
return context.is_view return context.is_view
@ -500,7 +497,7 @@ class DebugExtra(Extra):
"API and may change without warning." "API and may change without warning."
) )
example = ExtraExample("/fixtures/facetable.json?_extra=debug") example = ExtraExample("/fixtures/facetable.json?_extra=debug")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=debug" "/fixtures/simple_primary_key/1.json?_extra=debug"
), ),
@ -508,11 +505,7 @@ class DebugExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=debug" "/fixtures/-/query.json?sql=select+1+as+one&_extra=debug"
), ),
} }
scopes: ClassVar[set[ExtraScope]] = { scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
async def resolve(self, context): async def resolve(self, context):
debug = { debug = {
@ -536,7 +529,7 @@ class DebugExtra(Extra):
class RequestExtra(Extra): class RequestExtra(Extra):
description = "Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values." description = "Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values."
example = ExtraExample("/fixtures/facetable.json?_extra=request") example = ExtraExample("/fixtures/facetable.json?_extra=request")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=request" "/fixtures/simple_primary_key/1.json?_extra=request"
), ),
@ -544,11 +537,7 @@ class RequestExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=request" "/fixtures/-/query.json?sql=select+1+as+one&_extra=request"
), ),
} }
scopes: ClassVar[set[ExtraScope]] = { scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
async def resolve(self, context): async def resolve(self, context):
return { return {
@ -561,7 +550,7 @@ class RequestExtra(Extra):
class DisplayColumnsAndRowsProvider(Provider): class DisplayColumnsAndRowsProvider(Provider):
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
display_columns, display_rows = await context.display_columns_and_rows( display_columns, display_rows = await context.display_columns_and_rows(
@ -605,7 +594,7 @@ class DisplayColumnsExtra(Extra):
], ],
note="Shape abbreviated from /fixtures/facetable.json?_size=1&_extra=display_columns.", note="Shape abbreviated from /fixtures/facetable.json?_size=1&_extra=display_columns.",
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context, display_columns_and_rows): async def resolve(self, context, display_columns_and_rows):
return display_columns_and_rows["columns"] return display_columns_and_rows["columns"]
@ -613,7 +602,7 @@ class DisplayColumnsExtra(Extra):
class DisplayRowsExtra(Extra): class DisplayRowsExtra(Extra):
description = "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object." description = "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object."
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
# Contains markupsafe/sqlite3.Row values - not JSON serializable # Contains markupsafe/sqlite3.Row values - not JSON serializable
public = False public = False
@ -644,7 +633,7 @@ class RenderCellExtra(Extra):
"whose rendered value differs from the default are included." "whose rendered value differs from the default are included."
), ),
) )
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
value={ value={
"rows": [{"id": 4, "content": "RENDER_CELL_DEMO"}], "rows": [{"id": 4, "content": "RENDER_CELL_DEMO"}],
@ -669,11 +658,7 @@ class RenderCellExtra(Extra):
), ),
), ),
} }
scopes: ClassVar[set[ExtraScope]] = { scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
async def resolve(self, context): async def resolve(self, context):
table_name = context.table_name table_name = context.table_name
@ -727,7 +712,7 @@ class RenderCellExtra(Extra):
class QueryExtra(Extra): class QueryExtra(Extra):
description = "Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys." description = "Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys."
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query") example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=query" "/fixtures/simple_primary_key/1.json?_extra=query"
), ),
@ -736,11 +721,7 @@ class QueryExtra(Extra):
ExtraExample("/fixtures/neighborhood_search.json?text=town&_extra=query"), ExtraExample("/fixtures/neighborhood_search.json?text=town&_extra=query"),
], ],
} }
scopes: ClassVar[set[ExtraScope]] = { scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
async def resolve(self, context): async def resolve(self, context):
return { return {
@ -764,7 +745,7 @@ class ColumnTypesExtra(Extra):
"been assigned the ``json`` column type." "been assigned the ``json`` column type."
), ),
) )
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/facetable/1.json?_extra=column_types", "/fixtures/facetable/1.json?_extra=column_types",
note=( note=(
@ -773,7 +754,7 @@ class ColumnTypesExtra(Extra):
), ),
) )
} }
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW} scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context): async def resolve(self, context):
ct_map = await context.datasette.get_column_types( ct_map = await context.datasette.get_column_types(
@ -823,7 +804,7 @@ class SetColumnTypeUiExtra(Extra):
"types that could be assigned to it." "types that could be assigned to it."
), ),
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
if context.is_view: if context.is_view:
@ -865,7 +846,9 @@ class SetColumnTypeUiExtra(Extra):
], ],
} }
return { return {
"path": f"{context.datasette.urls.table(context.database_name, context.table_name)}/-/set-column-type", "path": "{}/-/set-column-type".format(
context.datasette.urls.table(context.database_name, context.table_name)
),
"columns": columns, "columns": columns,
} }
@ -883,7 +866,7 @@ class MetadataExtra(Extra):
"descriptions." "descriptions."
), ),
) )
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=metadata", "/fixtures/simple_primary_key/1.json?_extra=metadata",
note=( note=(
@ -901,11 +884,7 @@ class MetadataExtra(Extra):
), ),
), ),
} }
scopes: ClassVar[set[ExtraScope]] = { scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
async def resolve(self, context): async def resolve(self, context):
if context.scope == ExtraScope.QUERY: if context.scope == ExtraScope.QUERY:
@ -934,7 +913,7 @@ class MetadataExtra(Extra):
class DatabaseExtra(Extra): class DatabaseExtra(Extra):
description = "Database name" description = "Database name"
example = ExtraExample("/fixtures/facetable.json?_extra=database") example = ExtraExample("/fixtures/facetable.json?_extra=database")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=database" "/fixtures/simple_primary_key/1.json?_extra=database"
), ),
@ -942,11 +921,7 @@ class DatabaseExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database" "/fixtures/-/query.json?sql=select+1+as+one&_extra=database"
), ),
} }
scopes: ClassVar[set[ExtraScope]] = { scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
async def resolve(self, context): async def resolve(self, context):
return context.database_name return context.database_name
@ -955,10 +930,10 @@ class DatabaseExtra(Extra):
class TableExtra(Extra): class TableExtra(Extra):
description = "Table name" description = "Table name"
example = ExtraExample("/fixtures/facetable.json?_extra=table") example = ExtraExample("/fixtures/facetable.json?_extra=table")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample("/fixtures/simple_primary_key/1.json?_extra=table") ExtraScope.ROW: ExtraExample("/fixtures/simple_primary_key/1.json?_extra=table")
} }
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE, ExtraScope.ROW} scopes = {ExtraScope.TABLE, ExtraScope.ROW}
async def resolve(self, context): async def resolve(self, context):
return context.table_name return context.table_name
@ -971,7 +946,7 @@ class DatabaseColorExtra(Extra):
"a hash of the database name and used in the Datasette interface." "a hash of the database name and used in the Datasette interface."
) )
example = ExtraExample("/fixtures/facetable.json?_extra=database_color") example = ExtraExample("/fixtures/facetable.json?_extra=database_color")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=database_color" "/fixtures/simple_primary_key/1.json?_extra=database_color"
), ),
@ -979,11 +954,7 @@ class DatabaseColorExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=database_color" "/fixtures/-/query.json?sql=select+1+as+one&_extra=database_color"
), ),
} }
scopes: ClassVar[set[ExtraScope]] = { scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
async def resolve(self, context): async def resolve(self, context):
return context.db.color return context.db.color
@ -994,7 +965,7 @@ class FormHiddenArgsExtra(Extra):
example = ExtraExample( example = ExtraExample(
"/fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args" "/fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args"
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
form_hidden_args = [] form_hidden_args = []
@ -1011,7 +982,7 @@ class FormHiddenArgsExtra(Extra):
class FiltersExtra(Extra): class FiltersExtra(Extra):
description = "``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable." description = "``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable."
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
# Returns a Filters instance for the HTML templates - not JSON serializable # Returns a Filters instance for the HTML templates - not JSON serializable
public = False public = False
@ -1027,7 +998,7 @@ class CustomTableTemplatesExtra(Extra):
":ref:`customization_custom_templates`." ":ref:`customization_custom_templates`."
) )
example = ExtraExample("/fixtures/facetable.json?_extra=custom_table_templates") example = ExtraExample("/fixtures/facetable.json?_extra=custom_table_templates")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
return [ return [
@ -1048,7 +1019,7 @@ class SortedFacetResultsExtra(Extra):
example = ExtraExample( example = ExtraExample(
"/fixtures/facetable.json?_facet=state&_extra=sorted_facet_results" "/fixtures/facetable.json?_facet=state&_extra=sorted_facet_results"
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context, facet_results): async def resolve(self, context, facet_results):
facet_configs = context.table_metadata.get("facets", []) facet_configs = context.table_metadata.get("facets", [])
@ -1058,7 +1029,7 @@ class SortedFacetResultsExtra(Extra):
if isinstance(fc, str): if isinstance(fc, str):
metadata_facet_names.append(fc) metadata_facet_names.append(fc)
elif isinstance(fc, dict): elif isinstance(fc, dict):
metadata_facet_names.append(next(iter(fc.values()))) metadata_facet_names.append(list(fc.values())[0])
metadata_order = {name: i for i, name in enumerate(metadata_facet_names)} metadata_order = {name: i for i, name in enumerate(metadata_facet_names)}
metadata_facets = [] metadata_facets = []
request_facets = [] request_facets = []
@ -1084,7 +1055,7 @@ class SortedFacetResultsExtra(Extra):
class TableDefinitionExtra(Extra): class TableDefinitionExtra(Extra):
description = "SQL definition for this table" description = "SQL definition for this table"
example = ExtraExample("/fixtures/facetable.json?_extra=table_definition") example = ExtraExample("/fixtures/facetable.json?_extra=table_definition")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
return await context.db.get_table_definition(context.table_name) return await context.db.get_table_definition(context.table_name)
@ -1093,7 +1064,7 @@ class TableDefinitionExtra(Extra):
class ViewDefinitionExtra(Extra): class ViewDefinitionExtra(Extra):
description = "SQL definition for this view" description = "SQL definition for this view"
example = ExtraExample("/fixtures/simple_view.json?_extra=view_definition") example = ExtraExample("/fixtures/simple_view.json?_extra=view_definition")
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
return await context.db.get_view_definition(context.table_name) return await context.db.get_view_definition(context.table_name)
@ -1110,7 +1081,7 @@ class RenderersExtra(Extra):
"<plugin_register_output_renderer>`." "<plugin_register_output_renderer>`."
), ),
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context, expandable_columns, query): async def resolve(self, context, expandable_columns, query):
renderers = {} renderers = {}
@ -1152,7 +1123,7 @@ class PrivateExtra(Extra):
"anonymous user could not. See :ref:`authentication_permissions`." "anonymous user could not. See :ref:`authentication_permissions`."
) )
example = ExtraExample("/fixtures/facetable.json?_extra=private") example = ExtraExample("/fixtures/facetable.json?_extra=private")
examples: ClassVar[dict[ExtraScope, ExtraExample | list[ExtraExample]]] = { examples = {
ExtraScope.ROW: ExtraExample( ExtraScope.ROW: ExtraExample(
"/fixtures/simple_primary_key/1.json?_extra=private" "/fixtures/simple_primary_key/1.json?_extra=private"
), ),
@ -1160,11 +1131,7 @@ class PrivateExtra(Extra):
"/fixtures/-/query.json?sql=select+1+as+one&_extra=private" "/fixtures/-/query.json?sql=select+1+as+one&_extra=private"
), ),
} }
scopes: ClassVar[set[ExtraScope]] = { scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
async def resolve(self, context): async def resolve(self, context):
return context.private return context.private
@ -1181,7 +1148,7 @@ class ExpandableColumnsExtra(Extra):
"that would be used as the label for each expanded value." "that would be used as the label for each expanded value."
), ),
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
expandables = [] expandables = []
@ -1201,7 +1168,7 @@ class ForeignKeyTablesExtra(Extra):
"reference this row, and ``link`` is a URL to browse those rows." "reference this row, and ``link`` is a URL to browse those rows."
), ),
) )
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.ROW} scopes = {ExtraScope.ROW}
expensive = True expensive = True
async def resolve(self, context): async def resolve(self, context):
@ -1235,11 +1202,7 @@ class ExtrasExtra(Extra):
"the current request." "the current request."
), ),
) )
scopes: ClassVar[set[ExtraScope]] = { scopes = {ExtraScope.TABLE, ExtraScope.ROW, ExtraScope.QUERY}
ExtraScope.TABLE,
ExtraScope.ROW,
ExtraScope.QUERY,
}
async def resolve(self, context): async def resolve(self, context):
all_extras = [ all_extras = [

View file

@ -1,3 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# #
# Datasette documentation build configuration file, created by # Datasette documentation build configuration file, created by
# sphinx-quickstart on Thu Nov 16 06:50:13 2017. # sphinx-quickstart on Thu Nov 16 06:50:13 2017.

View file

@ -46,7 +46,7 @@ def table_extras(cog):
cog.out("\n") cog.out("\n")
for scope, heading, intro, classes in classes_by_scope: for scope, heading, intro, classes in classes_by_scope:
cog.out("{}\n{}\n\n".format(heading, "~" * len(heading))) cog.out("{}\n{}\n\n".format(heading, "~" * len(heading)))
cog.out(f"{intro}\n\n") cog.out("{}\n\n".format(intro))
for cls in classes: for cls in classes:
examples = _examples_for_scope(cls, scope) examples = _examples_for_scope(cls, scope)
description = cls.description or "" description = cls.description or ""
@ -58,16 +58,16 @@ def table_extras(cog):
if notes: if notes:
description = "{} ({})".format(description, " ".join(notes)).strip() description = "{} ({})".format(description, " ".join(notes)).strip()
cog.out(f"``{cls.key()}``\n") cog.out("``{}``\n".format(cls.key()))
cog.out(f" {description}\n\n") cog.out(" {}\n\n".format(description))
for example in examples: for example in examples:
if example.path: if example.path:
value = live_examples[(example.path, example.key or cls.key())] value = live_examples[(example.path, example.key or cls.key())]
cog.out(f" ``GET {example.path}``\n\n") cog.out(" ``GET {}``\n\n".format(example.path))
else: else:
value = example.value value = example.value
if example.note: if example.note:
cog.out(f" {example.note}\n\n") cog.out(" {}\n\n".format(example.note))
cog.out(" .. code-block:: json\n\n") cog.out(" .. code-block:: json\n\n")
cog.out(textwrap.indent(json.dumps(value, indent=2), " ")) cog.out(textwrap.indent(json.dumps(value, indent=2), " "))
cog.out("\n\n") cog.out("\n\n")
@ -139,7 +139,7 @@ async def _fetch_live_examples(scoped_classes):
response = await datasette.client.get(example.path) response = await datasette.client.get(example.path)
assert response.status_code == 200, example.path assert response.status_code == 200, example.path
data = response.json() data = response.json()
assert key in data, f"{key} missing from {example.path}" assert key in data, "{} missing from {}".format(key, example.path)
examples[(example.path, key)] = data[key] examples[(example.path, key)] = data[key]
finally: finally:
for db in datasette.databases.values(): for db in datasette.databases.values():

View file

@ -1,8 +1,7 @@
import json import json
import textwrap import textwrap
from ruamel.yaml import YAML
from yaml import safe_dump from yaml import safe_dump
from ruamel.yaml import YAML
def metadata_example(cog, data=None, yaml=None): def metadata_example(cog, data=None, yaml=None):
@ -34,10 +33,10 @@ def config_example(
else: else:
data = input data = input
output_yaml = safe_dump(input, sort_keys=False) output_yaml = safe_dump(input, sort_keys=False)
cog.out(f"\n.. tab:: {yaml_title}\n\n") cog.out("\n.. tab:: {}\n\n".format(yaml_title))
cog.out(" .. code-block:: yaml\n\n") cog.out(" .. code-block:: yaml\n\n")
cog.out(textwrap.indent(output_yaml, " ")) cog.out(textwrap.indent(output_yaml, " "))
cog.out(f"\n\n.. tab:: {json_title}\n\n") cog.out("\n\n.. tab:: {}\n\n".format(json_title))
cog.out(" .. code-block:: json\n\n") cog.out(" .. code-block:: json\n\n")
cog.out(textwrap.indent(json.dumps(data, indent=2), " ")) cog.out(textwrap.indent(json.dumps(data, indent=2), " "))
cog.out("\n") cog.out("\n")
@ -45,10 +44,8 @@ def config_example(
def internal_schema(cog): def internal_schema(cog):
import asyncio import asyncio
from sqlite_utils import Database
from datasette.app import Datasette from datasette.app import Datasette
from sqlite_utils import Database
ds = Datasette() ds = Datasette()
db = ds.get_internal_database() db = ds.get_internal_database()

View file

@ -21,12 +21,14 @@ def template_context(cog):
), ),
) )
for name, doc in TEMPLATE_BASE_CONTEXT.items(): for name, doc in TEMPLATE_BASE_CONTEXT.items():
cog.out(f"``{name}``\n") cog.out("``{}``\n".format(name))
cog.out(f" {doc}\n\n") cog.out(" {}\n\n".format(doc))
for klass in PAGES.values(): for klass in PAGES.values():
title = "{} page".format(klass.__name__.removesuffix("Context")) title = "{} page".format(klass.__name__.removesuffix("Context"))
intro = f"{klass.__doc__} Rendered using the ``{klass.documented_template}`` template." intro = "{} Rendered using the ``{}`` template.".format(
klass.__doc__, klass.documented_template
)
_section(cog, title, intro) _section(cog, title, intro)
if klass.extras_scope is not None: if klass.extras_scope is not None:
cog.out( cog.out(
@ -34,10 +36,10 @@ def template_context(cog):
"<json_api>` for this page.\n\n" "<json_api>` for this page.\n\n"
) )
for f in sorted(klass.documented_fields(), key=lambda f: f.name): for f in sorted(klass.documented_fields(), key=lambda f: f.name):
cog.out(f"``{f.name}`` - ``{f.type_name}``\n") cog.out("``{}`` - ``{}``\n".format(f.name, f.type_name))
cog.out(f" {f.help}\n\n") cog.out(" {}\n\n".format(f.help))
def _section(cog, title, intro): def _section(cog, title, intro):
cog.out("{}\n{}\n\n".format(title, "-" * len(title))) cog.out("{}\n{}\n\n".format(title, "-" * len(title)))
cog.out(f"{intro}\n\n") cog.out("{}\n\n".format(intro))

View file

@ -69,7 +69,7 @@ dev = [
"trustme>=0.7", "trustme>=0.7",
"cogapp>=3.3.0", "cogapp>=3.3.0",
"multipart-form-data-conformance==0.1a0", "multipart-form-data-conformance==0.1a0",
"ruff>=0.16.0", "ruff>=0.9",
# docs # docs
"Sphinx==7.4.7", "Sphinx==7.4.7",
"furo==2025.9.25", "furo==2025.9.25",
@ -102,5 +102,9 @@ datasette = ["templates/*.html"]
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
version = {attr = "datasette.version.__version__"} version = {attr = "datasette.version.__version__"}
[tool.ruff]
line-length = 160
select = ["E", "F", "W"]
[tool.uv] [tool.uv]
package = true package = true

View file

@ -1,7 +1,2 @@
line-length = 160 line-length = 160
target-version = "py310" target-version = "py310"
[lint.flake8-bugbear]
# from_extra() returns a dataclasses.field(), so it is safe as a dataclass
# default - ruff cannot see through the wrapper (RUF009)
extend-immutable-calls = ["datasette.views.from_extra"]

View file

@ -1,17 +1,15 @@
import httpx
import importlib.metadata import importlib.metadata
import os import os
import pathlib import pathlib
import pytest
import pytest_asyncio
import re import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time import time
from dataclasses import dataclass from dataclasses import dataclass
import httpx
import pytest
import pytest_asyncio
from datasette import Event, hookimpl from datasette import Event, hookimpl
try: try:
@ -40,7 +38,7 @@ def wait_until_responds(url, timeout=5.0, client=httpx, **kwargs):
return return
except httpx.ConnectError: except httpx.ConnectError:
time.sleep(0.1) time.sleep(0.1)
raise AssertionError(f"Timed out waiting for {url} to respond") raise AssertionError("Timed out waiting for {} to respond".format(url))
@pytest.fixture @pytest.fixture
@ -56,12 +54,10 @@ def bare_ds():
@pytest_asyncio.fixture(scope="session") @pytest_asyncio.fixture(scope="session")
async def ds_client(): async def ds_client():
import secrets
from datasette.app import Datasette from datasette.app import Datasette
from datasette.database import Database from datasette.database import Database
from .fixtures import CONFIG, METADATA, PLUGINS_DIR from .fixtures import CONFIG, METADATA, PLUGINS_DIR
import secrets
ds = Datasette( ds = Datasette(
metadata=METADATA, metadata=METADATA,
@ -100,8 +96,8 @@ def pytest_report_header(config):
conn.close() conn.close()
sqlite_utils_version = importlib.metadata.version("sqlite-utils") sqlite_utils_version = importlib.metadata.version("sqlite-utils")
headers = [ headers = [
f"SQLite: {version}", "SQLite: {}".format(version),
f"sqlite-utils: {sqlite_utils_version}", "sqlite-utils: {}".format(sqlite_utils_version),
] ]
if config.getoption("--playwright"): if config.getoption("--playwright"):
try: try:
@ -179,8 +175,8 @@ def restore_working_directory(tmpdir, request):
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def check_actions_are_documented(): def check_actions_are_documented():
from datasette.default_actions import register_actions as default_register_actions
from datasette.plugins import pm from datasette.plugins import pm
from datasette.default_actions import register_actions as default_register_actions
content = ( content = (
pathlib.Path(__file__).parent.parent / "docs" / "authentication.rst" pathlib.Path(__file__).parent.parent / "docs" / "authentication.rst"
@ -206,7 +202,7 @@ def check_actions_are_documented():
if kwargs["action"] in core_actions: if kwargs["action"] in core_actions:
assert ( assert (
action in documented_actions action in documented_actions
), f"Undocumented permission action: {action}" ), "Undocumented permission action: {}".format(action)
pm.add_hookcall_monitoring( pm.add_hookcall_monitoring(
before=before, after=lambda outcome, hook_name, hook_impls, kwargs: None before=before, after=lambda outcome, hook_name, hook_impls, kwargs: None
@ -302,8 +298,7 @@ def ds_unix_domain_socket_server(tmp_path_factory):
# Import fixtures from fixtures.py to make them available # Import fixtures from fixtures.py to make them available
from .fixtures import ( # noqa: F401 from .fixtures import ( # noqa: E402, F401
TEMP_PLUGIN_SECRET_FILE,
app_client, app_client,
app_client_base_url_prefix, app_client_base_url_prefix,
app_client_conflicting_database_names, app_client_conflicting_database_names,
@ -320,4 +315,5 @@ from .fixtures import ( # noqa: F401
app_client_with_dot, app_client_with_dot,
app_client_with_trace, app_client_with_trace,
make_app_client, make_app_client,
TEMP_PLUGIN_SECRET_FILE,
) )

View file

@ -1,13 +1,3 @@
import contextlib
import json
import os
import pathlib
import tempfile
import textwrap
import click
import pytest
from datasette.app import Datasette from datasette.app import Datasette
from datasette.fixtures import ( from datasette.fixtures import (
EXTRA_DATABASE_SQL, EXTRA_DATABASE_SQL,
@ -15,6 +5,14 @@ from datasette.fixtures import (
write_fixture_database, write_fixture_database,
) )
from datasette.utils.testing import TestClient from datasette.utils.testing import TestClient
import click
import contextlib
import json
import os
import pathlib
import pytest
import tempfile
import textwrap
# This temp file is used by one of the plugin config tests # This temp file is used by one of the plugin config tests
TEMP_PLUGIN_SECRET_FILE = os.path.join(tempfile.gettempdir(), "plugin-secret") TEMP_PLUGIN_SECRET_FILE = os.path.join(tempfile.gettempdir(), "plugin-secret")

View file

@ -1,16 +1,16 @@
import asyncio import asyncio
from datasette import hookimpl
from datasette.facets import Facet
from datasette.tokens import TokenHandler
from datasette import tracer
from datasette.permissions import Action
from datasette.resources import DatabaseResource
from datasette.utils import path_with_added_args
from datasette.utils.asgi import asgi_send_json, Response
import base64 import base64
import json import json
import urllib.parse import urllib.parse
from datasette import hookimpl, tracer
from datasette.facets import Facet
from datasette.permissions import Action
from datasette.resources import DatabaseResource
from datasette.tokens import TokenHandler
from datasette.utils import path_with_added_args
from datasette.utils.asgi import Response, asgi_send_json
@hookimpl @hookimpl
def prepare_connection(conn, database, datasette): def prepare_connection(conn, database, datasette):
@ -305,7 +305,11 @@ def startup(datasette):
datasette._startup_hook_fired = True datasette._startup_hook_fired = True
# And test some import shortcuts too # And test some import shortcuts too
from datasette import Forbidden, NotFound, Response, actor_matches_allow, hookimpl from datasette import Response
from datasette import Forbidden
from datasette import NotFound
from datasette import hookimpl
from datasette import actor_matches_allow
_ = (Response, Forbidden, NotFound, hookimpl, actor_matches_allow) _ = (Response, Forbidden, NotFound, hookimpl, actor_matches_allow)
@ -369,7 +373,7 @@ def table_actions(datasette, database, table, actor, request):
"label": "Plugin button", "label": "Plugin button",
"description": "Runs JavaScript from a plugin", "description": "Runs JavaScript from a plugin",
"attrs": { "attrs": {
"aria-label": f"Plugin button for {table}", "aria-label": "Plugin button for {}".format(table),
"data-plugin-action": "plugin-button", "data-plugin-action": "plugin-button",
"data-database": database, "data-database": database,
"data-table": table, "data-table": table,

View file

@ -1,10 +1,8 @@
import json
from functools import wraps
import markupsafe
from datasette import hookimpl from datasette import hookimpl
from datasette.utils.asgi import Response from datasette.utils.asgi import Response
from functools import wraps
import markupsafe
import json
@hookimpl @hookimpl
@ -35,7 +33,11 @@ def render_cell(value, database):
if set(data.keys()) != {"href", "label"}: if set(data.keys()) != {"href", "label"}:
return None return None
href = data["href"] href = data["href"]
if not (href.startswith(("/", "http://", "https://"))): if not (
href.startswith("/")
or href.startswith("http://")
or href.startswith("https://")
):
return None return None
return markupsafe.Markup( return markupsafe.Markup(
'<a data-database="{database}" href="{href}">{label}</a>'.format( '<a data-database="{database}" href="{href}">{label}</a>'.format(
@ -52,7 +54,7 @@ def extra_template_vars(template, database, table, view_name, request, datasette
datasette._last_request = request datasette._last_request = request
async def query_database(sql): async def query_database(sql):
first_db = next(iter(datasette.databases.keys())) first_db = list(datasette.databases.keys())[0]
return (await datasette.execute(first_db, sql)).rows[0][0] return (await datasette.execute(first_db, sql)).rows[0][0]
async def inner(): async def inner():
@ -170,10 +172,10 @@ def register_routes(datasette):
path = config["path"] path = config["path"]
def new_table(request): def new_table(request):
return Response.text(f"/db/table: {sorted(request.url_vars.items())}") return Response.text("/db/table: {}".format(sorted(request.url_vars.items())))
return [ return [
(rf"/{path}/$", lambda: Response.text(path.upper())), (r"/{}/$".format(path), lambda: Response.text(path.upper())),
# Also serves to demonstrate over-ride of default paths: # Also serves to demonstrate over-ride of default paths:
(r"/(?P<db_name>[^/]+)/(?P<table_and_format>[^/]+?$)", new_table), (r"/(?P<db_name>[^/]+)/(?P<table_and_format>[^/]+?$)", new_table),
] ]

View file

@ -1,7 +1,6 @@
import json
from datasette import hookimpl from datasette import hookimpl
from datasette.utils.asgi import Response from datasette.utils.asgi import Response
import json
async def can_render( async def can_render(
@ -19,7 +18,9 @@ async def can_render(
"request": request, "request": request,
"view_name": view_name, "view_name": view_name,
} }
return not request.args.get("_no_can_render") if request.args.get("_no_can_render"):
return False
return True
async def render_test_all_parameters( async def render_test_all_parameters(

View file

@ -1,6 +1,5 @@
import time
from datasette import hookimpl from datasette import hookimpl
import time
@hookimpl @hookimpl

View file

@ -10,11 +10,10 @@ These tests verify:
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from datasette import hookimpl
from datasette.app import Datasette from datasette.app import Datasette
from datasette.permissions import PermissionSQL from datasette.permissions import PermissionSQL
from datasette.resources import DatabaseResource, QueryResource, TableResource from datasette.resources import DatabaseResource, QueryResource, TableResource
from datasette import hookimpl
def test_resource_string_representations(): def test_resource_string_representations():
@ -91,7 +90,7 @@ async def test_allowed_resources_global_allow(test_ds):
assert all(isinstance(t, TableResource) for t in tables) assert all(isinstance(t, TableResource) for t in tables)
# Check specific tables are present # Check specific tables are present
table_set = {(t.parent, t.child) for t in tables} table_set = set((t.parent, t.child) for t in tables)
assert ("analytics", "events") in table_set assert ("analytics", "events") in table_set
assert ("analytics", "users") in table_set assert ("analytics", "users") in table_set
assert ("analytics", "sensitive") in table_set assert ("analytics", "sensitive") in table_set

View file

@ -6,7 +6,6 @@ config allow blocks can bypass table-level restrictions.
""" """
import pytest import pytest
from datasette.app import Datasette from datasette.app import Datasette
from datasette.resources import TableResource from datasette.resources import TableResource

View file

@ -10,8 +10,6 @@ Layer 3: table/database views precompute all registered actions before
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from datasette import hookimpl
from datasette.app import Datasette from datasette.app import Datasette
from datasette.permissions import ( from datasette.permissions import (
Action, Action,
@ -20,6 +18,7 @@ from datasette.permissions import (
_permission_check_cache, _permission_check_cache,
) )
from datasette.resources import DatabaseResource, TableResource from datasette.resources import DatabaseResource, TableResource
from datasette import hookimpl
class CountingRulesPlugin: class CountingRulesPlugin:
@ -115,7 +114,7 @@ async def test_allowed_not_memoized_without_cache(counting_ds):
async def test_cache_keyed_on_full_actor_identity(counting_ds): async def test_cache_keyed_on_full_actor_identity(counting_ds):
"""Interleaved checks for different actors never share cache entries.""" """Interleaved checks for different actors never share cache entries."""
# Uses drop-table because default permissions deny it to non-root actors # Uses drop-table because default permissions deny it to non-root actors
ds, _plugin = counting_ds ds, plugin = counting_ds
resource = TableResource("analytics", "users") resource = TableResource("analytics", "users")
token = _permission_check_cache.set({}) token = _permission_check_cache.set({})
try: try:
@ -181,7 +180,7 @@ async def test_cache_keyed_on_resource(counting_ds):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skip_permission_checks_bypasses_cache(counting_ds): async def test_skip_permission_checks_bypasses_cache(counting_ds):
ds, _plugin = counting_ds ds, plugin = counting_ds
resource = TableResource("analytics", "users") resource = TableResource("analytics", "users")
token = _permission_check_cache.set({}) token = _permission_check_cache.set({})
try: try:

View file

@ -7,10 +7,9 @@ based on permission rules from plugins and configuration.
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from datasette import hookimpl
from datasette.app import Datasette from datasette.app import Datasette
from datasette.permissions import PermissionSQL from datasette.permissions import PermissionSQL
from datasette import hookimpl
# Test plugin that provides permission rules # Test plugin that provides permission rules

View file

@ -1,15 +1,13 @@
import pathlib
import urllib
import pytest
from datasette.app import Datasette from datasette.app import Datasette
from datasette.plugins import DEFAULT_PLUGINS from datasette.plugins import DEFAULT_PLUGINS
from datasette.utils import UNSTABLE_API_MESSAGE, escape_sqlite, tilde_encode from datasette.utils import UNSTABLE_API_MESSAGE, escape_sqlite, tilde_encode
from datasette.utils.sqlite import sqlite_version from datasette.utils.sqlite import sqlite_version
from datasette.version import __version__ from datasette.version import __version__
from .fixtures import make_app_client, EXPECTED_PLUGINS
from .fixtures import EXPECTED_PLUGINS, make_app_client import pathlib
import pytest
import sys
import urllib
@pytest.mark.asyncio @pytest.mark.asyncio
@ -18,7 +16,7 @@ async def test_homepage(ds_client):
assert response.status_code == 200 assert response.status_code == 200
assert "application/json; charset=utf-8" == response.headers["content-type"] assert "application/json; charset=utf-8" == response.headers["content-type"]
data = response.json() data = response.json()
assert sorted(data.get("metadata").keys()) == [ assert sorted(list(data.get("metadata").keys())) == [
"about", "about",
"about_url", "about_url",
"description_html", "description_html",
@ -386,7 +384,9 @@ async def test_row_pk_arity_mismatch_returns_400(ds_client, row_path, suffix):
# because the SQL had one bind placeholder per PK column but params were # because the SQL had one bind placeholder per PK column but params were
# only bound for the supplied components. It should be a 400 instead, # only bound for the supplied components. It should be a 400 instead,
# mirroring the existing guard in datasette/views/table.py. # mirroring the existing guard in datasette/views/table.py.
response = await ds_client.get(f"/fixtures/compound_primary_key/{row_path}{suffix}") response = await ds_client.get(
"/fixtures/compound_primary_key/{}{}".format(row_path, suffix)
)
assert response.status_code == 400 assert response.status_code == 400
if suffix == ".json": if suffix == ".json":
assert response.json()["ok"] is False assert response.json()["ok"] is False
@ -600,7 +600,8 @@ async def test_threads_json(ds_client):
finally: finally:
ds_client.ds.root_enabled = False ds_client.ds.root_enabled = False
expected_keys = {"ok", "threads", "num_threads"} expected_keys = {"ok", "threads", "num_threads"}
expected_keys.update({"tasks", "num_tasks"}) if sys.version_info >= (3, 7, 0):
expected_keys.update({"tasks", "num_tasks"})
data = response.json() data = response.json()
assert set(data.keys()) == expected_keys assert set(data.keys()) == expected_keys
# Should be at least one _execute_writes thread for __INTERNAL__ # Should be at least one _execute_writes thread for __INTERNAL__
@ -938,10 +939,12 @@ async def test_table_with_reserved_characters_in_name(table_name):
ds = Datasette() ds = Datasette()
db = ds.add_memory_database("test_reserved_table_names") db = ds.add_memory_database("test_reserved_table_names")
await db.execute_write( await db.execute_write(
f"create table {escape_sqlite(table_name)} (id integer primary key, name text)" "create table {} (id integer primary key, name text)".format(
escape_sqlite(table_name)
)
) )
await db.execute_write( await db.execute_write(
f"insert into {escape_sqlite(table_name)} (id, name) values (1, 'one')" "insert into {} (id, name) values (1, 'one')".format(escape_sqlite(table_name))
) )
# Schema introspection (populate_schema_tables) must not crash: # Schema introspection (populate_schema_tables) must not crash:
db_response = await ds.client.get("/test_reserved_table_names.json") db_response = await ds.client.get("/test_reserved_table_names.json")
@ -950,7 +953,9 @@ async def test_table_with_reserved_characters_in_name(table_name):
assert tables[table_name]["count"] == 1 assert tables[table_name]["count"] == 1
# And the table page itself must load and return the row: # And the table page itself must load and return the row:
table_response = await ds.client.get( table_response = await ds.client.get(
f"/test_reserved_table_names/{tilde_encode(table_name)}.json?_shape=array" "/test_reserved_table_names/{}.json?_shape=array".format(
tilde_encode(table_name)
)
) )
assert table_response.status_code == 200 assert table_response.status_code == 200
assert table_response.json() == [{"id": 1, "name": "one"}] assert table_response.json() == [{"id": 1, "name": "one"}]

View file

@ -1,24 +1,21 @@
import time
import pytest
from datasette.app import Datasette from datasette.app import Datasette
from datasette.events import RenameTableEvent from datasette.events import RenameTableEvent
from datasette.utils import error_body, escape_sqlite, sqlite3 from datasette.utils import error_body, escape_sqlite, sqlite3
from .utils import last_event from .utils import last_event
import pytest
import time
def assert_schema_contains(fragment, schema): def assert_schema_contains(fragment, schema):
assert ( assert fragment in schema, "Expected schema to contain {!r}, got {!r}".format(
fragment in schema fragment, schema
), f"Expected schema to contain {fragment!r}, got {schema!r}" )
def assert_schema_not_contains(fragment, schema): def assert_schema_not_contains(fragment, schema):
assert ( assert (
fragment not in schema fragment not in schema
), f"Expected schema not to contain {fragment!r}, got {schema!r}" ), "Expected schema not to contain {!r}, got {!r}".format(fragment, schema)
@pytest.fixture @pytest.fixture
@ -50,7 +47,7 @@ def write_token(ds, actor_id="root", permissions=None):
def _headers(token): def _headers(token):
return { return {
"Authorization": f"Bearer {token}", "Authorization": "Bearer {}".format(token),
"Content-Type": "application/json", "Content-Type": "application/json",
} }
@ -58,7 +55,9 @@ def _headers(token):
def _insert_and_fetch_created(conn, table, insert_sql): def _insert_and_fetch_created(conn, table, insert_sql):
cursor = conn.execute(insert_sql) cursor = conn.execute(insert_sql)
return conn.execute( return conn.execute(
f"select created, typeof(created) from {escape_sqlite(table)} where rowid = ?", "select created, typeof(created) from {} where rowid = ?".format(
escape_sqlite(table)
),
(cursor.lastrowid,), (cursor.lastrowid,),
).fetchone() ).fetchone()
@ -242,7 +241,7 @@ async def test_insert_row(ds_write, content_type):
"/data/docs/-/insert", "/data/docs/-/insert",
json={"row": {"title": "Test", "score": 1.2, "age": 5}}, json={"row": {"title": "Test", "score": 1.2, "age": 5}},
headers={ headers={
"Authorization": f"Bearer {token}", "Authorization": "Bearer {}".format(token),
"Content-Type": content_type, "Content-Type": content_type,
}, },
) )
@ -287,7 +286,11 @@ async def test_insert_row_alter(ds_write):
@pytest.mark.parametrize("return_rows", (True, False)) @pytest.mark.parametrize("return_rows", (True, False))
async def test_insert_rows(ds_write, return_rows): async def test_insert_rows(ds_write, return_rows):
token = write_token(ds_write) token = write_token(ds_write)
data = {"rows": [{"title": f"Test {i}", "score": 1.0, "age": 5} for i in range(20)]} data = {
"rows": [
{"title": "Test {}".format(i), "score": 1.0, "age": 5} for i in range(20)
]
}
if return_rows: if return_rows:
data["return"] = True data["return"] = True
response = await ds_write.client.post( response = await ds_write.client.post(
@ -311,7 +314,8 @@ async def test_insert_rows(ds_write, return_rows):
).dicts() ).dicts()
assert len(actual_rows) == 20 assert len(actual_rows) == 20
assert actual_rows == [ assert actual_rows == [
{"id": i + 1, "title": f"Test {i}", "score": 1.0, "age": 5} for i in range(20) {"id": i + 1, "title": "Test {}".format(i), "score": 1.0, "age": 5}
for i in range(20)
] ]
assert response.json()["ok"] is True assert response.json()["ok"] is True
if return_rows: if return_rows:
@ -557,13 +561,13 @@ async def test_insert_or_upsert_row_errors(
) )
if special_case == "bad_token": if special_case == "bad_token":
token += "bad" token += "bad"
kwargs = { kwargs = dict(
"json": input, json=input,
"headers": { headers={
"Authorization": f"Bearer {token}", "Authorization": "Bearer {}".format(token),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} )
if special_case != "bad_token": if special_case != "bad_token":
actor_response = ( actor_response = (
@ -618,7 +622,7 @@ async def test_upsert_permissions_per_table(ds_write, allowed):
"/data/docs/-/upsert", "/data/docs/-/upsert",
json={"rows": [{"id": 1, "title": "One"}]}, json={"rows": [{"id": 1, "title": "One"}]},
headers={ headers={
"Authorization": f"Bearer {token}", "Authorization": "Bearer {}".format(token),
}, },
) )
if allowed: if allowed:
@ -855,7 +859,9 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path):
# Should be a single row # Should be a single row
assert ( assert (
await ds_write.client.get( await ds_write.client.get(
f"/data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+{table}" "/data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(
table
)
) )
).json() == [1] ).json() == [1]
# Now delete the row # Now delete the row
@ -863,12 +869,14 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path):
# Special case for that rowid table # Special case for that rowid table
delete_path = ( delete_path = (
await ds_write.client.get( await ds_write.client.get(
f"/data/-/query.json?_shape=arrayfirst&sql=select+rowid+from+{table}" "/data/-/query.json?_shape=arrayfirst&sql=select+rowid+from+{}".format(
table
)
) )
).json()[0] ).json()[0]
delete_response = await ds_write.client.post( delete_response = await ds_write.client.post(
f"/data/{table}/{delete_path}/-/delete", "/data/{}/{}/-/delete".format(table, delete_path),
headers=_headers(write_token(ds_write)), headers=_headers(write_token(ds_write)),
) )
assert delete_response.status_code == 200 assert delete_response.status_code == 200
@ -881,7 +889,9 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path):
assert event.pks == str(delete_path).split(",") assert event.pks == str(delete_path).split(",")
assert ( assert (
await ds_write.client.get( await ds_write.client.get(
f"/data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+{table}" "/data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(
table
)
) )
).json() == [0] ).json() == [0]
@ -931,7 +941,7 @@ async def test_update_row_invalid_key(ds_write):
pk = await _insert_row(ds_write) pk = await _insert_row(ds_write)
path = f"/data/docs/{pk}/-/update" path = "/data/docs/{}/-/update".format(pk)
response = await ds_write.client.post( response = await ds_write.client.post(
path, path,
json={"update": {"title": "New title"}, "bad_key": 1}, json={"update": {"title": "New title"}, "bad_key": 1},
@ -950,7 +960,7 @@ async def test_update_row_invalid_key(ds_write):
async def test_update_row_alter(ds_write): async def test_update_row_alter(ds_write):
token = write_token(ds_write, permissions=["ur", "at"]) token = write_token(ds_write, permissions=["ur", "at"])
pk = await _insert_row(ds_write) pk = await _insert_row(ds_write)
path = f"/data/docs/{pk}/-/update" path = "/data/docs/{}/-/update".format(pk)
response = await ds_write.client.post( response = await ds_write.client.post(
path, path,
json={"update": {"title": "New title", "extra": "extra"}, "alter": True}, json={"update": {"title": "New title", "extra": "extra"}, "alter": True},
@ -1106,9 +1116,9 @@ async def test_alter_table_integer_default_expr(
assert expected_schema in data["schema"] assert expected_schema in data["schema"]
columns = await db.execute("select * from pragma_table_info('docs')") columns = await db.execute("select * from pragma_table_info('docs')")
created_column = next( created_column = [
column for column in columns.dicts() if column["name"] == "created" column for column in columns.dicts() if column["name"] == "created"
) ][0]
assert created_column["type"] == "INTEGER" assert created_column["type"] == "INTEGER"
assert expected_schema in created_column["dflt_value"] assert expected_schema in created_column["dflt_value"]
@ -1409,8 +1419,7 @@ async def test_foreign_key_targets(ds_write):
await db.execute_write("create table no_pk (name text)") await db.execute_write("create table no_pk (name text)")
try: try:
await db.execute_write("create virtual table search_docs using fts5(body)") await db.execute_write("create virtual table search_docs using fts5(body)")
except Exception: # noqa: BLE001, S110 except Exception:
# FTS5 is not available in every SQLite build
pass pass
response = await ds_write.client.get( response = await ds_write.client.get(
@ -1616,7 +1625,7 @@ async def test_update_row(ds_write, input, expected_errors, use_return):
token = write_token(ds_write) token = write_token(ds_write)
pk = await _insert_row(ds_write) pk = await _insert_row(ds_write)
path = f"/data/docs/{pk}/-/update" path = "/data/docs/{}/-/update".format(pk)
data = {"update": input} data = {"update": input}
if use_return: if use_return:
@ -1651,7 +1660,7 @@ async def test_update_row(ds_write, input, expected_errors, use_return):
# And fetch the row to check it's updated # And fetch the row to check it's updated
response = await ds_write.client.get( response = await ds_write.client.get(
f"/data/docs/{pk}.json?_shape=array", "/data/docs/{}.json?_shape=array".format(pk),
) )
assert response.status_code == 200 assert response.status_code == 200
row = response.json()[0] row = response.json()[0]
@ -2297,7 +2306,7 @@ async def test_create_table_integer_default_expr(
ds_write, default_expr, minimum_value, expected_schema ds_write, default_expr, minimum_value, expected_schema
): ):
token = write_token(ds_write) token = write_token(ds_write)
table = f"default_{default_expr}" table = "default_{}".format(default_expr)
response = await ds_write.client.post( response = await ds_write.client.post(
"/data/-/create", "/data/-/create",
json={ json={
@ -2325,7 +2334,7 @@ async def test_create_table_integer_default_expr(
row = await db.execute_write_fn( row = await db.execute_write_fn(
lambda conn: _insert_and_fetch_created( lambda conn: _insert_and_fetch_created(
conn, table, f"insert into {escape_sqlite(table)} default values" conn, table, "insert into {} default values".format(escape_sqlite(table))
) )
) )
assert row[0] > minimum_value assert row[0] > minimum_value

View file

@ -1,17 +1,14 @@
import time
import pytest
from bs4 import BeautifulSoup as Soup from bs4 import BeautifulSoup as Soup
from .utils import cookie_was_deleted, last_event
from click.testing import CliRunner from click.testing import CliRunner
from datasette.utils import baseconv
from datasette.cli import cli from datasette.cli import cli
from datasette.resources import ( from datasette.resources import (
DatabaseResource, DatabaseResource,
TableResource, TableResource,
) )
from datasette.utils import baseconv import pytest
import time
from .utils import cookie_was_deleted, last_event
@pytest.mark.asyncio @pytest.mark.asyncio
@ -207,7 +204,7 @@ def test_auth_create_token(
assert response2.status == 200 assert response2.status == 200
if errors: if errors:
for error in errors: for error in errors:
assert f'<p class="message-error">{error}</p>' in response2.text assert '<p class="message-error">{}</p>'.format(error) in response2.text
else: else:
# Check create-token event # Check create-token event
event = last_event(app_client.ds) event = last_event(app_client.ds)
@ -231,7 +228,7 @@ def test_auth_create_token(
# And test that token # And test that token
response3 = app_client.get( response3 = app_client.get(
"/-/actor.json", "/-/actor.json",
headers={"Authorization": "Bearer {}".format(f"dstok_{token}")}, headers={"Authorization": "Bearer {}".format("dstok_{}".format(token))},
) )
assert response3.status == 200 assert response3.status == 200
assert response3.json["actor"]["id"] == "test" assert response3.json["actor"]["id"] == "test"
@ -244,7 +241,7 @@ async def test_auth_create_token_not_allowed_for_tokens(ds_client):
) )
response = await ds_client.get( response = await ds_client.get(
"/-/create-token", "/-/create-token",
headers={"Authorization": f"Bearer dstok_{ds_tok}"}, headers={"Authorization": "Bearer dstok_{}".format(ds_tok)},
) )
assert response.status_code == 403 assert response.status_code == 403
@ -289,12 +286,12 @@ async def test_auth_with_dstok_token(ds_client, scenario, should_work):
elif scenario == "invalid_token": elif scenario == "invalid_token":
token = "invalid" token = "invalid"
if token: if token:
token = f"dstok_{token}" token = "dstok_{}".format(token)
if scenario == "allow_signed_tokens_off": if scenario == "allow_signed_tokens_off":
ds_client.ds._settings["allow_signed_tokens"] = False ds_client.ds._settings["allow_signed_tokens"] = False
headers = {} headers = {}
if token: if token:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = "Bearer {}".format(token)
response = await ds_client.get("/-/actor.json", headers=headers) response = await ds_client.get("/-/actor.json", headers=headers)
try: try:
if should_work: if should_work:
@ -341,7 +338,7 @@ def test_cli_create_token(app_client, expires):
assert details.keys() == expected_keys assert details.keys() == expected_keys
assert details["a"] == "test" assert details["a"] == "test"
response = app_client.get( response = app_client.get(
"/-/actor.json", headers={"Authorization": f"Bearer {token}"} "/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
) )
if expires is None or expires > 0: if expires is None or expires > 0:
expected_actor = { expected_actor = {

View file

@ -1,10 +1,8 @@
import json from datasette.views.base import View
import pytest
from datasette import Request, Response from datasette import Request, Response
from datasette.app import Datasette from datasette.app import Datasette
from datasette.views.base import View import json
import pytest
class GetView(View): class GetView(View):

View file

@ -1,28 +1,23 @@
from .fixtures import (
make_app_client,
TestClient as _TestClient,
EXPECTED_PLUGINS,
)
from datasette.app import SETTINGS
from datasette.plugins import DEFAULT_PLUGINS, pm
from datasette.cli import cli, serve
from datasette.version import __version__
from datasette.utils import tilde_encode
from datasette.utils.sqlite import sqlite3
from click.testing import CliRunner
import io import io
import json import json
import pathlib import pathlib
import pytest
import sys import sys
import textwrap import textwrap
from unittest import mock from unittest import mock
import pytest
from click.testing import CliRunner
from datasette.app import SETTINGS
from datasette.cli import cli, serve
from datasette.plugins import DEFAULT_PLUGINS, pm
from datasette.utils import tilde_encode
from datasette.utils.sqlite import sqlite3
from datasette.version import __version__
from .fixtures import (
EXPECTED_PLUGINS,
make_app_client,
)
from .fixtures import (
TestClient as _TestClient,
)
def test_inspect_cli(app_client): def test_inspect_cli(app_client):
runner = CliRunner() runner = CliRunner()
@ -465,7 +460,7 @@ def test_serve_create(tmpdir):
@pytest.mark.parametrize("argument", ("-c", "--config")) @pytest.mark.parametrize("argument", ("-c", "--config"))
@pytest.mark.parametrize("format_", ("json", "yaml")) @pytest.mark.parametrize("format_", ("json", "yaml"))
def test_serve_config(tmpdir, argument, format_): def test_serve_config(tmpdir, argument, format_):
config_path = tmpdir / f"datasette.{format_}" config_path = tmpdir / "datasette.{}".format(format_)
config_path.write_text( config_path.write_text(
( (
"settings:\n default_page_size: 5\n" "settings:\n default_page_size: 5\n"
@ -518,13 +513,13 @@ def test_weird_database_names(tmpdir, filename):
result1 = runner.invoke(cli, [db_path, "--get", "/"]) result1 = runner.invoke(cli, [db_path, "--get", "/"])
assert result1.exit_code == 0, result1.output assert result1.exit_code == 0, result1.output
filename_no_stem = filename.rsplit(".", 1)[0] filename_no_stem = filename.rsplit(".", 1)[0]
expected_link = ( expected_link = '<a href="/{}">{}</a>'.format(
f'<a href="/{tilde_encode(filename_no_stem)}">{filename_no_stem}</a>' tilde_encode(filename_no_stem), filename_no_stem
) )
assert expected_link in result1.output assert expected_link in result1.output
# Now try hitting that database page # Now try hitting that database page
result2 = runner.invoke( result2 = runner.invoke(
cli, [db_path, "--get", f"/{tilde_encode(filename_no_stem)}"] cli, [db_path, "--get", "/{}".format(tilde_encode(filename_no_stem))]
) )
assert result2.exit_code == 0, result2.output assert result2.exit_code == 0, result2.output

View file

@ -1,10 +1,8 @@
import json
import textwrap
from click.testing import CliRunner
from datasette.cli import cli from datasette.cli import cli
from datasette.plugins import pm from datasette.plugins import pm
from click.testing import CliRunner
import textwrap
import json
def test_serve_with_get(tmp_path_factory): def test_serve_with_get(tmp_path_factory):
@ -46,9 +44,9 @@ def test_serve_with_get(tmp_path_factory):
# Annoyingly that new test plugin stays resident - we need # Annoyingly that new test plugin stays resident - we need
# to manually unregister it to avoid conflict with other tests # to manually unregister it to avoid conflict with other tests
to_unregister = next( to_unregister = [
p for p in pm.get_plugins() if p.__name__ == "init_for_serve_with_get.py" p for p in pm.get_plugins() if p.__name__ == "init_for_serve_with_get.py"
) ][0]
pm.unregister(to_unregister) pm.unregister(to_unregister)

View file

@ -1,7 +1,6 @@
import socket
import httpx import httpx
import pytest import pytest
import socket
@pytest.mark.serial @pytest.mark.serial

View file

@ -1,11 +1,7 @@
import json import json
import logging import logging
import time
import markupsafe
import pytest
from bs4 import BeautifulSoup as Soup from bs4 import BeautifulSoup as Soup
from datasette.app import Datasette from datasette.app import Datasette
from datasette.column_types import ( from datasette.column_types import (
ColumnType, ColumnType,
@ -13,7 +9,11 @@ from datasette.column_types import (
) )
from datasette.hookspecs import hookimpl from datasette.hookspecs import hookimpl
from datasette.plugins import pm from datasette.plugins import pm
from datasette.utils import StartupError, error_body, sqlite3 from datasette.utils import error_body, sqlite3
from datasette.utils import StartupError
import markupsafe
import pytest
import time
@pytest.fixture @pytest.fixture
@ -104,7 +104,7 @@ def write_token(ds, actor_id="root", permissions=None):
def _headers(token): def _headers(token):
return { return {
"Authorization": f"Bearer {token}", "Authorization": "Bearer {}".format(token),
"Content-Type": "application/json", "Content-Type": "application/json",
} }

View file

@ -1,12 +1,10 @@
import json import json
import pathlib import pathlib
import pytest import pytest
from datasette.app import Datasette from datasette.app import Datasette
from datasette.utils import StartupError
from datasette.utils.sqlite import sqlite3 from datasette.utils.sqlite import sqlite3
from datasette.utils import StartupError
from .fixtures import TestClient as _TestClient from .fixtures import TestClient as _TestClient
PLUGIN = """ PLUGIN = """

View file

@ -1,9 +1,7 @@
import sqlite3
import urllib
from click.testing import CliRunner
from datasette.cli import cli from datasette.cli import cli
from click.testing import CliRunner
import urllib
import sqlite3
def test_crossdb_join(app_client_two_attached_databases_crossdb_enabled): def test_crossdb_join(app_client_two_attached_databases_crossdb_enabled):
@ -42,7 +40,7 @@ def test_crossdb_warning_if_too_many_databases(tmp_path_factory):
db_dir = tmp_path_factory.mktemp("dbs") db_dir = tmp_path_factory.mktemp("dbs")
dbs = [] dbs = []
for i in range(11): for i in range(11):
path = str(db_dir / f"db_{i}.db") path = str(db_dir / "db_{}.db".format(i))
conn = sqlite3.connect(path) conn = sqlite3.connect(path)
conn.execute("vacuum") conn.execute("vacuum")
conn.close() conn.close()

View file

@ -44,7 +44,7 @@ async def _run_middleware(scope):
await mw(scope, None, send) await mw(scope, None, send)
if inner_called: if inner_called:
return ("allowed",) return ("allowed",)
start = next(m for m in sent if m["type"] == "http.response.start") start = [m for m in sent if m["type"] == "http.response.start"][0]
return ("blocked", start["status"]) return ("blocked", start["status"])

View file

@ -1,9 +1,7 @@
import urllib.parse
import pytest
from bs4 import BeautifulSoup as Soup
from datasette.app import Datasette from datasette.app import Datasette
from bs4 import BeautifulSoup as Soup
import pytest
import urllib.parse
EXPECTED_TABLE_CSV = """id,content EXPECTED_TABLE_CSV = """id,content
1,hello 1,hello

View file

@ -1,7 +1,5 @@
import pathlib import pathlib
import pytest import pytest
from .fixtures import make_app_client from .fixtures import make_app_client
TEST_TEMPLATE_DIRS = str(pathlib.Path(__file__).parent / "test_templates") TEST_TEMPLATE_DIRS = str(pathlib.Path(__file__).parent / "test_templates")

View file

@ -1,5 +1,4 @@
import pytest import pytest
from datasette.app import Datasette from datasette.app import Datasette
from datasette.resources import DatabaseResource, TableResource from datasette.resources import DatabaseResource, TableResource

View file

@ -2,22 +2,20 @@
Tests to ensure certain things are documented. Tests to ensure certain things are documented.
""" """
import re
from pathlib import Path
import pytest
import datasette.fixtures # noqa: F401
from datasette import app, utils from datasette import app, utils
import datasette.fixtures # noqa: F401
from datasette.app import Datasette from datasette.app import Datasette
from datasette.filters import Filters from datasette.filters import Filters
from pathlib import Path
import pytest
import re
docs_path = Path(__file__).parent.parent / "docs" docs_path = Path(__file__).parent.parent / "docs"
label_re = re.compile(r"\.\. _([^\s:]+):") label_re = re.compile(r"\.\. _([^\s:]+):")
def get_headings(content, underline="-"): def get_headings(content, underline="-"):
heading_re = re.compile(rf"(\w+)(\([^)]*\))?\n\{underline}+\n") heading_re = re.compile(r"(\w+)(\([^)]*\))?\n\{}+\n".format(underline))
return {h[0] for h in heading_re.findall(content)} return {h[0] for h in heading_re.findall(content)}

View file

@ -1,10 +1,9 @@
# fmt: off # fmt: off
# -- start datasette_with_plugin_fixture -- # -- start datasette_with_plugin_fixture --
import pytest
import pytest_asyncio
from datasette import hookimpl from datasette import hookimpl
from datasette.app import Datasette from datasette.app import Datasette
import pytest
import pytest_asyncio
@pytest_asyncio.fixture @pytest_asyncio.fixture

View file

@ -17,10 +17,8 @@ present and the legacy "title" key must not be.
https://github.com/simonw/datasette/issues - 1.0 API consistency https://github.com/simonw/datasette/issues - 1.0 API consistency
""" """
import time
import pytest import pytest
import time
from datasette.app import Datasette from datasette.app import Datasette
from datasette.utils import sqlite3 from datasette.utils import sqlite3
@ -88,7 +86,7 @@ async def test_write_api_validation_error_shape(ds_error_shape):
"/data/docs/-/insert", "/data/docs/-/insert",
json={"rows": [{"nope": 1}, {"also_nope": 2}]}, json={"rows": [{"nope": 1}, {"also_nope": 2}]},
headers={ headers={
"Authorization": f"Bearer {token}", "Authorization": "Bearer {}".format(token),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
) )
@ -412,7 +410,7 @@ async def test_expired_token_returns_401(ds_error_shape):
) )
) )
response = await ds_error_shape.client.get( response = await ds_error_shape.client.get(
"/-/actor.json", headers={"Authorization": f"Bearer {token}"} "/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
) )
data = assert_canonical_error(response, 401) data = assert_canonical_error(response, 401)
assert "expired" in data["error"].lower() assert "expired" in data["error"].lower()
@ -448,7 +446,7 @@ async def test_valid_token_still_authenticates(ds_error_shape):
) )
) )
response = await ds_error_shape.client.get( response = await ds_error_shape.client.get(
"/-/actor.json", headers={"Authorization": f"Bearer {token}"} "/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["actor"]["id"] == "root" assert response.json()["actor"]["id"] == "root"
@ -479,7 +477,7 @@ async def test_token_when_signed_tokens_disabled_returns_401(tmp_path_factory):
ds.sign({"a": "root", "t": int(time.time())}, namespace="token") ds.sign({"a": "root", "t": int(time.time())}, namespace="token")
) )
response = await ds.client.get( response = await ds.client.get(
"/-/actor.json", headers={"Authorization": f"Bearer {token}"} "/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
) )
data = assert_canonical_error(response, 401) data = assert_canonical_error(response, 401)
assert "not enabled" in data["error"] assert "not enabled" in data["error"]
@ -644,7 +642,7 @@ async def test_query_list_size_rejects_non_integer(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", ("allowed", "rules")) @pytest.mark.parametrize("endpoint", ("allowed", "rules"))
async def test_debug_endpoints_use_size_and_page_parameters(ds_error_shape, endpoint): async def test_debug_endpoints_use_size_and_page_parameters(ds_error_shape, endpoint):
base = f"/-/{endpoint}.json?action=view-instance" base = "/-/{}.json?action=view-instance".format(endpoint)
ok = await ds_error_shape.client.get( ok = await ds_error_shape.client.get(
base + "&_size=1&_page=1", actor={"id": "root"} base + "&_size=1&_page=1", actor={"id": "root"}
) )

View file

@ -1,5 +1,4 @@
import asyncio import asyncio
from typing import ClassVar
import pytest import pytest
@ -8,7 +7,7 @@ from datasette.extras import Extra, ExtraRegistry, ExtraScope
class SlowValueExtra(Extra): class SlowValueExtra(Extra):
description = "Returns context['value'], optionally slowly" description = "Returns context['value'], optionally slowly"
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context): async def resolve(self, context):
if context["slow"]: if context["slow"]:
@ -18,7 +17,7 @@ class SlowValueExtra(Extra):
class DependentExtra(Extra): class DependentExtra(Extra):
description = "Depends on slow_value" description = "Depends on slow_value"
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
async def resolve(self, context, slow_value): async def resolve(self, context, slow_value):
return slow_value + 1 return slow_value + 1
@ -26,7 +25,7 @@ class DependentExtra(Extra):
class InternalOnlyExtra(Extra): class InternalOnlyExtra(Extra):
description = "Internal extra for HTML templates only" description = "Internal extra for HTML templates only"
scopes: ClassVar[set[ExtraScope]] = {ExtraScope.TABLE} scopes = {ExtraScope.TABLE}
public = False public = False
async def resolve(self, context): async def resolve(self, context):
@ -53,7 +52,7 @@ def _registered_extra_classes():
@pytest.mark.parametrize("cls", _registered_extra_classes(), ids=lambda cls: cls.key()) @pytest.mark.parametrize("cls", _registered_extra_classes(), ids=lambda cls: cls.key())
def test_registered_extras_have_descriptions(cls): def test_registered_extras_have_descriptions(cls):
# Every registered extra is part of the documented template/JSON contract # Every registered extra is part of the documented template/JSON contract
assert cls.description, f"{cls.__name__} is missing a description" assert cls.description, "{} is missing a description".format(cls.__name__)
def test_registry_is_built_once_per_scope(): def test_registry_is_built_once_per_scope():

View file

@ -1,14 +1,11 @@
import json
import pytest
from datasette.app import Datasette from datasette.app import Datasette
from datasette.database import Database from datasette.database import Database
from datasette.facets import ArrayFacet, ColumnFacet, DateFacet, Facet from datasette.facets import Facet, ColumnFacet, ArrayFacet, DateFacet
from datasette.utils import detect_json1
from datasette.utils.asgi import Request from datasette.utils.asgi import Request
from datasette.utils import detect_json1
from .fixtures import make_app_client from .fixtures import make_app_client
import json
import pytest
@pytest.mark.asyncio @pytest.mark.asyncio
@ -540,7 +537,7 @@ async def test_facet_size():
for j in range(1, 4): for j in range(1, 4):
await db.execute_write( await db.execute_write(
"insert into neighbourhoods (city, neighbourhood) values (?, ?)", "insert into neighbourhoods (city, neighbourhood) values (?, ?)",
[f"City {i}", f"Neighbourhood {j}"], ["City {}".format(i), "Neighbourhood {}".format(j)],
) )
response = await ds.client.get( response = await ds.client.get(
"/test_facet_size/neighbourhoods.json?_extra=suggested_facets" "/test_facet_size/neighbourhoods.json?_extra=suggested_facets"

View file

@ -1,7 +1,6 @@
import pytest from datasette.filters import Filters, through_filters, where_filters, search_filters
from datasette.filters import Filters, search_filters, through_filters, where_filters
from datasette.utils.asgi import Request from datasette.utils.asgi import Request
import pytest
@pytest.mark.parametrize( @pytest.mark.parametrize(

View file

@ -1,19 +1,16 @@
from bs4 import BeautifulSoup as Soup
from datasette.app import Datasette
from datasette.utils import allowed_pragmas
from .fixtures import make_app_client
from .utils import assert_footer_links, inner_html
import copy import copy
import hashlib import hashlib
import json import json
import pathlib import pathlib
import pytest
import re import re
import urllib.parse import urllib.parse
import pytest
from bs4 import BeautifulSoup as Soup
from datasette.app import Datasette
from datasette.utils import allowed_pragmas
from .fixtures import make_app_client
from .utils import assert_footer_links, inner_html
def test_homepage(app_client_two_attached_databases): def test_homepage(app_client_two_attached_databases):
response = app_client_two_attached_databases.get("/") response = app_client_two_attached_databases.get("/")
@ -145,7 +142,9 @@ def test_static_mounts_hash_cache_control():
) )
incorrect_hash = hashlib.sha256(b"incorrect").hexdigest()[:12] incorrect_hash = hashlib.sha256(b"incorrect").hexdigest()[:12]
response = client.get(f"/custom-static/test_html.py?_hash={incorrect_hash}") response = client.get(
"/custom-static/test_html.py?_hash={}".format(incorrect_hash)
)
assert response.status_code == 200 assert response.status_code == 200
assert "cache-control" not in response.headers assert "cache-control" not in response.headers
@ -220,9 +219,11 @@ async def test_disallowed_custom_sql_pragma(ds_client):
"/fixtures/-/query?sql=SELECT+*+FROM+pragma_not_on_allow_list('idx52')" "/fixtures/-/query?sql=SELECT+*+FROM+pragma_not_on_allow_list('idx52')"
) )
assert response.status_code == 400 assert response.status_code == 400
pragmas = ", ".join(f"pragma_{pragma}()" for pragma in allowed_pragmas) pragmas = ", ".join("pragma_{}()".format(pragma) for pragma in allowed_pragmas)
assert ( assert (
f"Statement contained a disallowed PRAGMA. Allowed pragma functions are {pragmas}" "Statement contained a disallowed PRAGMA. Allowed pragma functions are {}".format(
pragmas
)
in response.text in response.text
) )
@ -777,8 +778,8 @@ def test_stored_query_show_hide_metadata_option(
}, },
memory=True, memory=True,
) as client: ) as client:
expected_show_hide_fragment = ( expected_show_hide_fragment = '(<a href="{}">{}</a>)'.format(
f'(<a href="{expected_show_hide_link}">{expected_show_hide_text}</a>)' expected_show_hide_link, expected_show_hide_text
) )
response = client.get("/_memory/one" + querystring) response = client.get("/_memory/one" + querystring)
html = response.text html = response.text
@ -787,7 +788,10 @@ def test_stored_query_show_hide_metadata_option(
)[0] )[0]
assert show_hide_fragment == expected_show_hide_fragment assert show_hide_fragment == expected_show_hide_fragment
if expected_hidden: if expected_hidden:
assert f'<input type="hidden" name="{expected_hidden}" value="1">' in html assert (
'<input type="hidden" name="{}" value="1">'.format(expected_hidden)
in html
)
else: else:
assert '<input type="hidden" ' not in html assert '<input type="hidden" ' not in html
@ -1190,9 +1194,13 @@ async def test_alternate_url_json(ds_client, path, expected):
response = await ds_client.get(path) response = await ds_client.get(path)
assert response.status_code == 200 assert response.status_code == 200
link = response.headers["link"] link = response.headers["link"]
assert link == f'<{expected}>; rel="alternate"; type="application/json+datasette"' assert link == '<{}>; rel="alternate"; type="application/json+datasette"'.format(
expected
)
assert ( assert (
f'<link rel="alternate" type="application/json+datasette" href="{expected}">' '<link rel="alternate" type="application/json+datasette" href="{}">'.format(
expected
)
in response.text in response.text
) )
@ -1284,8 +1292,8 @@ async def test_database_color(ds_client):
expected_color = ds_client.ds.get_database("fixtures").color expected_color = ds_client.ds.get_database("fixtures").color
# Should be something like #9403e5 # Should be something like #9403e5
expected_fragments = ( expected_fragments = (
f"10px solid #{expected_color}", "10px solid #{}".format(expected_color),
f"border-color: #{expected_color}", "border-color: #{}".format(expected_color),
) )
assert len(expected_color) == 6 assert len(expected_color) == 6
for path in ( for path in (

View file

@ -1,6 +1,5 @@
import sqlite3
import pytest import pytest
import sqlite3
from datasette.utils import escape_sqlite from datasette.utils import escape_sqlite
from datasette.utils.internal_db import INTERNAL_DB_SCHEMA_SQL from datasette.utils.internal_db import INTERNAL_DB_SCHEMA_SQL
@ -138,7 +137,7 @@ async def test_internal_foreign_key_references(ds_client):
return { return {
row[1] row[1]
for row in conn.execute( for row in conn.execute(
f"PRAGMA table_info({escape_sqlite(table_name)})" "PRAGMA table_info({})".format(escape_sqlite(table_name))
).fetchall() ).fetchall()
} }
@ -148,7 +147,7 @@ async def test_internal_foreign_key_references(ds_client):
for _, name in sorted( for _, name in sorted(
(row[5], row[1]) (row[5], row[1])
for row in conn.execute( for row in conn.execute(
f"PRAGMA table_info({escape_sqlite(table_name)})" "PRAGMA table_info({})".format(escape_sqlite(table_name))
).fetchall() ).fetchall()
if row[5] if row[5]
) )
@ -160,7 +159,7 @@ async def test_internal_foreign_key_references(ds_client):
for table_name in table_names: for table_name in table_names:
foreign_key_rows = conn.execute( foreign_key_rows = conn.execute(
f"PRAGMA foreign_key_list({escape_sqlite(table_name)})" "PRAGMA foreign_key_list({})".format(escape_sqlite(table_name))
).fetchall() ).fetchall()
foreign_keys_by_id = {} foreign_keys_by_id = {}
for foreign_key in foreign_key_rows: for foreign_key in foreign_key_rows:
@ -170,16 +169,25 @@ async def test_internal_foreign_key_references(ds_client):
foreign_key_rows.sort(key=lambda row: row[1]) foreign_key_rows.sort(key=lambda row: row[1])
other_table = foreign_key_rows[0][2] other_table = foreign_key_rows[0][2]
other_columns = [row[4] for row in foreign_key_rows] other_columns = [row[4] for row in foreign_key_rows]
message = f'Column "{table_name}.{foreign_key_rows[0][3]}" references other table "{other_table}" which does not exist' message = 'Column "{}.{}" references other table "{}" which does not exist'.format(
table_name, foreign_key_rows[0][3], other_table
)
assert other_table in table_names, message + " (bad table)" assert other_table in table_names, message + " (bad table)"
if all(other_column is None for other_column in other_columns): if all(other_column is None for other_column in other_columns):
other_columns = primary_keys_for_table(other_table) other_columns = primary_keys_for_table(other_table)
length_message = f'Foreign key from "{table_name}" to "{other_table}" has {len(foreign_key_rows)} columns but references {len(other_columns)} columns' length_message = 'Foreign key from "{}" to "{}" has {} columns but references {} columns'.format(
table_name,
other_table,
len(foreign_key_rows),
len(other_columns),
)
assert len(other_columns) == len(foreign_key_rows), length_message assert len(other_columns) == len(foreign_key_rows), length_message
for foreign_key, other_column in zip(foreign_key_rows, other_columns): for foreign_key, other_column in zip(foreign_key_rows, other_columns):
column = foreign_key[3] column = foreign_key[3]
message = f'Column "{table_name}.{column}" references other column "{other_table}.{other_column}" which does not exist' message = 'Column "{}.{}" references other column "{}.{}" which does not exist'.format(
table_name, column, other_table, other_column
)
assert other_column in columns_by_table[other_table], ( assert other_column in columns_by_table[other_table], (
message + " (bad column)" message + " (bad column)"
) )
@ -237,10 +245,10 @@ async def test_stale_catalog_entry_database_fix(tmp_path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stale_catalog_child_entries_removed_for_missing_database(tmp_path): async def test_stale_catalog_child_entries_removed_for_missing_database(tmp_path):
import sqlite3
from datasette.app import Datasette from datasette.app import Datasette
import sqlite3
internal_db_path = str(tmp_path / "internal.db") internal_db_path = str(tmp_path / "internal.db")
alpha_db_path = str(tmp_path / "alpha.db") alpha_db_path = str(tmp_path / "alpha.db")
bravo_db_path = str(tmp_path / "bravo.db") bravo_db_path = str(tmp_path / "bravo.db")
@ -285,10 +293,10 @@ async def test_stale_catalog_child_entries_removed_for_missing_database(tmp_path
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_orphan_stale_catalog_child_entries_removed(tmp_path): async def test_orphan_stale_catalog_child_entries_removed(tmp_path):
import sqlite3
from datasette.app import Datasette from datasette.app import Datasette
import sqlite3
internal_db_path = str(tmp_path / "internal.db") internal_db_path = str(tmp_path / "internal.db")
alpha_db_path = str(tmp_path / "alpha.db") alpha_db_path = str(tmp_path / "alpha.db")

View file

@ -3,23 +3,17 @@ Tests for the datasette.database.Database class
""" """
import asyncio import asyncio
import uuid
from types import SimpleNamespace from types import SimpleNamespace
from datasette.app import Datasette
from datasette.database import Database, ExecuteWriteResult, Results, MultipleValues
from datasette.database import DatasetteClosedError
from datasette.database import _deliver_write_result
from datasette.utils.sqlite import sqlite3, supports_returning
from datasette.utils import Column
import pytest import pytest
import sqlite_utils import sqlite_utils
import time
from datasette.app import Datasette import uuid
from datasette.database import (
Database,
DatasetteClosedError,
ExecuteWriteResult,
MultipleValues,
Results,
_deliver_write_result,
)
from datasette.utils import Column
from datasette.utils.sqlite import sqlite3, supports_returning
requires_sqlite_returning = pytest.mark.skipif( requires_sqlite_returning = pytest.mark.skipif(
not supports_returning(), reason="SQLite does not support RETURNING" not supports_returning(), reason="SQLite does not support RETURNING"
@ -50,7 +44,7 @@ async def test_results_first(db):
@pytest.mark.parametrize("expected", (True, False)) @pytest.mark.parametrize("expected", (True, False))
async def test_results_bool(db, expected): async def test_results_bool(db, expected):
where = "" if expected else "where pk = 0" where = "" if expected else "where pk = 0"
results = await db.execute(f"select * from facetable {where}") results = await db.execute("select * from facetable {}".format(where))
assert bool(results) is expected assert bool(results) is expected
@ -622,7 +616,7 @@ async def test_execute_write_block_false(db):
"update roadside_attractions set name = ? where pk = ?", "update roadside_attractions set name = ? where pk = ?",
["Mystery!", 1], ["Mystery!", 1],
) )
await asyncio.sleep(0.1) time.sleep(0.1)
rows = await db.execute("select name from roadside_attractions where pk = 1") rows = await db.execute("select name from roadside_attractions where pk = 1")
assert "Mystery!" == rows.rows[0][0] assert "Mystery!" == rows.rows[0][0]
@ -640,7 +634,7 @@ async def test_execute_write_with_returning_block_false(db):
) )
assert isinstance(task_id, uuid.UUID) assert isinstance(task_id, uuid.UUID)
await asyncio.sleep(0.1) time.sleep(0.1)
assert ( assert (
await db.execute("select name from write_returning_block_false") await db.execute("select name from write_returning_block_false")
).single_value() == "Cleo" ).single_value() == "Cleo"
@ -766,10 +760,9 @@ async def test_execute_write_fn_accepts_any_single_param_name(db, param_name):
# Plugins historically relied on the fact that the callback was invoked # Plugins historically relied on the fact that the callback was invoked
# positionally, so any parameter name worked. Preserve that contract. # positionally, so any parameter name worked. Preserve that contract.
scope = {} scope = {}
# exec() is how we build a function with a parameterized argument name exec(
exec( # noqa: S102 "def write_fn({0}):\n"
f"def write_fn({param_name}):\n" " return {0}.execute('select 1 + 1').fetchone()[0]".format(param_name),
f" return {param_name}.execute('select 1 + 1').fetchone()[0]",
scope, scope,
) )
write_fn = scope["write_fn"] write_fn = scope["write_fn"]
@ -793,9 +786,7 @@ async def test_execute_write_fn_with_track_event(db):
@pytest.mark.asyncio @pytest.mark.asyncio
# func_only so the budget covers the write-thread call under test, not the @pytest.mark.timeout(1)
# one-off app_client fixture setup this test may be first to trigger
@pytest.mark.timeout(1, func_only=True)
async def test_execute_write_fn_connection_exception(tmpdir, app_client): async def test_execute_write_fn_connection_exception(tmpdir, app_client):
path = str(tmpdir / "immutable.db") path = str(tmpdir / "immutable.db")
conn = sqlite3.connect(path) conn = sqlite3.connect(path)

View file

@ -9,15 +9,13 @@ import importlib
import os import os
import sqlite3 import sqlite3
import time import time
import pytest
from itsdangerous import BadSignature
from datasette import Context from datasette import Context
from datasette.app import Database, Datasette, ResourcesSQL from datasette.app import Datasette, Database, ResourcesSQL
from datasette.database import DatasetteClosedError from datasette.database import DatasetteClosedError
from datasette.resources import DatabaseResource from datasette.resources import DatabaseResource
from datasette.utils import PrefixedUrlString from datasette.utils import PrefixedUrlString
from itsdangerous import BadSignature
import pytest
@pytest.fixture @pytest.fixture
@ -79,7 +77,9 @@ async def test_static_template_function_hashes_core_asset(tmp_path, monkeypatch)
template = ds.get_jinja_environment().from_string("{{ static('demo.js') }}") template = ds.get_jinja_environment().from_string("{{ static('demo.js') }}")
expected_hash = hashlib.sha256(b"const demo = true;").hexdigest()[:12] expected_hash = hashlib.sha256(b"const demo = true;").hexdigest()[:12]
assert await template.render_async() == f"/-/static/demo.js?_hash={expected_hash}" assert await template.render_async() == "/-/static/demo.js?_hash={}".format(
expected_hash
)
assert isinstance(ds.static("demo.js"), PrefixedUrlString) assert isinstance(ds.static("demo.js"), PrefixedUrlString)
@ -101,7 +101,7 @@ def test_static_hash_recalculated_when_cache_headers_disabled(tmp_path, monkeypa
asset_path.write_bytes(b"let a = 2;") asset_path.write_bytes(b"let a = 2;")
expected_hash = hashlib.sha256(b"let a = 2;").hexdigest()[:12] expected_hash = hashlib.sha256(b"let a = 2;").hexdigest()[:12]
assert ds.static("demo.js") == f"/-/static/demo.js?_hash={expected_hash}" assert ds.static("demo.js") == "/-/static/demo.js?_hash={}".format(expected_hash)
assert ds.static("demo.js") != first_url assert ds.static("demo.js") != first_url
@ -114,12 +114,12 @@ def test_static_hashes_mounted_static_file(tmp_path):
expected_hash = hashlib.sha256(b"body { color: black; }").hexdigest()[:12] expected_hash = hashlib.sha256(b"body { color: black; }").hexdigest()[:12]
assert ds.static("styles.css", mount="assets") == ( assert ds.static("styles.css", mount="assets") == (
f"/assets/styles.css?_hash={expected_hash}" "/assets/styles.css?_hash={}".format(expected_hash)
) )
ds._settings["base_url"] = "/prefix/" ds._settings["base_url"] = "/prefix/"
assert ds.static("styles.css", mount="assets") == ( assert ds.static("styles.css", mount="assets") == (
f"/prefix/assets/styles.css?_hash={expected_hash}" "/prefix/assets/styles.css?_hash={}".format(expected_hash)
) )
@ -144,7 +144,9 @@ def test_static_hashes_plugin_static_file(tmp_path, monkeypatch):
expected_hash = hashlib.sha256(b"console.log('plugin');").hexdigest()[:12] expected_hash = hashlib.sha256(b"console.log('plugin');").hexdigest()[:12]
assert ds.static("plugin.js", plugin="datasette_cluster_map") == ( assert ds.static("plugin.js", plugin="datasette_cluster_map") == (
f"/-/static-plugins/datasette_cluster_map/plugin.js?_hash={expected_hash}" "/-/static-plugins/datasette_cluster_map/plugin.js?_hash={}".format(
expected_hash
)
) )

Some files were not shown because too many files have changed in this diff Show more