diff --git a/datasette/app.py b/datasette/app.py
index 3793cd55..3790b340 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -693,14 +693,14 @@ class Datasette:
action_abbrs[action.abbr] = action
self.actions[action.name] = action
- # Register column types
+ # Register column types (classes, not instances)
self._column_types = {}
for hook in pm.hook.register_column_types(datasette=self):
if hook:
- for ct in hook:
- if ct.name in self._column_types:
- raise StartupError(f"Duplicate column type name: {ct.name}")
- self._column_types[ct.name] = ct
+ for ct_cls in hook:
+ if ct_cls.name in self._column_types:
+ raise StartupError(f"Duplicate column type name: {ct_cls.name}")
+ self._column_types[ct_cls.name] = ct_cls
for hook in pm.hook.prepare_jinja2_environment(
env=self._jinja_env, datasette=self
@@ -984,10 +984,10 @@ class Datasette:
db_name, table_name, col_name, col_type, config
)
- async def get_column_type(self, database: str, resource: str, column: str) -> tuple:
+ async def get_column_type(self, database: str, resource: str, column: str):
"""
- Return (column_type_name, config_dict) for a specific column,
- or (None, None) if no column type is assigned.
+ Return a ColumnType instance (with config baked in) for a specific
+ column, or None if no column type is assigned.
"""
row = await self.get_internal_database().execute(
"SELECT column_type, config FROM column_types "
@@ -996,13 +996,16 @@ class Datasette:
)
rows = row.rows
if not rows:
- return None, None
- ct, config = rows[0]
- return (ct, json.loads(config) if config else None)
+ return None
+ ct_name, config = rows[0]
+ ct_cls = self._column_types.get(ct_name)
+ if ct_cls is None:
+ return None
+ return ct_cls(config=json.loads(config) if config else None)
async def get_column_types(self, database: str, resource: str) -> dict:
"""
- Return {column_name: (column_type_name, config_dict_or_None)}
+ Return {column_name: ColumnType instance (with config)}
for all columns with assigned types on the given resource.
"""
rows = await self.get_internal_database().execute(
@@ -1010,10 +1013,13 @@ class Datasette:
"WHERE database_name = ? AND resource_name = ?",
[database, resource],
)
- return {
- row[0]: (row[1], json.loads(row[2]) if row[2] else None)
- for row in rows.rows
- }
+ result = {}
+ for row in rows.rows:
+ col_name, ct_name, config = row
+ ct_cls = self._column_types.get(ct_name)
+ if ct_cls is not None:
+ result[col_name] = ct_cls(config=json.loads(config) if config else None)
+ return result
async def set_column_type(
self,
@@ -1047,13 +1053,6 @@ class Datasette:
[database, resource, column],
)
- def get_column_type_class(self, column_type_name: str):
- """
- Return the registered ColumnType instance for a given name,
- or None if no plugin has registered that name.
- """
- return self._column_types.get(column_type_name)
-
def get_internal_database(self):
return self._internal_database
diff --git a/datasette/column_types.py b/datasette/column_types.py
index e5b54845..c4114294 100644
--- a/datasette/column_types.py
+++ b/datasette/column_types.py
@@ -8,31 +8,35 @@ class ColumnType:
Examples: "markdown", "file", "email", "url", "point", "image".
- ``description``: Human-readable label for admin UI dropdowns.
Examples: "Markdown text", "File reference", "Email address".
+
+ Instantiate with an optional ``config`` dict to bind per-column
+ configuration::
+
+ ct = MyColumnType(config={"key": "value"})
+ ct.config # {"key": "value"}
"""
name: str
description: str
- async def render_cell(
- self, value, column, table, database, datasette, request, config
- ):
+ def __init__(self, config=None):
+ self.config = config
+
+ async def render_cell(self, value, column, table, database, datasette, request):
"""
Return an HTML string to render this cell value, or None to
fall through to the default render_cell plugin hook chain.
-
- ``config`` is the parsed JSON config dict for this specific
- column assignment, or None.
"""
return None
- async def validate(self, value, config, datasette):
+ async def validate(self, value, datasette):
"""
Validate a value before it is written. Return None if valid,
or a string error message if invalid.
"""
return None
- async def transform_value(self, value, config, datasette):
+ async def transform_value(self, value, datasette):
"""
Transform a value before it appears in JSON API output.
Return the transformed value. Default: return unchanged.
diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py
index 87d9713d..b4ebfcc5 100644
--- a/datasette/default_column_types.py
+++ b/datasette/default_column_types.py
@@ -11,15 +11,13 @@ class UrlColumnType(ColumnType):
name = "url"
description = "URL"
- async def render_cell(
- self, value, column, table, database, datasette, request, config
- ):
+ async def render_cell(self, value, column, table, database, datasette, request):
if not value or not isinstance(value, str):
return None
escaped = markupsafe.escape(value.strip())
return markupsafe.Markup(f'{escaped}')
- async def validate(self, value, config, datasette):
+ async def validate(self, value, datasette):
if value is None or value == "":
return None
if not isinstance(value, str):
@@ -33,15 +31,13 @@ class EmailColumnType(ColumnType):
name = "email"
description = "Email address"
- async def render_cell(
- self, value, column, table, database, datasette, request, config
- ):
+ async def render_cell(self, value, column, table, database, datasette, request):
if not value or not isinstance(value, str):
return None
escaped = markupsafe.escape(value.strip())
return markupsafe.Markup(f'{escaped}')
- async def validate(self, value, config, datasette):
+ async def validate(self, value, datasette):
if value is None or value == "":
return None
if not isinstance(value, str):
@@ -55,9 +51,7 @@ class JsonColumnType(ColumnType):
name = "json"
description = "JSON data"
- async def render_cell(
- self, value, column, table, database, datasette, request, config
- ):
+ async def render_cell(self, value, column, table, database, datasette, request):
if value is None:
return None
try:
@@ -68,7 +62,7 @@ class JsonColumnType(ColumnType):
except (json.JSONDecodeError, TypeError):
return None
- async def validate(self, value, config, datasette):
+ async def validate(self, value, datasette):
if value is None or value == "":
return None
if isinstance(value, str):
@@ -81,8 +75,4 @@ class JsonColumnType(ColumnType):
@hookimpl
def register_column_types(datasette):
- return [
- UrlColumnType(),
- EmailColumnType(),
- JsonColumnType(),
- ]
+ return [UrlColumnType, EmailColumnType, JsonColumnType]
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index 86cd529e..f7bb6ab6 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -65,7 +65,6 @@ def render_cell(
datasette,
request,
column_type,
- column_type_config,
):
"""Customize rendering of HTML table cell values"""
diff --git a/datasette/views/database.py b/datasette/views/database.py
index 29533215..916cdbc1 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -1206,7 +1206,6 @@ async def display_rows(datasette, database, request, rows, columns):
datasette=datasette,
request=request,
column_type=None,
- column_type_config=None,
):
candidate = await await_me_maybe(candidate)
if candidate is not None:
diff --git a/datasette/views/row.py b/datasette/views/row.py
index d1713d4d..4eacfe49 100644
--- a/datasette/views/row.py
+++ b/datasette/views/row.py
@@ -184,25 +184,20 @@ class RowView(DataView):
for row in rows:
rendered_row = {}
for value, column in zip(row, columns):
- ct_info = ct_map.get(column)
- ct_name = ct_info[0] if ct_info else None
- ct_config = ct_info[1] if ct_info else None
+ ct = ct_map.get(column)
plugin_display_value = None
# Try column type render_cell first
- if ct_name:
- ct_class = self.ds.get_column_type_class(ct_name)
- if ct_class:
- candidate = await ct_class.render_cell(
- value=value,
- column=column,
- table=table,
- database=database,
- datasette=self.ds,
- request=request,
- config=ct_config,
- )
- if candidate is not None:
- plugin_display_value = candidate
+ if ct:
+ candidate = await ct.render_cell(
+ value=value,
+ column=column,
+ table=table,
+ database=database,
+ datasette=self.ds,
+ request=request,
+ )
+ if candidate is not None:
+ plugin_display_value = candidate
if plugin_display_value is None:
for candidate in pm.hook.render_cell(
row=row,
@@ -213,8 +208,7 @@ class RowView(DataView):
database=database,
datasette=self.ds,
request=request,
- column_type=ct_name,
- column_type_config=ct_config,
+ column_type=ct,
):
candidate = await await_me_maybe(candidate)
if candidate is not None:
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 30beb81f..035abb1b 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -141,13 +141,10 @@ async def _validate_column_types(datasette, database_name, table_name, rows):
return []
errors = []
for row in rows:
- for col_name, (ct_name, ct_config) in ct_map.items():
+ for col_name, ct in ct_map.items():
if col_name not in row:
continue
- ct_class = datasette.get_column_type_class(ct_name)
- if ct_class is None:
- continue
- error = await ct_class.validate(row[col_name], ct_config, datasette)
+ error = await ct.validate(row[col_name], datasette)
if error:
errors.append(f"{col_name}: {error}")
return errors
@@ -211,10 +208,10 @@ async def display_columns_and_rows(
"column_type": None,
"column_type_config": None,
}
- ct_info = column_types_map.get(r[0])
- if ct_info:
- col_dict["column_type"] = ct_info[0]
- col_dict["column_type_config"] = ct_info[1]
+ ct = column_types_map.get(r[0])
+ if ct:
+ col_dict["column_type"] = ct.name
+ col_dict["column_type_config"] = ct.config
columns.append(col_dict)
column_to_foreign_key_table = {
@@ -257,22 +254,18 @@ async def display_columns_and_rows(
# First try column type render_cell, then plugins
# pylint: disable=no-member
plugin_display_value = None
- ct_name = column_dict.get("column_type")
- ct_config = column_dict.get("column_type_config")
- if ct_name:
- ct_class = datasette.get_column_type_class(ct_name)
- if ct_class:
- candidate = await ct_class.render_cell(
- value=value,
- column=column,
- table=table_name,
- database=database_name,
- datasette=datasette,
- request=request,
- config=ct_config,
- )
- if candidate is not None:
- plugin_display_value = candidate
+ ct = column_types_map.get(column)
+ if ct:
+ candidate = await ct.render_cell(
+ value=value,
+ column=column,
+ table=table_name,
+ database=database_name,
+ datasette=datasette,
+ request=request,
+ )
+ if candidate is not None:
+ plugin_display_value = candidate
if plugin_display_value is None:
for candidate in pm.hook.render_cell(
row=row,
@@ -283,8 +276,7 @@ async def display_columns_and_rows(
database=database_name,
datasette=datasette,
request=request,
- column_type=ct_name,
- column_type_config=ct_config,
+ column_type=ct,
):
candidate = await await_me_maybe(candidate)
if candidate is not None:
@@ -1559,25 +1551,20 @@ async def table_view_data(
for row in rows:
rendered_row = {}
for value, column in zip(row, col_names):
- ct_info = ct_map.get(column)
- ct_name = ct_info[0] if ct_info else None
- ct_config = ct_info[1] if ct_info else None
+ ct = ct_map.get(column)
plugin_display_value = None
# Try column type render_cell first
- if ct_name:
- ct_class = datasette.get_column_type_class(ct_name)
- if ct_class:
- candidate = await ct_class.render_cell(
- value=value,
- column=column,
- table=table_name,
- database=database_name,
- datasette=datasette,
- request=request,
- config=ct_config,
- )
- if candidate is not None:
- plugin_display_value = candidate
+ if ct:
+ candidate = await ct.render_cell(
+ value=value,
+ column=column,
+ table=table_name,
+ database=database_name,
+ datasette=datasette,
+ request=request,
+ )
+ if candidate is not None:
+ plugin_display_value = candidate
if plugin_display_value is None:
for candidate in pm.hook.render_cell(
row=row,
@@ -1588,8 +1575,7 @@ async def table_view_data(
database=database_name,
datasette=datasette,
request=request,
- column_type=ct_name,
- column_type_config=ct_config,
+ column_type=ct,
):
candidate = await await_me_maybe(candidate)
if candidate is not None:
@@ -1612,10 +1598,10 @@ async def table_view_data(
ct_map = await datasette.get_column_types(database_name, table_name)
return {
col_name: {
- "type": ct_name,
- "config": ct_config,
+ "type": ct.name,
+ "config": ct.config,
}
- for col_name, (ct_name, ct_config) in ct_map.items()
+ for col_name, ct in ct_map.items()
}
async def extra_metadata():
@@ -1866,13 +1852,11 @@ async def table_view_data(
transformed_rows = []
for r in raw_sqlite_rows:
row_dict = dict(r)
- for col_name, (ct_name, ct_config) in ct_map.items():
+ for col_name, ct in ct_map.items():
if col_name in row_dict:
- ct_class = datasette.get_column_type_class(ct_name)
- if ct_class:
- row_dict[col_name] = await ct_class.transform_value(
- row_dict[col_name], ct_config, datasette
- )
+ row_dict[col_name] = await ct.transform_value(
+ row_dict[col_name], datasette
+ )
transformed_rows.append(row_dict)
data["rows"] = transformed_rows
diff --git a/docs/internals.rst b/docs/internals.rst
index e9c6454e..5adb4cac 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -922,13 +922,16 @@ await .get_column_type(database, resource, column)
``column`` - string
The name of the column.
-Returns a ``(column_type_name, config)`` tuple for the specified column. ``column_type_name`` is a string like ``"email"`` or ``"url"``, and ``config`` is a dict or ``None``. If no column type is assigned, returns ``(None, None)``.
+Returns a :ref:`ColumnType ` instance with ``.config`` populated for the specified column, or ``None`` if no column type is assigned.
.. code-block:: python
- ct_name, config = await datasette.get_column_type(
+ ct = await datasette.get_column_type(
"mydb", "mytable", "email_col"
)
+ if ct:
+ print(ct.name) # "email"
+ print(ct.config) # None or {...}
.. _datasette_get_column_types:
@@ -940,12 +943,13 @@ await .get_column_types(database, resource)
``resource`` - string
The name of the table or view.
-Returns a dictionary mapping column names to ``(column_type_name, config)`` tuples for all columns that have assigned types on the given resource.
+Returns a dictionary mapping column names to :ref:`ColumnType ` instances (with ``.config`` populated) for all columns that have assigned types on the given resource.
.. code-block:: python
ct_map = await datasette.get_column_types("mydb", "mytable")
- # {"email_col": ("email", None), "site": ("url", None)}
+ for col_name, ct in ct_map.items():
+ print(col_name, ct.name, ct.config)
.. _datasette_set_column_type:
@@ -995,22 +999,6 @@ Removes the column type assignment for the specified column.
"mydb", "mytable", "location"
)
-.. _datasette_get_column_type_class:
-
-.get_column_type_class(column_type_name)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-``column_type_name`` - string
- The name of the column type, e.g. ``"email"``.
-
-Returns the registered ``ColumnType`` instance for the given name, or ``None`` if no plugin has registered a column type with that name. This is a synchronous method.
-
-.. code-block:: python
-
- ct = datasette.get_column_type_class("email")
- if ct:
- print(ct.description) # "Email address"
-
.. _datasette_add_database:
.add_database(db, name=None, route=None)
@@ -2049,6 +2037,14 @@ The internal database schema is as follows:
value text,
unique(database_name, resource_name, column_name, key)
);
+ CREATE TABLE column_types (
+ database_name TEXT NOT NULL,
+ resource_name TEXT NOT NULL,
+ column_name TEXT NOT NULL,
+ column_type TEXT NOT NULL,
+ config TEXT,
+ PRIMARY KEY (database_name, resource_name, column_name)
+ );
.. [[[end]]]
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index bab70edf..916f3449 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -474,8 +474,8 @@ Examples: `datasette-publish-fly ` assigned to this column, or ``None`` if no column type is assigned.
-
-``column_type_config`` - dict or None
- The configuration dict for the assigned column type, or ``None``.
+``column_type`` - :ref:`ColumnType ` instance or None
+ The :ref:`ColumnType ` instance assigned to this column (with ``.config`` populated), or ``None`` if no column type is assigned. You can access ``column_type.name``, ``column_type.config``, etc.
If a column has a :ref:`column type ` assigned and that column type's ``render_cell`` method returns a non-``None`` value, it will take priority over this plugin hook.
@@ -1002,7 +999,7 @@ The permission system then uses this query along with rules from plugins to dete
register_column_types(datasette)
--------------------------------
-Return a list of :ref:`ColumnType ` instances to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
+Return a list of :ref:`ColumnType ` **classes** (not instances) to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
.. code-block:: python
@@ -1023,7 +1020,6 @@ Return a list of :ref:`ColumnType ` instances to register custom c
database,
datasette,
request,
- config,
):
if value:
return markupsafe.Markup(
@@ -1032,14 +1028,12 @@ Return a list of :ref:`ColumnType ` instances to register custom c
).format(color=markupsafe.escape(value))
return None
- async def validate(self, value, config, datasette):
+ async def validate(self, value, datasette):
if value and not value.startswith("#"):
return "Color must start with #"
return None
- async def transform_value(
- self, value, config, datasette
- ):
+ async def transform_value(self, value, datasette):
# Normalize to uppercase
if isinstance(value, str):
return value.upper()
@@ -1048,7 +1042,7 @@ Return a list of :ref:`ColumnType ` instances to register custom c
@hookimpl
def register_column_types(datasette):
- return [ColorColumnType()]
+ return [ColorColumnType]
Each ``ColumnType`` subclass must define the following class attributes:
@@ -1060,16 +1054,16 @@ Each ``ColumnType`` subclass must define the following class attributes:
And the following methods, all optional:
-``render_cell(self, value, column, table, database, datasette, request, config)``
+``render_cell(self, value, column, table, database, datasette, request)``
Return an HTML string to render this cell value, or ``None`` to fall through to the default ``render_cell`` plugin hook chain. When a column type provides rendering, it takes priority over the ``render_cell`` plugin hook.
-``validate(self, value, config, datasette)``
+``validate(self, value, datasette)``
Validate a value before it is written via the insert, update, or upsert API endpoints. Return ``None`` if valid, or a string error message if invalid. Null values and empty strings skip validation.
-``transform_value(self, value, config, datasette)``
+``transform_value(self, value, datasette)``
Transform a value before it appears in JSON API output. Return the transformed value. The default implementation returns the value unchanged.
-The ``config`` argument passed to these methods is the parsed JSON config dict for the specific column assignment, or ``None`` if no config was provided.
+Per-column configuration is available via ``self.config`` in all methods. When a column type is looked up for a specific column (via :ref:`get_column_type ` or :ref:`get_column_types `), the returned instance has ``config`` set to the parsed JSON config dict for that column assignment, or ``None`` if no config was provided.
Column types are assigned to columns via the ``column_types`` key in :ref:`table configuration `:
diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 8315d603..0100a079 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -84,47 +84,62 @@ async def test_column_types_table_created(ds_ct):
async def test_config_loaded_into_internal_db(ds_ct):
await ds_ct.invoke_startup()
ct_map = await ds_ct.get_column_types("data", "posts")
- assert "body" in ct_map
- assert ct_map["body"] == ("markdown", None)
- assert ct_map["author_email"] == ("email", None)
- assert ct_map["website"] == ("url", None)
- assert ct_map["metadata"] == ("json", None)
+ # "markdown" is not a registered type, so it won't appear
+ assert "body" not in ct_map
+ assert ct_map["author_email"].name == "email"
+ assert ct_map["author_email"].config is None
+ assert ct_map["website"].name == "url"
+ assert ct_map["metadata"].name == "json"
@pytest.mark.asyncio
async def test_config_with_type_and_config(tmp_path_factory):
- db_directory = tmp_path_factory.mktemp("dbs")
- db_path = str(db_directory / "data.db")
- db = sqlite3.connect(str(db_path))
- db.execute("vacuum")
- db.execute("create table geo (id integer primary key, location text)")
- ds = Datasette(
- [db_path],
- config={
- "databases": {
- "data": {
- "tables": {
- "geo": {
- "column_types": {
- "location": {
- "type": "point",
- "config": {"srid": 4326},
+ class PointColumnType(ColumnType):
+ name = "point"
+ description = "Geographic point"
+
+ class _Plugin:
+ @hookimpl
+ def register_column_types(self, datasette):
+ return [PointColumnType]
+
+ plugin = _Plugin()
+ pm.register(plugin, name="test_point_ct")
+ try:
+ db_directory = tmp_path_factory.mktemp("dbs")
+ db_path = str(db_directory / "data.db")
+ db = sqlite3.connect(str(db_path))
+ db.execute("vacuum")
+ db.execute("create table geo (id integer primary key, location text)")
+ ds = Datasette(
+ [db_path],
+ config={
+ "databases": {
+ "data": {
+ "tables": {
+ "geo": {
+ "column_types": {
+ "location": {
+ "type": "point",
+ "config": {"srid": 4326},
+ }
}
}
}
}
}
- }
- },
- )
- await ds.invoke_startup()
- ct, config = await ds.get_column_type("data", "geo", "location")
- assert ct == "point"
- assert config == {"srid": 4326}
- db.close()
- for database in ds.databases.values():
- if not database.is_memory:
- database.close()
+ },
+ )
+ await ds.invoke_startup()
+ ct = await ds.get_column_type("data", "geo", "location")
+ assert ct.name == "point"
+ assert ct.config == {"srid": 4326}
+ db.close()
+ for database in ds.databases.values():
+ if not database.is_memory:
+ database.close()
+ finally:
+ pm.unregister(plugin, name="test_point_ct")
# --- Datasette API methods ---
@@ -133,39 +148,39 @@ async def test_config_with_type_and_config(tmp_path_factory):
@pytest.mark.asyncio
async def test_get_column_type(ds_ct):
await ds_ct.invoke_startup()
- ct, config = await ds_ct.get_column_type("data", "posts", "author_email")
- assert ct == "email"
- assert config is None
+ ct = await ds_ct.get_column_type("data", "posts", "author_email")
+ assert isinstance(ct, ColumnType)
+ assert ct.name == "email"
+ assert ct.config is None
@pytest.mark.asyncio
async def test_get_column_type_missing(ds_ct):
await ds_ct.invoke_startup()
- ct, config = await ds_ct.get_column_type("data", "posts", "title")
+ ct = await ds_ct.get_column_type("data", "posts", "title")
assert ct is None
- assert config is None
@pytest.mark.asyncio
async def test_set_and_remove_column_type(ds_ct):
await ds_ct.invoke_startup()
- await ds_ct.set_column_type("data", "posts", "title", "markdown")
- ct, config = await ds_ct.get_column_type("data", "posts", "title")
- assert ct == "markdown"
- assert config is None
+ await ds_ct.set_column_type("data", "posts", "title", "email")
+ ct = await ds_ct.get_column_type("data", "posts", "title")
+ assert ct.name == "email"
+ assert ct.config is None
await ds_ct.remove_column_type("data", "posts", "title")
- ct, config = await ds_ct.get_column_type("data", "posts", "title")
+ ct = await ds_ct.get_column_type("data", "posts", "title")
assert ct is None
@pytest.mark.asyncio
async def test_set_column_type_with_config(ds_ct):
await ds_ct.invoke_startup()
- await ds_ct.set_column_type("data", "posts", "title", "file", {"accept": "image/*"})
- ct, config = await ds_ct.get_column_type("data", "posts", "title")
- assert ct == "file"
- assert config == {"accept": "image/*"}
+ await ds_ct.set_column_type("data", "posts", "title", "url", {"max_length": 200})
+ ct = await ds_ct.get_column_type("data", "posts", "title")
+ assert ct.name == "url"
+ assert ct.config == {"max_length": 200}
# --- Plugin registration ---
@@ -173,22 +188,23 @@ async def test_set_column_type_with_config(ds_ct):
@pytest.mark.asyncio
async def test_builtin_column_types_registered(ds_ct):
+ """register_column_types returns classes; _column_types stores them by name."""
await ds_ct.invoke_startup()
- assert ds_ct.get_column_type_class("url") is not None
- assert ds_ct.get_column_type_class("email") is not None
- assert ds_ct.get_column_type_class("json") is not None
- assert ds_ct.get_column_type_class("nonexistent") is None
+ assert "url" in ds_ct._column_types
+ assert "email" in ds_ct._column_types
+ assert "json" in ds_ct._column_types
+ assert "nonexistent" not in ds_ct._column_types
@pytest.mark.asyncio
async def test_column_type_class_attributes(ds_ct):
await ds_ct.invoke_startup()
- url_type = ds_ct.get_column_type_class("url")
- assert url_type.name == "url"
- assert url_type.description == "URL"
- email_type = ds_ct.get_column_type_class("email")
- assert email_type.name == "email"
- assert email_type.description == "Email address"
+ url_cls = ds_ct._column_types["url"]
+ assert url_cls.name == "url"
+ assert url_cls.description == "URL"
+ email_cls = ds_ct._column_types["email"]
+ assert email_cls.name == "email"
+ assert email_cls.description == "Email address"
# --- JSON API ---
@@ -201,9 +217,11 @@ async def test_column_types_extra(ds_ct):
assert response.status_code == 200
data = response.json()
assert "column_types" in data
- assert data["column_types"]["body"] == {"type": "markdown", "config": None}
assert data["column_types"]["author_email"] == {"type": "email", "config": None}
assert data["column_types"]["website"] == {"type": "url", "config": None}
+ assert data["column_types"]["metadata"] == {"type": "json", "config": None}
+ # "markdown" is not a registered type, so body should not appear
+ assert "body" not in data["column_types"]
# title has no column type, should not appear
assert "title" not in data["column_types"]
@@ -357,9 +375,21 @@ async def test_column_type_base_defaults():
description = "Test type"
ct = TestType()
- assert await ct.render_cell("val", "col", "tbl", "db", None, None, None) is None
- assert await ct.validate("val", None, None) is None
- assert await ct.transform_value("val", None, None) == "val"
+ assert ct.config is None
+ assert await ct.render_cell("val", "col", "tbl", "db", None, None) is None
+ assert await ct.validate("val", None) is None
+ assert await ct.transform_value("val", None) == "val"
+
+
+@pytest.mark.asyncio
+async def test_column_type_with_config():
+ class TestType(ColumnType):
+ name = "test"
+ description = "Test type"
+
+ ct = TestType(config={"key": "value"})
+ assert ct.config == {"key": "value"}
+ assert ct.name == "test"
# --- render_cell extra with column types ---
@@ -385,15 +415,13 @@ async def test_duplicate_column_type_name_raises_error():
name = "url"
description = "Duplicate URL"
- async def render_cell(
- self, value, column, table, database, datasette, request, config
- ):
+ async def render_cell(self, value, column, table, database, datasette, request):
return None
class _Plugin:
@hookimpl
def register_column_types(self, datasette):
- return [DuplicateUrlType()]
+ return [DuplicateUrlType]
plugin = _Plugin()
pm.register(plugin, name="test_duplicate_ct")
@@ -430,7 +458,7 @@ async def test_transform_value_in_json_output(tmp_path_factory):
name = "upper"
description = "Uppercase"
- async def transform_value(self, value, config, datasette):
+ async def transform_value(self, value, datasette):
if isinstance(value, str):
return value.upper()
return value
@@ -438,7 +466,7 @@ async def test_transform_value_in_json_output(tmp_path_factory):
class _Plugin:
@hookimpl
def register_column_types(self, datasette):
- return [UpperColumnType()]
+ return [UpperColumnType]
plugin = _Plugin()
pm.register(plugin, name="test_transform_ct")
@@ -482,9 +510,7 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
name = "priority_test"
description = "Priority test"
- async def render_cell(
- self, value, column, table, database, datasette, request, config
- ):
+ async def render_cell(self, value, column, table, database, datasette, request):
if value is not None:
return markupsafe.Markup(
f"COLUMN_TYPE:{markupsafe.escape(value)}"
@@ -494,7 +520,7 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
class _ColumnTypePlugin:
@hookimpl
def register_column_types(self, datasette):
- return [PriorityColumnType()]
+ return [PriorityColumnType]
class _RenderCellPlugin:
@hookimpl
@@ -509,7 +535,6 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
datasette,
request,
column_type,
- column_type_config,
):
if column == "name":
return markupsafe.Markup(f"PLUGIN:{markupsafe.escape(value)}")
@@ -663,18 +688,18 @@ async def test_config_overwrites_on_restart(tmp_path_factory):
},
)
await ds.invoke_startup()
- ct, _ = await ds.get_column_type("data", "t", "col")
- assert ct == "email"
+ ct = await ds.get_column_type("data", "t", "col")
+ assert ct.name == "email"
# Manually change the column type in the internal DB
await ds.set_column_type("data", "t", "col", "url")
- ct, _ = await ds.get_column_type("data", "t", "col")
- assert ct == "url"
+ ct = await ds.get_column_type("data", "t", "col")
+ assert ct.name == "url"
# Re-apply config (simulating what happens on restart)
await ds._apply_column_types_config()
- ct, _ = await ds.get_column_type("data", "t", "col")
- assert ct == "email" # Config wins
+ ct = await ds.get_column_type("data", "t", "col")
+ assert ct.name == "email" # Config wins
db.close()
for database in ds.databases.values():
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index b3014275..47d727f2 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -1955,7 +1955,7 @@ async def test_hook_register_column_types():
ds = Datasette()
await ds.invoke_startup()
# Built-in column types should be registered
- assert ds.get_column_type_class("url") is not None
- assert ds.get_column_type_class("email") is not None
- assert ds.get_column_type_class("json") is not None
- assert ds.get_column_type_class("nonexistent") is None
+ assert "url" in ds._column_types
+ assert "email" in ds._column_types
+ assert "json" in ds._column_types
+ assert "nonexistent" not in ds._column_types