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)