diff --git a/datasette/app.py b/datasette/app.py index fdec2c86ca..5348600714 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -368,7 +368,7 @@ def __init__( for key in config_settings: if key not in DEFAULT_SETTINGS: raise StartupError("Invalid setting '{}' in datasette.json".format(key)) - + self.config = config # CLI settings should overwrite datasette.json settings self._settings = dict(DEFAULT_SETTINGS, **(config_settings), **(settings or {})) self.renderers = {} # File extension -> (renderer, can_render) functions @@ -674,15 +674,43 @@ def get_internal_database(self): def plugin_config(self, plugin_name, database=None, table=None, fallback=True): """Return config for plugin, falling back from specified database/table""" - plugins = self.metadata( - "plugins", database=database, table=table, fallback=fallback - ) - if plugins is None: - return None - plugin_config = plugins.get(plugin_name) - # Resolve any $file and $env keys - plugin_config = resolve_env_secrets(plugin_config, os.environ) - return plugin_config + if database is None and table is None: + config = self._plugin_config_top(plugin_name) + else: + config = self._plugin_config_nested(plugin_name, database, table, fallback) + + return resolve_env_secrets(config, os.environ) + + def _plugin_config_top(self, plugin_name): + """Returns any top-level plugin configuration for the specified plugin.""" + return ((self.config or {}).get("plugins") or {}).get(plugin_name) + + def _plugin_config_nested(self, plugin_name, database, table=None, fallback=True): + """Returns any database or table-level plugin configuration for the specified plugin.""" + db_config = ((self.config or {}).get("databases") or {}).get(database) + + # if there's no db-level configuration, then return early, falling back to top-level if needed + if not db_config: + return self._plugin_config_top(plugin_name) if fallback else None + + db_plugin_config = (db_config.get("plugins") or {}).get(plugin_name) + + if table: + table_plugin_config = ( + ((db_config.get("tables") or {}).get(table) or {}).get("plugins") or {} + ).get(plugin_name) + + # fallback to db_config or top-level config, in that order, if needed + if table_plugin_config is None and fallback: + return db_plugin_config or self._plugin_config_top(plugin_name) + + return table_plugin_config + + # fallback to top-level if needed + if db_plugin_config is None and fallback: + self._plugin_config_top(plugin_name) + + return db_plugin_config def app_css_hash(self): if not hasattr(self, "_app_css_hash"): diff --git a/docs/configuration.rst b/docs/configuration.rst index ed9975ac02..214e904483 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1,10 +1,101 @@ .. _configuration: Configuration -======== +============= -Datasette offers many way to configure your Datasette instances: server settings, plugin configuration, authentication, and more. +Datasette offers several ways to configure your Datasette instances: server settings, plugin configuration, authentication, and more. -To facilitate this, You can provide a `datasette.yaml` configuration file to datasette with the ``--config``/ ``-c`` flag: +To facilitate this, You can provide a ``datasette.yaml`` configuration file to datasette with the ``--config``/ ``-c`` flag: + +.. code-block:: bash datasette mydatabase.db --config datasette.yaml + +.. _configuration_reference: + +``datasette.yaml`` reference +---------------------------- + +Here's a full example of all the valid configuration options that can exist inside ``datasette.yaml``. + +.. tab:: YAML + + .. code-block:: yaml + + # Datasette settings block + settings: + default_page_size: 50 + sql_time_limit_ms: 3500 + max_returned_rows: 2000 + + # top-level plugin configuration + plugins: + datasette-my-plugin: + key: valueA + + # Database and table-level configuration + databases: + your_db_name: + # plugin configuration for the your_db_name database + plugins: + datasette-my-plugin: + key: valueA + tables: + your_table_name: + # plugin configuration for the your_table_name table + # inside your_db_name database + plugins: + datasette-my-plugin: + key: valueB + +.. _configuration_reference_settings: +Settings configuration +~~~~~~~~~~~~~~~~~~~~~~ + +:ref:`settings` can be configured in ``datasette.yaml`` with the ``settings`` key. + +.. tab:: YAML + + .. code-block:: yaml + + # inside datasette.yaml + settings: + default_allow_sql: off + default_page_size: 50 + + +.. _configuration_reference_plugins: +Plugin configuration +~~~~~~~~~~~~~~~~~~~~ + +Configuration for plugins can be defined inside ``datasette.yaml``. For top-level plugin configuration, use the ``plugins`` key. + +.. tab:: YAML + + .. code-block:: yaml + + # inside datasette.yaml + plugins: + datasette-my-plugin: + key: my_value + +For database level or table level plugin configuration, nest it under the appropriate place under ``databases``. + +.. tab:: YAML + + .. code-block:: yaml + + # inside datasette.yaml + databases: + my_database: + # plugin configuration for the my_database database + plugins: + datasette-my-plugin: + key: my_value + my_other_database: + tables: + my_table: + # plugin configuration for the my_table table inside the my_other_database database + plugins: + datasette-my-plugin: + key: my_value diff --git a/docs/index.rst b/docs/index.rst index f5c1f2321d..cfa3443cc1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,6 +39,7 @@ Contents getting_started installation + configuration ecosystem cli-reference pages diff --git a/docs/internals.rst b/docs/internals.rst index 13f1d4a1a5..7fc7948ce1 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -296,7 +296,7 @@ The dictionary keys are the permission names - e.g. ``view-instance`` - and the ``table`` - None or string The table the user is interacting with. -This method lets you read plugin configuration values that were set in ``metadata.json``. See :ref:`writing_plugins_configuration` for full details of how this method should be used. +This method lets you read plugin configuration values that were set in ``datasette.yaml``. See :ref:`writing_plugins_configuration` for full details of how this method should be used. The return value will be the value from the configuration file - usually a dictionary. diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index e966919b29..1816d48c28 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -909,7 +909,7 @@ Potential use-cases: * Run some initialization code for the plugin * Create database tables that a plugin needs on startup -* Validate the metadata configuration for a plugin on startup, and raise an error if it is invalid +* Validate the configuration for a plugin on startup, and raise an error if it is invalid .. note:: diff --git a/docs/writing_plugins.rst b/docs/writing_plugins.rst index d0dd8f363d..c028b4ff13 100644 --- a/docs/writing_plugins.rst +++ b/docs/writing_plugins.rst @@ -184,7 +184,7 @@ This will return the ``{"latitude_column": "lat", "longitude_column": "lng"}`` i If there is no configuration for that plugin, the method will return ``None``. -If it cannot find the requested configuration at the table layer, it will fall back to the database layer and then the root layer. For example, a user may have set the plugin configuration option like so: +If it cannot find the requested configuration at the table layer, it will fall back to the database layer and then the root layer. For example, a user may have set the plugin configuration option inside ``datasette.yaml`` like so: .. [[[cog from metadata_doc import metadata_example @@ -234,11 +234,10 @@ If it cannot find the requested configuration at the table layer, it will fall b In this case, the above code would return that configuration for ANY table within the ``sf-trees`` database. -The plugin configuration could also be set at the top level of ``metadata.yaml``: +The plugin configuration could also be set at the top level of ``datasette.yaml``: .. [[[cog metadata_example(cog, { - "title": "This is the top-level title in metadata.json", "plugins": { "datasette-cluster-map": { "latitude_column": "xlat", @@ -252,7 +251,6 @@ The plugin configuration could also be set at the top level of ``metadata.yaml`` .. code-block:: yaml - title: This is the top-level title in metadata.json plugins: datasette-cluster-map: latitude_column: xlat @@ -264,7 +262,6 @@ The plugin configuration could also be set at the top level of ``metadata.yaml`` .. code-block:: json { - "title": "This is the top-level title in metadata.json", "plugins": { "datasette-cluster-map": { "latitude_column": "xlat", diff --git a/tests/conftest.py b/tests/conftest.py index fb7f768e9e..31336aead9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,7 +41,7 @@ def wait_until_responds(url, timeout=5.0, client=httpx, **kwargs): @pytest_asyncio.fixture async def ds_client(): from datasette.app import Datasette - from .fixtures import METADATA, PLUGINS_DIR + from .fixtures import CONFIG, METADATA, PLUGINS_DIR global _ds_client if _ds_client is not None: @@ -49,6 +49,7 @@ async def ds_client(): ds = Datasette( metadata=METADATA, + config=CONFIG, plugins_dir=PLUGINS_DIR, settings={ "default_page_size": 50, diff --git a/tests/fixtures.py b/tests/fixtures.py index a6700239aa..9cf6b60588 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -114,6 +114,7 @@ def make_app_client( inspect_data=None, static_mounts=None, template_dir=None, + config=None, metadata=None, crossdb=False, ): @@ -158,6 +159,7 @@ def make_app_client( memory=memory, cors=cors, metadata=metadata or METADATA, + config=config or CONFIG, plugins_dir=PLUGINS_DIR, settings=settings, inspect_data=inspect_data, @@ -296,16 +298,7 @@ def generate_sortable_rows(num): } -METADATA = { - "title": "Datasette Fixtures", - "description_html": 'An example SQLite database demonstrating Datasette. Sign in as root user', - "license": "Apache License 2.0", - "license_url": "https://github.com/simonw/datasette/blob/main/LICENSE", - "source": "tests/fixtures.py", - "source_url": "https://github.com/simonw/datasette/blob/main/tests/fixtures.py", - "about": "About Datasette", - "about_url": "https://github.com/simonw/datasette", - "extra_css_urls": ["/static/extra-css-urls.css"], +CONFIG = { "plugins": { "name-of-plugin": {"depth": "root"}, "env-plugin": {"foo": {"$env": "FOO_ENV"}}, @@ -314,12 +307,9 @@ def generate_sortable_rows(num): }, "databases": { "fixtures": { - "description": "Test tables description", "plugins": {"name-of-plugin": {"depth": "database"}}, "tables": { "simple_primary_key": { - "description_html": "Simple primary key", - "title": "This HTML is escaped", "plugins": { "name-of-plugin": { "depth": "table", @@ -327,6 +317,32 @@ def generate_sortable_rows(num): } }, }, + "sortable": { + "plugins": {"name-of-plugin": {"depth": "table"}}, + }, + }, + } + }, +} + +METADATA = { + "title": "Datasette Fixtures", + "description_html": 'An example SQLite database demonstrating Datasette. Sign in as root user', + "license": "Apache License 2.0", + "license_url": "https://github.com/simonw/datasette/blob/main/LICENSE", + "source": "tests/fixtures.py", + "source_url": "https://github.com/simonw/datasette/blob/main/tests/fixtures.py", + "about": "About Datasette", + "about_url": "https://github.com/simonw/datasette", + "extra_css_urls": ["/static/extra-css-urls.css"], + "databases": { + "fixtures": { + "description": "Test tables description", + "tables": { + "simple_primary_key": { + "description_html": "Simple primary key", + "title": "This HTML is escaped", + }, "sortable": { "sortable_columns": [ "sortable", @@ -334,7 +350,6 @@ def generate_sortable_rows(num): "sortable_with_nulls_2", "text", ], - "plugins": {"name-of-plugin": {"depth": "table"}}, }, "no_primary_key": {"sortable_columns": [], "hidden": True}, "units": {"units": {"distance": "m", "frequency": "Hz"}}, @@ -768,6 +783,7 @@ def assert_permissions_checked(datasette, actions): type=click.Path(file_okay=True, dir_okay=False), ) @click.argument("metadata", required=False) +@click.argument("config", required=False) @click.argument( "plugins_path", type=click.Path(file_okay=False, dir_okay=True), required=False ) @@ -782,7 +798,7 @@ def assert_permissions_checked(datasette, actions): type=click.Path(file_okay=True, dir_okay=False), help="Write out second test DB to this file", ) -def cli(db_filename, metadata, plugins_path, recreate, extra_db_filename): +def cli(db_filename, config, metadata, plugins_path, recreate, extra_db_filename): """Write out the fixtures database used by Datasette's test suite""" if metadata and not metadata.endswith(".json"): raise click.ClickException("Metadata should end with .json") @@ -805,6 +821,10 @@ def cli(db_filename, metadata, plugins_path, recreate, extra_db_filename): with open(metadata, "w") as fp: fp.write(json.dumps(METADATA, indent=4)) print(f"- metadata written to {metadata}") + if config: + with open(config, "w") as fp: + fp.write(json.dumps(CONFIG, indent=4)) + print(f"- config written to {config}") if plugins_path: path = pathlib.Path(plugins_path) if not path.exists(): diff --git a/tests/test_cli.py b/tests/test_cli.py index e85bcef184..213db41657 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -238,6 +238,44 @@ def test_setting(args): assert settings["default_page_size"] == 5 +def test_plugin_s_overwrite(): + runner = CliRunner() + plugins_dir = str(pathlib.Path(__file__).parent / "plugins") + + result = runner.invoke( + cli, + [ + "--plugins-dir", + plugins_dir, + "--get", + "/_memory.json?sql=select+prepare_connection_args()", + ], + ) + assert result.exit_code == 0, result.output + assert ( + json.loads(result.output).get("rows")[0].get("prepare_connection_args()") + == 'database=_memory, datasette.plugin_config("name-of-plugin")=None' + ) + + result = runner.invoke( + cli, + [ + "--plugins-dir", + plugins_dir, + "--get", + "/_memory.json?sql=select+prepare_connection_args()", + "-s", + "plugins.name-of-plugin", + "OVERRIDE", + ], + ) + assert result.exit_code == 0, result.output + assert ( + json.loads(result.output).get("rows")[0].get("prepare_connection_args()") + == 'database=_memory, datasette.plugin_config("name-of-plugin")=OVERRIDE' + ) + + def test_setting_type_validation(): runner = CliRunner(mix_stderr=False) result = runner.invoke(cli, ["--setting", "default_page_size", "dog"]) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 625ae635a9..3753099193 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -234,9 +234,6 @@ async def test_plugin_config(ds_client): async def test_plugin_config_env(ds_client): os.environ["FOO_ENV"] = "FROM_ENVIRONMENT" assert {"foo": "FROM_ENVIRONMENT"} == ds_client.ds.plugin_config("env-plugin") - # Ensure secrets aren't visible in /-/metadata.json - metadata = await ds_client.get("/-/metadata.json") - assert {"foo": {"$env": "FOO_ENV"}} == metadata.json()["plugins"]["env-plugin"] del os.environ["FOO_ENV"] @@ -246,11 +243,6 @@ async def test_plugin_config_env_from_list(ds_client): assert [{"in_a_list": "FROM_ENVIRONMENT"}] == ds_client.ds.plugin_config( "env-plugin-list" ) - # Ensure secrets aren't visible in /-/metadata.json - metadata = await ds_client.get("/-/metadata.json") - assert [{"in_a_list": {"$env": "FOO_ENV"}}] == metadata.json()["plugins"][ - "env-plugin-list" - ] del os.environ["FOO_ENV"] @@ -259,11 +251,6 @@ async def test_plugin_config_file(ds_client): with open(TEMP_PLUGIN_SECRET_FILE, "w") as fp: fp.write("FROM_FILE") assert {"foo": "FROM_FILE"} == ds_client.ds.plugin_config("file-plugin") - # Ensure secrets aren't visible in /-/metadata.json - metadata = await ds_client.get("/-/metadata.json") - assert {"foo": {"$file": TEMP_PLUGIN_SECRET_FILE}} == metadata.json()["plugins"][ - "file-plugin" - ] os.remove(TEMP_PLUGIN_SECRET_FILE) @@ -722,7 +709,7 @@ async def test_hook_register_routes(ds_client, path, body): @pytest.mark.parametrize("configured_path", ("path1", "path2")) def test_hook_register_routes_with_datasette(configured_path): with make_app_client( - metadata={ + config={ "plugins": { "register-route-demo": { "path": configured_path, @@ -741,7 +728,7 @@ def test_hook_register_routes_with_datasette(configured_path): def test_hook_register_routes_override(): "Plugins can over-ride default paths such as /db/table" with make_app_client( - metadata={ + config={ "plugins": { "register-route-demo": { "path": "blah", @@ -1099,7 +1086,7 @@ def filters_from_request(self, request): @pytest.mark.parametrize("extra_metadata", (False, True)) async def test_hook_register_permissions(extra_metadata): ds = Datasette( - metadata={ + config={ "plugins": { "datasette-register-permissions": { "permissions": [ @@ -1151,7 +1138,7 @@ async def test_hook_register_permissions_no_duplicates(duplicate): if duplicate == "abbr": abbr2 = "abbr1" ds = Datasette( - metadata={ + config={ "plugins": { "datasette-register-permissions": { "permissions": [ @@ -1186,7 +1173,7 @@ async def test_hook_register_permissions_no_duplicates(duplicate): @pytest.mark.asyncio async def test_hook_register_permissions_allows_identical_duplicates(): ds = Datasette( - metadata={ + config={ "plugins": { "datasette-register-permissions": { "permissions": [