diff --git a/docs/development/plugin_configuration.md b/docs/development/plugin_configuration.md index 8066123..69a47bd 100644 --- a/docs/development/plugin_configuration.md +++ b/docs/development/plugin_configuration.md @@ -2,9 +2,10 @@ Plugin packages that add [custom CLI commands](custom_commands.md) or [modules](index.md#installed-module-packages) often need to store their -own settings, such as an API key, a cache folder or the timestamp of the last +own settings, such as an API key, a server URL or the timestamp of the last synchronization. MVT provides a namespaced settings base class so each plugin -keeps its configuration in its own file. +keeps its configuration in its own file, and a data folder for anything else a +plugin needs to keep on disk. !!! warning @@ -33,6 +34,35 @@ matching the `mvt-plugin-` package naming convention. MVT creates the through a temporary file and moved into place, so an interrupted save never leaves a partially written settings file behind. +## Plugin Data Folder + +Everything else a plugin keeps on disk, such as a cache, a downloaded artifact +or synchronization state, belongs in the folder returned by +`mvt.common.plugin_config.plugin_data_folder()`: + +``` +~/.local/share/mvt/plugin-data// # Linux +~/Library/Application Support/mvt/plugin-data// # macOS +``` + +The folder sits beside MVT's own data, such as the downloaded indicators. +`plugin_data_folder()` creates it if it is missing, with `0700` permissions, +and returns its path. Calling it again returns the same path and leaves the +contents alone, so a plugin can call it every time it needs the folder: + +```python +import os + +from mvt.common.plugin_config import plugin_data_folder + + +def cache_path() -> str: + return os.path.join(plugin_data_folder("example-plugin"), "results.json") +``` + +Do not fall back on a path of your own such as `~/.cache/example-plugin`: it +is a Linux-only convention, and MVT will not create it for you. + ## Defining Plugin Settings Subclass `MVTPluginSettings`, set `plugin_name` and declare typed fields with @@ -48,7 +78,7 @@ class ExamplePluginSettings(MVTPluginSettings): plugin_name = "example-plugin" API_KEY: Optional[str] = None - CACHE_FOLDER: str = "~/.cache/example-plugin" + MAX_RESULTS: int = 25 LAST_SYNC: Optional[str] = None ``` @@ -91,7 +121,7 @@ underscores, followed by the field name. For the example above: ```bash export MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY=... -export MVT_PLUGIN_EXAMPLE_PLUGIN_CACHE_FOLDER=/tmp/example-cache +export MVT_PLUGIN_EXAMPLE_PLUGIN_MAX_RESULTS=50 ``` Settings resolve in this order, from highest to lowest priority: diff --git a/src/mvt/common/plugin_config.py b/src/mvt/common/plugin_config.py index 553462f..8c50262 100644 --- a/src/mvt/common/plugin_config.py +++ b/src/mvt/common/plugin_config.py @@ -11,7 +11,7 @@ import tempfile from typing import Any, ClassVar, Dict, List, Tuple, Type, TypeVar import yaml -from appdirs import user_config_dir +from appdirs import user_config_dir, user_data_dir from pydantic import ValidationError from pydantic_settings import ( BaseSettings, @@ -22,6 +22,9 @@ from pydantic_settings import ( ) PLUGIN_CONFIG_FOLDER_NAME = "plugins" +# Not "plugins": on macOS the configuration and data folders are the same +# directory, and that name already holds the settings files. +PLUGIN_DATA_FOLDER_NAME = "plugin-data" PLUGIN_ENV_PREFIX = "MVT_PLUGIN_" PLUGIN_NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*") @@ -74,6 +77,33 @@ def plugin_config_path(plugin_name: str) -> str: ) +def plugin_data_folder(plugin_name: str) -> str: + """ + Return the folder where a given plugin stores its data, creating it. + + Plugins should keep whatever they persist, such as caches or downloaded + artifacts, in this folder. It is created with owner-only permissions. The + path is resolved on every call so it always reflects the current + environment. + + :param plugin_name: Name of the plugin. + :returns: The path of the data folder of the plugin. + """ + # Validate the name before anything is created, so an unsafe name cannot + # leave a folder behind. + name = validate_plugin_name(plugin_name) + + # makedirs() applies its mode only to the last folder of the path, so + # MVT's own data folder keeps the default permissions while the two + # plugin folders are private. + data_folder = os.path.join(user_data_dir("mvt"), PLUGIN_DATA_FOLDER_NAME) + os.makedirs(data_folder, mode=0o700, exist_ok=True) + + folder = os.path.join(data_folder, name) + os.makedirs(folder, mode=0o700, exist_ok=True) + return folder + + def plugin_env_prefix(plugin_name: str) -> str: """ Return the environment variable prefix used by a given plugin. diff --git a/tests/common/test_plugin_config.py b/tests/common/test_plugin_config.py index 1cb1e0b..172dc68 100644 --- a/tests/common/test_plugin_config.py +++ b/tests/common/test_plugin_config.py @@ -16,6 +16,7 @@ from mvt.common.plugin_config import ( PluginConfigLoadError, plugin_config_folder, plugin_config_path, + plugin_data_folder, plugin_env_prefix, ) @@ -43,6 +44,16 @@ def config_folder(tmp_path, monkeypatch): return tmp_path +@pytest.fixture +def data_folder(tmp_path, monkeypatch): + folder = tmp_path / "data" + monkeypatch.setattr( + "mvt.common.plugin_config.user_data_dir", + lambda *args, **kwargs: str(folder), + ) + return folder + + def _write_plugin_file(plugin_name, values): config_path = plugin_config_path(plugin_name) os.makedirs(os.path.dirname(config_path), exist_ok=True) @@ -301,3 +312,59 @@ def test_underscores_are_not_allowed_in_plugin_names(): def test_unsafe_plugin_names_have_no_configuration_path(plugin_name): with pytest.raises(ValueError, match="Invalid plugin name"): plugin_config_path(plugin_name) + + +def test_data_folder_is_namespaced_and_created(data_folder): + folder = plugin_data_folder("example-plugin") + + assert folder == str(data_folder / "plugin-data" / "example-plugin") + assert os.path.isdir(folder) + + +def test_data_folder_can_be_requested_repeatedly(data_folder): + folder = plugin_data_folder("example-plugin") + with open(os.path.join(folder, "kept.json"), "w") as data_file: + data_file.write("{}") + + assert plugin_data_folder("example-plugin") == folder + assert os.listdir(folder) == ["kept.json"] + + +@pytest.mark.skipif( + sys.platform == "win32", reason="POSIX file permissions are not available" +) +def test_data_folder_is_only_accessible_by_the_user(data_folder): + folder = plugin_data_folder("example-plugin") + + assert stat.S_IMODE(os.stat(folder).st_mode) & 0o077 == 0 + parent_mode = stat.S_IMODE(os.stat(os.path.dirname(folder)).st_mode) + assert parent_mode & 0o077 == 0 + + +def test_plugins_get_their_own_data_folder(data_folder): + example_folder = plugin_data_folder("example-plugin") + other_folder = plugin_data_folder("other-plugin") + + assert example_folder != other_folder + assert sorted(os.listdir(data_folder / "plugin-data")) == [ + "example-plugin", + "other-plugin", + ] + + +def test_data_folder_does_not_touch_the_configuration_folder( + config_folder, data_folder +): + plugin_data_folder("example-plugin") + + assert not os.path.exists(plugin_config_folder()) + + +@pytest.mark.parametrize( + "plugin_name", ["../escape", "folder/name", "UPPER", "-dash", ""] +) +def test_unsafe_plugin_names_have_no_data_folder(data_folder, plugin_name): + with pytest.raises(ValueError, match="Invalid plugin name"): + plugin_data_folder(plugin_name) + + assert not os.path.exists(data_folder)