From be4f02335fb35d40a763d07b2d4e880b90083e53 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 1 Feb 2024 15:33:33 -0800 Subject: [PATCH] Treat plugins in metadata as if they were in config, closes #2248 --- datasette/app.py | 6 +++++ datasette/utils/__init__.py | 40 +++++++++++++++++++++++++++++ tests/test_plugins.py | 51 +++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/datasette/app.py b/datasette/app.py index 0143223a..634283ff 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -74,6 +74,7 @@ from .utils import ( find_spatialite, format_bytes, module_from_path, + move_plugins, parse_metadata, resolve_env_secrets, resolve_routes, @@ -341,6 +342,11 @@ class Datasette: with config_files[0].open() as fp: config = parse_metadata(fp.read()) + # Move any "plugins" settings from metadata to config - updates them in place + metadata = metadata or {} + config = config or {} + move_plugins(metadata, config) + self._metadata_local = metadata or {} self.sqlite_extensions = [] for extension in sqlite_extensions or []: diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 75f1c2f4..cc175b01 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1287,3 +1287,43 @@ def make_slot_function(name, datasette, request, **kwargs): return markupsafe.Markup("".join(html_bits)) return inner + + +def move_plugins(source, destination): + """ + Move 'plugins' keys from source to destination dictionary. Creates hierarchy in destination if needed. + After moving, recursively remove any keys in the source that are left empty. + """ + + def recursive_move(src, dest, path=None): + if path is None: + path = [] + for key, value in list(src.items()): + new_path = path + [key] + if key == "plugins": + # Navigate and create the hierarchy in destination if needed + d = dest + for step in path: + d = d.setdefault(step, {}) + # Move the plugins + d[key] = value + # Remove the plugins from source + src.pop(key, None) + elif isinstance(value, dict): + recursive_move(value, dest, new_path) + # After moving, check if the current dictionary is empty and remove it if so + if not value: + src.pop(key, None) + + def prune_empty_dicts(d): + """ + Recursively prune all empty dictionaries from a given dictionary. + """ + for key, value in list(d.items()): + if isinstance(value, dict): + prune_empty_dicts(value) + if value == {}: + d.pop(key, None) + + recursive_move(source, destination) + prune_empty_dicts(source) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index f26e3652..a53fc118 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1458,3 +1458,54 @@ async def test_hook_register_events(): datasette = Datasette(memory=True) await datasette.invoke_startup() assert any(k.__name__ == "OneEvent" for k in datasette.event_classes) + + +@pytest.mark.parametrize( + "metadata,config,expected_metadata,expected_config", + ( + ( + # Instance level + {"plugins": {"datasette-foo": "bar"}}, + {}, + {}, + {"plugins": {"datasette-foo": "bar"}}, + ), + ( + # Database level + {"databases": {"foo": {"plugins": {"datasette-foo": "bar"}}}}, + {}, + {}, + {"databases": {"foo": {"plugins": {"datasette-foo": "bar"}}}}, + ), + ( + # Table level + { + "databases": { + "foo": {"tables": {"bar": {"plugins": {"datasette-foo": "bar"}}}} + } + }, + {}, + {}, + { + "databases": { + "foo": {"tables": {"bar": {"plugins": {"datasette-foo": "bar"}}}} + } + }, + ), + ( + # Keep other keys + {"plugins": {"datasette-foo": "bar"}, "other": "key"}, + {"original_config": "original"}, + {"other": "key"}, + {"original_config": "original", "plugins": {"datasette-foo": "bar"}}, + ), + ), +) +def test_metadata_plugin_config_treated_as_config( + metadata, config, expected_metadata, expected_config +): + ds = Datasette(metadata=metadata, config=config) + actual_metadata = ds.metadata() + assert "plugins" not in actual_metadata + assert actual_metadata == expected_metadata + assert ds.config == expected_config