From 34d9a3bf33e3c4ff610f7d3a633f63c3d7272cc9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 23 Jun 2026 12:11:26 -0700 Subject: [PATCH] Use dataclasses for database table context --- datasette/app.py | 17 +++++++++- datasette/views/__init__.py | 17 +++++++++- datasette/views/database.py | 57 +++++++++++++++++++++------------- docs/contributing.rst | 13 ++++++++ docs/template_context.rst | 8 ++--- tests/test_template_context.py | 10 ++++++ 6 files changed, 95 insertions(+), 27 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 90de60a9..57f893fe 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -331,6 +331,15 @@ def _to_string(value): return json.dumps(value, default=str) +def _template_context_json_default(value): + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return { + field.name: getattr(value, field.name) + for field in dataclasses.fields(value) + } + return repr(value) + + @pass_context def _legacy_template_csrftoken(context): request = context.get("request") @@ -2390,7 +2399,13 @@ class Datasette: } if request and request.args.get("_context") and self.setting("template_debug"): return "
{}
".format( - escape(json.dumps(template_context, default=repr, indent=4)) + escape( + json.dumps( + template_context, + default=_template_context_json_default, + indent=4, + ) + ) ) return await template.render_async(template_context) diff --git a/datasette/views/__init__.py b/datasette/views/__init__.py index 6fba1c90..ed7e175f 100644 --- a/datasette/views/__init__.py +++ b/datasette/views/__init__.py @@ -1,5 +1,7 @@ from dataclasses import dataclass import dataclasses +import types +import typing @dataclass(frozen=True) @@ -10,6 +12,19 @@ class ContextField: from_extra: bool = False +def _type_name(type_): + if type_ is type(None): + return "None" + origin = typing.get_origin(type_) + args = typing.get_args(type_) + if origin in (typing.Union, types.UnionType): + return " | ".join(_type_name(arg) for arg in args) + if origin is not None: + name = getattr(origin, "__name__", str(origin).removeprefix("typing.")) + return "{}[{}]".format(name, ", ".join(_type_name(arg) for arg in args)) + return getattr(type_, "__name__", str(type_).removeprefix("typing.")) + + def from_extra(): """ Declare a Context dataclass field whose value comes from a registered @@ -42,7 +57,7 @@ class Context: documented.append( ContextField( name=f.name, - type_name=getattr(f.type, "__name__", str(f.type)), + type_name=_type_name(f.type), help=help_text, from_extra=is_from_extra, ) diff --git a/datasette/views/database.py b/datasette/views/database.py index 0b4ca647..fd985b77 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from urllib.parse import parse_qsl, urlencode import asyncio import hashlib @@ -45,6 +45,21 @@ from .table_create_alter import _create_table_ui_context from . import Context +@dataclass +class DatabaseTable: + "Summary of a table or view shown on database and query pages." + + name: str + columns: list[str] + primary_keys: list[str] + count: int | None + count_truncated: bool + hidden: bool + fts_table: str | None + foreign_keys: dict[str, list[dict[str, str]]] + private: bool + + class DatabaseView(View): async def get(self, request, datasette): format_ = request.url_vars.get("format") or "html" @@ -169,8 +184,8 @@ class DatabaseView(View): "private": private, "path": datasette.urls.database(database), "size": db.size, - "tables": tables, - "hidden_count": len([t for t in tables if t["hidden"]]), + "tables": [asdict(table) for table in tables], + "hidden_count": len([table for table in tables if table.hidden]), "views": sql_views, "queries": [stored_query_to_dict(query) for query in stored_queries], "queries_more": queries_more, @@ -211,7 +226,7 @@ class DatabaseView(View): path=datasette.urls.database(database), size=db.size, tables=tables, - hidden_count=len([t for t in tables if t["hidden"]]), + hidden_count=len([table for table in tables if table.hidden]), views=sql_views, queries=stored_queries, queries_more=queries_more, @@ -266,9 +281,9 @@ class DatabaseContext(Context): ) path: str = field(metadata={"help": "The URL path to this database"}) size: int = field(metadata={"help": "The size of the database in bytes"}) - tables: list = field( + tables: list[DatabaseTable] = field( metadata={ - "help": "List of table dictionaries in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` keys. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total." + "help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total." } ) hidden_count: int = field(metadata={"help": "Count of hidden tables"}) @@ -391,9 +406,9 @@ class QueryContext(Context): save_query_url: str = field( metadata={"help": "URL to save the current arbitrary SQL as a query"} ) - tables: list = field( + tables: list[DatabaseTable] = field( metadata={ - "help": "List of table dictionaries in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` keys. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total." + "help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total." } ) named_parameter_values: dict = field( @@ -456,7 +471,7 @@ class QueryContext(Context): ) -async def get_tables(datasette, request, db, allowed_dict): +async def get_tables(datasette, request, db, allowed_dict) -> list[DatabaseTable]: """ Get list of tables with metadata for the database view. @@ -477,21 +492,21 @@ async def get_tables(datasette, request, db, allowed_dict): table_columns = await db.table_columns(table) tables.append( - { - "name": table, - "columns": table_columns, - "primary_keys": await db.primary_keys(table), - "count": table_counts[table], - "count_truncated": _table_count_truncated( + DatabaseTable( + name=table, + columns=table_columns, + primary_keys=await db.primary_keys(table), + count=table_counts[table], + count_truncated=_table_count_truncated( datasette, db, table, table_counts[table] ), - "hidden": table in hidden_table_names, - "fts_table": await db.fts_table(table), - "foreign_keys": all_foreign_keys[table], - "private": allowed_dict[table].private, - } + hidden=table in hidden_table_names, + fts_table=await db.fts_table(table), + foreign_keys=all_foreign_keys[table], + private=allowed_dict[table].private, + ) ) - tables.sort(key=lambda t: (t["hidden"], t["name"])) + tables.sort(key=lambda table: (table.hidden, table.name)) return tables diff --git a/docs/contributing.rst b/docs/contributing.rst index 3b45834c..142c2f41 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -309,6 +309,19 @@ To update these pages, run the following command:: uv run cog -r docs/*.rst +.. _contributing_template_contexts: + +Documented template contexts +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Datasette's documented template contexts are part of the public API for custom templates. They are defined as dataclasses next to the view code that renders them, for example ``DatabaseContext`` and ``QueryContext`` in ``datasette/views/database.py``. + +Every documented context class inherits from ``datasette.views.Context``. Fields that are added directly by view code should be declared as dataclass fields with ``help`` metadata, which is used to generate :ref:`template_context`. Fields resolved through the page extras system should use ``from_extra()`` so their documentation comes from the matching ``Extra`` class. + +Use ``documented_template`` on each context class to record the canonical template named in the generated documentation. This should be a string such as ``"database.html"``. Runtime template selection still happens in the view code, since most pages consider more specific template names before falling back to the canonical one. + +When a context field contains repeated structured data, prefer a small nested dataclass over an anonymous dictionary. For example, a field containing table summaries should be annotated as ``list[DatabaseTable]`` where ``DatabaseTable`` is a dataclass describing the keys and value types. This keeps the Python contract and generated documentation clear. JSON responses and ``?_context=1`` debug output will convert nested dataclasses back to JSON objects at the response boundary. + .. _contributing_continuous_deployment: Continuously deployed demo instances diff --git a/docs/template_context.rst b/docs/template_context.rst index 12f656d0..311de8bb 100644 --- a/docs/template_context.rst +++ b/docs/template_context.rst @@ -145,8 +145,8 @@ The page listing the tables, views and queries in a database, e.g. /fixtures. Re ``table_columns`` - ``dict`` Dictionary mapping table names to lists of column names, used to power SQL autocomplete. -``tables`` - ``list`` - List of table dictionaries in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` keys. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total. +``tables`` - ``list[DatabaseTable]`` + List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total. ``top_database`` - ``callable`` Async callable that renders the ``top_database`` plugin slot for this database and returns HTML. @@ -234,8 +234,8 @@ The page for arbitrary SQL queries (/database/-/query?sql=...) and stored querie ``table_columns`` - ``dict`` Dictionary mapping table names to lists of column names, used to power SQL autocomplete. -``tables`` - ``list`` - List of table dictionaries in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` keys. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total. +``tables`` - ``list[DatabaseTable]`` + List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total. ``top_query`` - ``callable`` Async callable that renders the ``top_query`` plugin slot for this query and returns HTML. diff --git a/tests/test_template_context.py b/tests/test_template_context.py index 4c452774..f1919891 100644 --- a/tests/test_template_context.py +++ b/tests/test_template_context.py @@ -18,16 +18,22 @@ from datasette.views import Context def test_documented_fields(): + @dataclass + class DemoNested: + name: str + @dataclass class DemoContext(Context): name: str = field(metadata={"help": "The name"}) _internal: str = field() count: int = field(metadata={"help": "How many there are"}) + items: list[DemoNested] = field(metadata={"help": "Nested items"}) fields = DemoContext.documented_fields() assert [(f.name, f.type_name, f.help) for f in fields] == [ ("name", "str", "The name"), ("count", "int", "How many there are"), + ("items", "list[DemoNested]", "Nested items"), ] @@ -230,3 +236,7 @@ def test_template_context_docs_cover_every_documented_key(): assert "``{}``".format(context_field.name) in docs, "{} ({} page)".format( context_field.name, page_name ) + assert ( + "``{}`` - ``{}``".format(context_field.name, context_field.type_name) + in docs + ), "{} type ({} page)".format(context_field.name, page_name)