From dd9b83301cf2cd7665c987aa170ef0e1849005e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 05:18:14 +0000 Subject: [PATCH] Refactor ColumnType: register classes, return instances with config - register_column_types() now returns classes instead of instances - ColumnType.__init__ takes optional config=, baking it into the instance - get_column_type() returns a ColumnType instance (or None) instead of a (name, config) tuple - get_column_types() returns {col: ColumnType instance} instead of tuples - Remove get_column_type_class() - no longer needed - render_cell/validate/transform_value methods no longer take config arg; use self.config instead - render_cell hook takes column_type (ColumnType or None) instead of column_type + column_type_config https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3 --- datasette/app.py | 45 ++++---- datasette/column_types.py | 20 ++-- datasette/default_column_types.py | 24 ++-- datasette/hookspecs.py | 1 - datasette/views/database.py | 1 - datasette/views/row.py | 32 +++--- datasette/views/table.py | 94 +++++++--------- docs/internals.rst | 36 +++--- docs/plugin_hooks.rst | 30 ++--- tests/test_column_types.py | 179 +++++++++++++++++------------- tests/test_plugins.py | 8 +- 11 files changed, 227 insertions(+), 243 deletions(-) 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