Add per-plugin data folders

Plugins had no sanctioned place to keep the data they persist, so the
plugin configuration documentation suggested a CACHE_FOLDER setting
defaulting to ~/.cache/example-plugin. That is a Linux convention which
is wrong on macOS, nothing expands or creates it, and it turns a path
into a setting a user can be asked to configure.

Add plugin_data_folder(), which returns the folder a plugin should use
for caches, downloaded artifacts, synchronization state or anything
else it writes to disk, and creates it if it is missing. The plugin
name is validated before anything is created, so a name holding a path
separator raises an error and leaves no folder behind, and calling the
function again returns the same folder with its contents untouched.

The folder sits under plugin-data rather than under the plugins folder
which holds the settings files. On macOS the configuration folder and
the data folder are the same directory, so reusing the plugins name
would leave each plugin's data folder in among the settings files.

Both the plugin-data folder and the folder of each plugin are created
with 0700 permissions. MVT is a forensic tool, and what a plugin keeps
there, such as API responses or sample metadata, is private by default.
The path is resolved on every call, as the configuration folder already
is, so it follows the current environment rather than whatever it was
when MVT was imported.

The documentation now points plugins at the helper, and the example
settings class carries a plain integer setting in place of its cache
folder.
This commit is contained in:
Donncha Ó Cearbhaill
2026-08-26 11:53:47 +02:00
parent dab82e38d0
commit 90fc0ae351
3 changed files with 132 additions and 5 deletions
+34 -4
View File
@@ -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-<name>` 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/<plugin name>/ # Linux
~/Library/Application Support/mvt/plugin-data/<plugin name>/ # 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:
+31 -1
View File
@@ -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.
+67
View File
@@ -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)