From 680320c740ddbab9c602a37326b7f275ba798ee1 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 14 Sep 2023 16:08:45 +0200 Subject: [PATCH 01/15] PICARD-1861: Initial draft for Plugin API v3 --- PLUGINS.md | 302 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 PLUGINS.md diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 0000000000..58e4e5147c --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,302 @@ +# Picard Plugin API v3 (Proposal) + +## Introduction / Motivation +TBD + + +## Scope + +This document only discusses the structure and API for Picard plugins. It does not +discuss distribution of plugins or the maintenance of a plugins repository. See +[the wiki](https://github.com/rdswift/picard-plugins/wiki/Picard-Plugins-System-Proposal) +for and extended discussion of distribution and maintenance. + + +## Limitations of the old plugin system + +- **No separation of metadata and code:** As the metadata, such as plugin name + and description, but also supported API versions, is part of the Python code, + each installed plugin's code was executed regardless of whether the plugin + is enabled or even compatible with the current Picard version. + +- **No defined API:** Apart from a few methods to register plugin hooks there + is no actual API provided. This makes it both difficult for plugin developers + to decide what parts of Picard can be safely used as well for Picard developers + to decide which internal change should be considered a breaking change for plugins. + +- **Imports scattered over the codebase:** The different functions for registering + plugin hooks as well as the objects provided by Picard that are actually useful + for plugins are all scattered over the Picard code base. While this follows a + system that is logical if you are familiar with Picard's code base, this is not + transparent to plugin developers. + +- **Many supported plugin formats:** The old system allowed multiple ways how a + plugin can be structured. The following formats where supported: + - A single Python module (`example.py`) + - A Python package (`example/__init__.py`) + - A ZIP archive (`example.zip`) containing a single Python module + - A ZIP archive (`example.zip`) containing a Python package + - A ZIP archive (`example.picard.zip`) with either a Python module or package + and an additional metadata file `MANIFEST.json`. + This variation leeds to extra complexity in the implementation and increases + maintenance and testing effort. It also increased complexity for users, as they + needed to decide whether a plugin file needs to be placed at the top level + or inside a directory. + + +## Format + +A Picard plugin MUST be a Python package, that is a directory with a valid Python +package name containing at least a single `__init__.py` file. The package directory +MUST also contain a manifest file which provides metadata about the plugin. + +The package directory MAY contain additional files, such as Python modules to load. + +A basic plugin `example` could have the following structure: + + +``` +example/ + __init__.py + MANIFEST.toml +``` + +The plugin package MAY be put into a ZIP archive. In this case the filename +must be the same as the plugin package name followed by `.picard.zip`, e.g. +`example.picard.zip`. + + +### File system locations +TBD + + +### Package structure and implemented API + +The package MUST define the following top-level functions: + +- `enable` gets called when the plugin gets enabled. This happens on startup for + all enabled plugins and also if the user enables a previously disabled plugin. + The function gets passed an instance of `picard.plugin.PluginApi`, which + provides access to Picard's official plugin API and allows to register plugin hooks. + +The package MAY define the following top-level functions: + +- `disable` gets called when the plugin gets disabled. The plugin should stop all + processing and free required resources. The plugin does not need to de-register + plugin hooks, as those get disabled automatically. + After being disabled the plugin can always be enabled again (the `enable` + function gets called). + +> ***Discussion:** Are `install` and `uninstall` hooks needed?* + + +A basic plugin structure could be: + +```python +from picard.plugin import PluginApi + +def enable(api: PluginApi) -> None: + # api can be used to register plugin hooks and to access essential Picard APIs. + pass + +def disable() -> None: + pass +``` + +The plugin MUST NOT perform any actual work, apart from defining types and +functions, on import. All actual processing must be performed only as part of +the `enable` and `disable` functions and any plugin hooks registered in `enable`. + + +### Manifest format +The plugin's package directory MUST contain a file `MANIFEST.toml`. + +> ***Discussion:** Is TOML the proper format, or should something like JSON or YAML be preferred?* + +The file MUST define the following mandatory metadata fields: + +| Field name | Type | Description | +|----------------|--------|------------------------------------------------------------------| +| name | string | The plugin's full name | +| author | string | The plugin author | +| description | string | Detailed description of the plugin. Supports Markdown formatting | +| version | string | Plugin version. Use semantic versioning in the format "x.y.z" | +| api | list | The Picard API versions supported by the plugin | +| license | string | License, should be a [SPDX license name](https://spdx.org/licenses/) and GPLv2 compatible | + +The file MAY define any of the following optional fields: + +| Field name | Type | Description | +|----------------|--------|------------------------------------------------------------------| +| extract | bool | If set to `true` the plugin must be extracted on installation | +| license-url | string | URL to the full license text | +| user-guide-url | string | URL to the plugin's documentation | + + +Example `MANIFEST.toml`: + +```toml +name = "Example plugin" +author = "Philipp Wolfer" +description = """ +This is an example plugin showcasing the new **Picard 3 plugin** API. + +You can use [Markdown](https://daringfireball.net/projects/markdown/) for formatting.""" +version = "1.0.0" +api = ["3.0", "3.1"] +extract = true +license = "CC0-1.0" +license-url = "https://creativecommons.org/publicdomain/zero/1.0/" +user-guide-url = "https://example.com/" +``` + + +### Picard Plugin API + +As described above the plugin's `enable` function gets called with an instance +of `picard.plugin.PluginApi`. `PluginApi` provides access to essential Picard +APIs and also allows registering plugin hooks. + +`PluginApi` implements the interface below: + +```python +from typing import ( + Callable, + Type, +) +from logging import Logger + +from picard.config import ( + Config, + ConfigSection, +) +from picard.coverart.providers import CoverArtProvider +from picard.file import File +from picard.plugin import PluginPriority +from picard.webservice import WebService +from picard.webservice.api_helpers import MBAPIHelper + +from picard.ui.itemviews import BaseAction +from picard.ui.options import OptionsPage + +class PluginApi: + @property + def web_service(self) -> WebService: + pass + + @property + def mb_api(self) -> MBAPIHelper: + pass + + @property + def logger(self) -> Logger: + pass + + @property + def global_config(self) -> Config: + pass + + @property + def plugin_config(self) -> ConfigSection: + """Configuration private to the plugin""" + pass + + # Metadata processors + def register_album_metadata_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + pass + + def register_track_metadata_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + pass + + # Event hooks + def register_album_post_removal_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + pass + + def register_file_post_load_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + pass + + def register_file_post_addition_to_track_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + pass + + def register_file_post_removal_from_track_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + pass + + def register_file_post_save_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + pass + + # Cover art + def register_cover_art_provider(provider: CoverArtProvider) -> None: + pass + + # File formats + def register_format(format: File) -> None: + pass + + # Scripting + def register_script_function(function: Callable, name: str = None, eval_args: bool = True, + check_argcount: bool = True, documentation: str = None) -> None: + pass + + # Context menu actions + def register_album_action(action: BaseAction) -> None: + pass + + def register_cluster_action(action: BaseAction) -> None: + pass + + def register_clusterlist_action(action: BaseAction) -> None: + pass + + def register_track_action(action: BaseAction) -> None: + pass + + def register_file_action(action: BaseAction) -> None: + pass + + # UI + def register_options_page(page_class: Type[OptionsPage]) -> None: + pass + + # TODO: Replace by init function in plugin + # def register_ui_init(function: Callable) -> None: + # pass + + # Other ideas + # Implement status indicators as an extension point. This allows plugins + # that use alternative progress displays + # def register_status_indicator(function: Callable) -> None: + # pass + + # Register page for file properties. Same for track and album + # def register_file_info_page(page_class): + # pass + + # For the media player toolbar? + # def register_toolbar(toolbar_class): + # pass +``` + + +### Localization +TBD + + +### Plugin live cycle +TBD + + +### To be discussed + +- Localization? +- Categorization? [PW-12](https://tickets.metabrainz.org/browse/PW-12) +- Extra data files? +- Additional extension points? + + +## Implementation considerations + +- All objects exposed by `picard.plugin.PluginApi` SHOULD provide + full type hinting for all methods and properties that are considered + public API. +- It might be advisable in some cases that `picard.plugin.PluginApi` exposes + only wrappers instead of the actual object to limit the exposed API. From e3c86ef223d4f872d81fe4fb70c6959b6dbe3cf3 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 11 Feb 2024 12:41:15 +0100 Subject: [PATCH 02/15] PICARD-1861: basic implementation for PluginManifest and PluginApi --- PLUGINS.md | 4 +- picard/plugin3/__init__.py | 19 +++ picard/plugin3/api.py | 171 +++++++++++++++++++ picard/plugin3/manifest.py | 84 +++++++++ requirements-macos-11.0.txt | 1 + requirements-win.txt | 1 + requirements.txt | 1 + test/data/testplugins3/example/MANIFEST.toml | 11 ++ test/data/testplugins3/example/__init__.py | 12 ++ test/test_plugins3.py | 72 ++++++++ 10 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 picard/plugin3/__init__.py create mode 100644 picard/plugin3/api.py create mode 100644 picard/plugin3/manifest.py create mode 100644 test/data/testplugins3/example/MANIFEST.toml create mode 100644 test/data/testplugins3/example/__init__.py create mode 100644 test/test_plugins3.py diff --git a/PLUGINS.md b/PLUGINS.md index 58e4e5147c..6179467e8b 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -93,7 +93,7 @@ The package MAY define the following top-level functions: A basic plugin structure could be: ```python -from picard.plugin import PluginApi +from picard.plugin3.api import PluginApi def enable(api: PluginApi) -> None: # api can be used to register plugin hooks and to access essential Picard APIs. @@ -128,7 +128,7 @@ The file MAY define any of the following optional fields: | Field name | Type | Description | |----------------|--------|------------------------------------------------------------------| -| extract | bool | If set to `true` the plugin must be extracted on installation | +| extract | bool | If set to `true` the plugin must be extracted on installation (to be discussed) | | license-url | string | URL to the full license text | | user-guide-url | string | URL to the plugin's documentation | diff --git a/picard/plugin3/__init__.py b/picard/plugin3/__init__.py new file mode 100644 index 0000000000..470444a30e --- /dev/null +++ b/picard/plugin3/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2024 Philipp Wolfer +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. diff --git a/picard/plugin3/api.py b/picard/plugin3/api.py new file mode 100644 index 0000000000..a420d47678 --- /dev/null +++ b/picard/plugin3/api.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2023-2024 Philipp Wolfer +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from logging import ( + Logger, + getLogger, +) +from typing import ( + Callable, + Type, +) + +from picard.album import register_album_post_removal_processor +from picard.config import ( + Config, + ConfigSection, + config, + get_config, +) +from picard.coverart.providers import ( + CoverArtProvider, + register_cover_art_provider, +) +from picard.file import ( + File, + register_file_post_addition_to_track_processor, + register_file_post_load_processor, + register_file_post_removal_from_track_processor, + register_file_post_save_processor, +) +from picard.formats.util import register_format +from picard.metadata import ( + register_album_metadata_processor, + register_track_metadata_processor, +) +from picard.plugin3.manifest import PluginManifest +from picard.plugin import PluginPriority +from picard.script.functions import register_script_function +from picard.webservice import WebService +from picard.webservice.api_helpers import MBAPIHelper + +from picard.ui.itemviews import ( + BaseAction, + register_album_action, + register_cluster_action, + register_clusterlist_action, + register_file_action, + register_track_action, +) +from picard.ui.options import ( + OptionsPage, + register_options_page, +) + + +class PluginApi: + def __init__(self, manifest: PluginManifest, tagger) -> None: + from picard.tagger import Tagger + self._tagger: Tagger = tagger + self._manifest = manifest + full_name = f'plugin.{self._manifest.module_name}' + self._logger = getLogger(full_name) + self._api_config = ConfigSection(config, full_name) + + @property + def web_service(self) -> WebService: + return self._tagger.webservice + + @property + def mb_api(self) -> MBAPIHelper: + return MBAPIHelper(self._tagger.webservice) + + @property + def logger(self) -> Logger: + return self._logger + + @property + def global_config(self) -> Config: + return get_config() + + @property + def plugin_config(self) -> ConfigSection: + """Configuration private to the plugin""" + return self._api_config + + # Metadata processors + def register_album_metadata_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + return register_album_metadata_processor(function, priority) + + def register_track_metadata_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + return register_track_metadata_processor(function, priority) + + # Event hooks + def register_album_post_removal_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + return register_album_post_removal_processor(function, priority) + + def register_file_post_load_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + return register_file_post_load_processor(function, priority) + + def register_file_post_addition_to_track_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + return register_file_post_addition_to_track_processor(function, priority) + + def register_file_post_removal_from_track_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + return register_file_post_removal_from_track_processor(function, priority) + + def register_file_post_save_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + return register_file_post_save_processor(function, priority) + + # Cover art + def register_cover_art_provider(provider: CoverArtProvider) -> None: + return register_cover_art_provider(provider) + + # File formats + def register_format(format: File) -> None: + return register_format(format) + + # Scripting + def register_script_function(function: Callable, name: str = None, eval_args: bool = True, + check_argcount: bool = True, documentation: str = None) -> None: + return register_script_function(function, name, eval_args, check_argcount, documentation) + + # Context menu actions + def register_album_action(action: BaseAction) -> None: + return register_album_action(action) + + def register_cluster_action(action: BaseAction) -> None: + return register_cluster_action(action) + + def register_clusterlist_action(action: BaseAction) -> None: + return register_clusterlist_action(action) + + def register_track_action(action: BaseAction) -> None: + return register_track_action(action) + + def register_file_action(action: BaseAction) -> None: + return register_file_action(action) + + # UI + def register_options_page(page_class: Type[OptionsPage]) -> None: + return register_options_page(page_class) + + # TODO: Replace by init function in plugin + # def register_ui_init(function: Callable) -> None: + # pass + + # Other ideas + # Implement status indicators as an extension point. This allows plugins + # that use alternative progress displays + # def register_status_indicator(function: Callable) -> None: + # pass + + # Register page for file properties. Same for track and album + # def register_file_info_page(page_class): + # pass diff --git a/picard/plugin3/manifest.py b/picard/plugin3/manifest.py new file mode 100644 index 0000000000..18b365e834 --- /dev/null +++ b/picard/plugin3/manifest.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2023-2024 Philipp Wolfer +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +try: + from tomllib import load as load_toml +except ImportError: + from tomlkit import load as load_toml + +from typing import ( + BinaryIO, + Tuple, +) + +from picard.version import ( + Version, + VersionError, +) + + +class PluginManifest: + """Provides access to the plugin metadata from a MANIFEST.toml file. + """ + + def __init__(self, module_name: str, manifest_fp: BinaryIO) -> None: + self.module_name = module_name + self._data = load_toml(manifest_fp) + + @property + def name(self) -> str: + return self._data.get('name') + + @property + def author(self) -> str: + return self._data.get('author') + + @property + def description(self) -> str: + return self._data.get('description') + + @property + def version(self) -> Version: + try: + return Version.from_string(self._data.get('version')) + except VersionError: + return Version(0, 0, 0) + + @property + def api_versions(self) -> Tuple[Version]: + versions = self._data.get('api') + if not versions: + return tuple() + try: + return tuple(Version.from_string(v) for v in versions) + except VersionError: + return tuple() + + @property + def license(self) -> str: + return self._data.get('license') + + @property + def license_url(self) -> str: + return self._data.get('license-url') + + @property + def user_guide_url(self) -> str: + return self._data.get('user-guide-url') diff --git a/requirements-macos-11.0.txt b/requirements-macos-11.0.txt index 5c23da2e55..303fc80a3a 100644 --- a/requirements-macos-11.0.txt +++ b/requirements-macos-11.0.txt @@ -9,3 +9,4 @@ PyQt6-Qt6==6.8.1 python-dateutil==2.9.0.post0 PyYAML==6.0.2 charset-normalizer==3.4.1 +tomlkit==0.13.2; python_version < '3.11' diff --git a/requirements-win.txt b/requirements-win.txt index 16e4c18de6..ba26ecdd66 100644 --- a/requirements-win.txt +++ b/requirements-win.txt @@ -8,3 +8,4 @@ python-dateutil==2.9.0.post0 pywin32==308 PyYAML==6.0.2 charset-normalizer==3.4.1 +tomlkit==0.13.2; python_version < '3.11' diff --git a/requirements.txt b/requirements.txt index 60e4f7318f..eef603752f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ python-dateutil~=2.7 pywin32; sys_platform == 'win32' PyYAML>=5.1, <7 charset-normalizer~=3.3 +tomlkit~=0.12; python_version < '3.11' diff --git a/test/data/testplugins3/example/MANIFEST.toml b/test/data/testplugins3/example/MANIFEST.toml new file mode 100644 index 0000000000..5389593806 --- /dev/null +++ b/test/data/testplugins3/example/MANIFEST.toml @@ -0,0 +1,11 @@ +name = "Example plugin" +author = "Philipp Wolfer" +description = """ +This is an example plugin showcasing the new **Picard 3 plugin** API. + +You can use [Markdown](https://daringfireball.net/projects/markdown/) for formatting.""" +version = "1.0.0" +api = ["3.0", "3.1"] +license = "CC0-1.0" +license-url = "https://creativecommons.org/publicdomain/zero/1.0/" +user-guide-url = "https://example.com/" diff --git a/test/data/testplugins3/example/__init__.py b/test/data/testplugins3/example/__init__.py new file mode 100644 index 0000000000..1c0564e5b0 --- /dev/null +++ b/test/data/testplugins3/example/__init__.py @@ -0,0 +1,12 @@ +# Basic Picard 3 plugin example + +from picard.plugin3.api import PluginApi + + +def enable(api: PluginApi) -> None: + # api can be used to register plugin hooks and to access essential Picard APIs. + pass + + +def disable() -> None: + pass diff --git a/test/test_plugins3.py b/test/test_plugins3.py new file mode 100644 index 0000000000..caf163990d --- /dev/null +++ b/test/test_plugins3.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2024 Philipp Wolfer +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest.mock import Mock + +from test.picardtestcase import ( + PicardTestCase, + get_test_data_path, +) + +from picard.config import ( + ConfigSection, + get_config, +) +from picard.plugin3.api import PluginApi +from picard.plugin3.manifest import PluginManifest +from picard.version import Version + + +def load_plugin_manifest(plugin_name: str) -> PluginManifest: + manifest_path = get_test_data_path('testplugins3', plugin_name, 'MANIFEST.toml') + with open(manifest_path, 'rb') as manifest_file: + return PluginManifest(plugin_name, manifest_file) + + +class TestPluginManifest(PicardTestCase): + + def test_load_from_toml(self): + manifest = load_plugin_manifest('example') + self.assertEqual(manifest.module_name, 'example') + self.assertEqual(manifest.name, 'Example plugin') + self.assertEqual(manifest.author, 'Philipp Wolfer') + self.assertEqual( + manifest.description.replace('\r\n', '\n'), + "This is an example plugin showcasing the new **Picard 3 plugin** API.\n\nYou can use [Markdown](https://daringfireball.net/projects/markdown/) for formatting.") + self.assertEqual(manifest.version, Version(1, 0, 0)) + self.assertEqual(manifest.api_versions, (Version(3, 0, 0), Version(3, 1, 0))) + self.assertEqual(manifest.license, 'CC0-1.0') + self.assertEqual(manifest.license_url, 'https://creativecommons.org/publicdomain/zero/1.0/') + self.assertEqual(manifest.user_guide_url, 'https://example.com/') + + +class TestPluginApi(PicardTestCase): + + def test_init(self): + manifest = load_plugin_manifest('example') + + mock_tagger = Mock() + mock_ws = mock_tagger.webservice = Mock() + + api = PluginApi(manifest, mock_tagger) + self.assertEqual(api.web_service, mock_ws) + self.assertEqual(api.logger.name, 'plugin.example') + self.assertEqual(api.global_config, get_config()) + self.assertIsInstance(api.plugin_config, ConfigSection) From f8129882f852caee7f2b656b8bdd2f5807eff7cd Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 11 Feb 2024 16:18:10 +0100 Subject: [PATCH 03/15] PICARD-1861: remove zipped plugins from current spec Document zipped plugins as an open discussion, extended the description of items open for discussion. --- PLUGINS.md | 55 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/PLUGINS.md b/PLUGINS.md index 6179467e8b..57fff62a65 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -61,10 +61,6 @@ example/ MANIFEST.toml ``` -The plugin package MAY be put into a ZIP archive. In this case the filename -must be the same as the plugin package name followed by `.picard.zip`, e.g. -`example.picard.zip`. - ### File system locations TBD @@ -128,7 +124,6 @@ The file MAY define any of the following optional fields: | Field name | Type | Description | |----------------|--------|------------------------------------------------------------------| -| extract | bool | If set to `true` the plugin must be extracted on installation (to be discussed) | | license-url | string | URL to the full license text | | user-guide-url | string | URL to the plugin's documentation | @@ -144,7 +139,6 @@ This is an example plugin showcasing the new **Picard 3 plugin** API. You can use [Markdown](https://daringfireball.net/projects/markdown/) for formatting.""" version = "1.0.0" api = ["3.0", "3.1"] -extract = true license = "CC0-1.0" license-url = "https://creativecommons.org/publicdomain/zero/1.0/" user-guide-url = "https://example.com/" @@ -281,16 +275,55 @@ class PluginApi: TBD -### Plugin live cycle +### Plugin life cycle TBD ### To be discussed -- Localization? -- Categorization? [PW-12](https://tickets.metabrainz.org/browse/PW-12) -- Extra data files? -- Additional extension points? +#### Localization +Existing plugins in Picard 2 cannot be localized. The new plugin system should +allow plugins to provide translations for user facing strings. + +Plugins could provide gettext `.mo` files that will be loaded under a plugin +specific translation domain. + +Also the description from `MANIFEST.json` should be localizable. + + +#### Categorization +See [PW-12](https://tickets.metabrainz.org/browse/PW-12) + + +#### Extra data files +Does the Plugin API need to expose functions to allow plugins to easily load +additional data files shipped as part of the plugins? E.g. for loading +configuration from JSON files. + + +#### Additional extension points +Which additional extension points should be supported? + + +#### Support for ZIP compressed plugins: +As before plugins in a single ZIP archive could also be supported. The "Format" +section above could be extended with: + +> The plugin package MAY be put into a ZIP archive. In this case the filename +> must be the same as the plugin package name followed by `.picard.zip`, e.g. +> `example.picard.zip`. + +It needs to be discussed whether such plugins should be extracted by default +or whether module loading from ZIP should be retained. + +The advantage of loading directly from ZIP is the simplicity of plugin handling, +as the user can move around a single plugin file. + +Disadvantages are: + +- Additional complexity in the module loader +- Inability of accessing shared libraries shipped as part of the plugin +- No bytecode caching ## Implementation considerations From 5bd423dc3f7ba653a23510e48718cacccefcbafd Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 11 Feb 2024 16:35:51 +0100 Subject: [PATCH 04/15] PICARD-1861: allow multilingual descriptions in plugin manifests --- PLUGINS.md | 22 +++++++++++++++----- picard/plugin3/manifest.py | 6 +++--- test/data/testplugins3/example/MANIFEST.toml | 9 ++++---- test/test_plugins3.py | 7 ++++--- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/PLUGINS.md b/PLUGINS.md index 57fff62a65..2b95082b38 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -115,7 +115,7 @@ The file MUST define the following mandatory metadata fields: |----------------|--------|------------------------------------------------------------------| | name | string | The plugin's full name | | author | string | The plugin author | -| description | string | Detailed description of the plugin. Supports Markdown formatting | +| description | table | Table of multi-lingual detailed plugin descriptions. The keys are locale names. At least an English description is mandatory. Supports Markdown formatting. | | version | string | Plugin version. Use semantic versioning in the format "x.y.z" | | api | list | The Picard API versions supported by the plugin | | license | string | License, should be a [SPDX license name](https://spdx.org/licenses/) and GPLv2 compatible | @@ -133,15 +133,27 @@ Example `MANIFEST.toml`: ```toml name = "Example plugin" author = "Philipp Wolfer" -description = """ -This is an example plugin showcasing the new **Picard 3 plugin** API. - -You can use [Markdown](https://daringfireball.net/projects/markdown/) for formatting.""" version = "1.0.0" api = ["3.0", "3.1"] license = "CC0-1.0" license-url = "https://creativecommons.org/publicdomain/zero/1.0/" user-guide-url = "https://example.com/" + +[description] +en = """ +This is an example plugin showcasing the new **Picard 3** plugin API. + +You can use [Markdown](https://daringfireball.net/projects/markdown/) for formatting.""" +de = """ +Dies ist ein Beispiel-Plugin, das die neue **Picard 3** Plugin-API vorstellt. + +Du kannst [Markdown](https://daringfireball.net/projects/markdown/) für die Formatierung verwenden. +""" +fr = """ +Ceci est un exemple de plugin présentant la nouvelle API de plugin **Picard 3**. + +Vous pouvez utiliser [Markdown](https://daringfireball.net/projects/markdown/) pour la mise en forme. +""" ``` diff --git a/picard/plugin3/manifest.py b/picard/plugin3/manifest.py index 18b365e834..8194a1f542 100644 --- a/picard/plugin3/manifest.py +++ b/picard/plugin3/manifest.py @@ -50,9 +50,9 @@ def name(self) -> str: def author(self) -> str: return self._data.get('author') - @property - def description(self) -> str: - return self._data.get('description') + def description(self, preferred_language: str = 'en') -> str: + descriptions = self._data.get('description') or {} + return descriptions.get(preferred_language, descriptions.get('en', '')) @property def version(self) -> Version: diff --git a/test/data/testplugins3/example/MANIFEST.toml b/test/data/testplugins3/example/MANIFEST.toml index 5389593806..ea2f993eb1 100644 --- a/test/data/testplugins3/example/MANIFEST.toml +++ b/test/data/testplugins3/example/MANIFEST.toml @@ -1,11 +1,12 @@ name = "Example plugin" author = "Philipp Wolfer" -description = """ -This is an example plugin showcasing the new **Picard 3 plugin** API. - -You can use [Markdown](https://daringfireball.net/projects/markdown/) for formatting.""" version = "1.0.0" api = ["3.0", "3.1"] license = "CC0-1.0" license-url = "https://creativecommons.org/publicdomain/zero/1.0/" user-guide-url = "https://example.com/" + +[description] +en = "This is an example plugin" +de = "Dies ist ein Beispiel-Plugin" +fr = "Ceci est un exemple de plugin" diff --git a/test/test_plugins3.py b/test/test_plugins3.py index caf163990d..1561a87c94 100644 --- a/test/test_plugins3.py +++ b/test/test_plugins3.py @@ -47,9 +47,10 @@ def test_load_from_toml(self): self.assertEqual(manifest.module_name, 'example') self.assertEqual(manifest.name, 'Example plugin') self.assertEqual(manifest.author, 'Philipp Wolfer') - self.assertEqual( - manifest.description.replace('\r\n', '\n'), - "This is an example plugin showcasing the new **Picard 3 plugin** API.\n\nYou can use [Markdown](https://daringfireball.net/projects/markdown/) for formatting.") + self.assertEqual(manifest.description(), "This is an example plugin") + self.assertEqual(manifest.description('en'), "This is an example plugin") + self.assertEqual(manifest.description('fr'), "Ceci est un exemple de plugin") + self.assertEqual(manifest.description('it'), "This is an example plugin") self.assertEqual(manifest.version, Version(1, 0, 0)) self.assertEqual(manifest.api_versions, (Version(3, 0, 0), Version(3, 1, 0))) self.assertEqual(manifest.license, 'CC0-1.0') From 24ed5fa17f0ba539733d258e164ef7402760b08c Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Mon, 12 Feb 2024 17:58:07 +0100 Subject: [PATCH 05/15] PICARD-1861: Updated plugin specification - documented plugin folder location - separated id and display name in manifest --- PLUGINS.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/PLUGINS.md b/PLUGINS.md index 2b95082b38..6bb1818a72 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -1,7 +1,12 @@ # Picard Plugin API v3 (Proposal) ## Introduction / Motivation -TBD + +Picard's plugin system has made Picard very extensible and there exist many +plugins that extend Picard's functionality in various ways. + +However, the current plugin system has multiple shortcomings. This document +proposes a new plugin system for Picard 3 to address those shortcomings. ## Scope @@ -63,7 +68,17 @@ example/ ### File system locations -TBD + +User plugins will be stored in a system specific location for application data +inside the `MusicBrainz/Picard/plugins3` directory. On the primary operating +systems those are: + +- **Linux:** `~/.local/share/MusicBrainz/Picard/plugins3` +- **macOS:** `~/Library/Application Support/MusicBrainz/Picard/plugins3` +- **Windows:** `~/AppData/Roaming/MusicBrainz/Picard/plugins3` + +System wide plugins will be loaded from Picard's install location from the +`plugins3` directory. ### Package structure and implemented API @@ -113,7 +128,8 @@ The file MUST define the following mandatory metadata fields: | Field name | Type | Description | |----------------|--------|------------------------------------------------------------------| -| name | string | The plugin's full name | +| id | string | The plugin's unique name. Must be a valid Python package name and only consist of the characters `[a-z0-9_]` | +| name | table | Table of multi-lingual display names. The keys are locale names. At least an English description is mandatory. | | author | string | The plugin author | | description | table | Table of multi-lingual detailed plugin descriptions. The keys are locale names. At least an English description is mandatory. Supports Markdown formatting. | | version | string | Plugin version. Use semantic versioning in the format "x.y.z" | @@ -131,7 +147,10 @@ The file MAY define any of the following optional fields: Example `MANIFEST.toml`: ```toml -name = "Example plugin" +id = "example" +name.en = "Example plugin" +name.de = "Beispiel-Plugin" +name.fr = "Exemple de plugin" author = "Philipp Wolfer" version = "1.0.0" api = ["3.0", "3.1"] From 7b71d0061ac550d58a70c4f20bb2cd0c85e66da7 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Mon, 12 Feb 2024 18:33:15 +0100 Subject: [PATCH 06/15] PICARD-1861: Added description of distribution to plugin specification --- PLUGINS.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/PLUGINS.md b/PLUGINS.md index 6bb1818a72..3ff38006e5 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -11,10 +11,14 @@ proposes a new plugin system for Picard 3 to address those shortcomings. ## Scope -This document only discusses the structure and API for Picard plugins. It does not -discuss distribution of plugins or the maintenance of a plugins repository. See -[the wiki](https://github.com/rdswift/picard-plugins/wiki/Picard-Plugins-System-Proposal) -for and extended discussion of distribution and maintenance. +This document discusses the structure and API for Picard plugins and the +basics of distributing, installing and updating plugins. + +> ***Note:** This document builds upon the extended discussion of requirements +> for a new plugin system on +> [the wiki](https://github.com/rdswift/picard-plugins/wiki/Picard-Plugins-System-Proposal). +> It proposes a specific implementation which tries to address the various ideas +> brought up in the above discussion.* ## Limitations of the old plugin system @@ -48,6 +52,13 @@ for and extended discussion of distribution and maintenance. needed to decide whether a plugin file needs to be placed at the top level or inside a directory. +- **Single central repository:** All official plugins must be located in the + official [picard-plugins](https://github.com/metabrainz/picard-plugins) git + repository. Only plugins located there can be installed directly from the UI + and can receive automated updates. This makes it difficult for third-party + developers to provide plugins and keep them updated. It also adds additional + work on the Picard developers to maintain and update all submitted plugins. + ## Format @@ -310,9 +321,62 @@ TBD TBD -### To be discussed +## Distribution + +In order to both simplify third-party development of plugins and distribute +the maintenance work there will no longer be a single plugin repository. Instead +each plugin SHOULD be provided in a separate git repository. Plugins also CAN +be installed locally without a git repository by placing the plugin package +inside the plugin directory. + + +### Repository structure + +A Picard plugin repository MUST be a git repository containing exactly one +plugin. The content of the git repository MUST match the plugin file structure +as described above, containing at least the `__init__.py` and `MANIFEST.toml` +files. + + +### Installation and upgrade + +Plugin installation is performed directly from git by cloning the git repository. +Likewise updates are performed by updating the repository and checking out the +requested git ref. + +For plugins installed from git the version will be shown as a combination of +the version from the manifest and the git ref (`{VERSION}-{GITREF}`). + + +### Official plugins + +The Picard website will provide a list of officially supported plugins and their +git location. Those plugins will be offered in the Picard user interface for +installation. Plugins can be added to the official list after a review. The +Picard website must provide an API endpoint for querying the metadata for all +the plugins. The metadata consists of both the information from the plugin +manifests and the git URL for each plugin. + +The exact implementation for submitting plugins for the Picard website is +outside the scope of this document and will be discussed separately. It could +e.g. both be handled by opening tickets on the MetaBrainz Jira or by +implementing an actual plugin submission interface directly on the Picard +website. + + +### Installing plugins from unofficial sources + +Picard must provide a user interface for installing third-party plugins which +are not provided in the official plugin list. The user needs to enter the +plugin's git URL and Picard will verify the manifest and offer to install and +activate the plugin. The UI must make it clear that the user is installing the +plugin at their own risk and that the plugin can execute arbitrary code. + + +## To be discussed + +### Localization -#### Localization Existing plugins in Picard 2 cannot be localized. The new plugin system should allow plugins to provide translations for user facing strings. @@ -322,21 +386,25 @@ specific translation domain. Also the description from `MANIFEST.json` should be localizable. -#### Categorization +### Categorization + See [PW-12](https://tickets.metabrainz.org/browse/PW-12) -#### Extra data files +### Extra data files + Does the Plugin API need to expose functions to allow plugins to easily load additional data files shipped as part of the plugins? E.g. for loading configuration from JSON files. -#### Additional extension points +### Additional extension points + Which additional extension points should be supported? -#### Support for ZIP compressed plugins: +### Support for ZIP compressed plugins: + As before plugins in a single ZIP archive could also be supported. The "Format" section above could be extended with: From a10fb1404dccb7fca32f4ce566713ff82ff2a84a Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Mon, 12 Feb 2024 18:42:16 +0100 Subject: [PATCH 07/15] PICARD-1861: Extended limitation section in plugin specification --- PLUGINS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/PLUGINS.md b/PLUGINS.md index 3ff38006e5..888094b38d 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -39,6 +39,14 @@ basics of distributing, installing and updating plugins. system that is logical if you are familiar with Picard's code base, this is not transparent to plugin developers. +- **Plugin configuration conflicts:** Plugins only have access to Picard's global + configuration. Plugins that need to store their own configuration usually try + to avoid conflicts by adding a prefix to the configuration. Yet there is no + way to remove a specific plugin's configuration. + +- **No localization:** There is no standardized way how plugins can provide + localized user interface strings. + - **Many supported plugin formats:** The old system allowed multiple ways how a plugin can be structured. The following formats where supported: - A single Python module (`example.py`) @@ -47,11 +55,16 @@ basics of distributing, installing and updating plugins. - A ZIP archive (`example.zip`) containing a Python package - A ZIP archive (`example.picard.zip`) with either a Python module or package and an additional metadata file `MANIFEST.json`. + This variation leeds to extra complexity in the implementation and increases maintenance and testing effort. It also increased complexity for users, as they needed to decide whether a plugin file needs to be placed at the top level or inside a directory. +- **Difficult to ship additional data files:** As ZIPs are most of the time + installed as ZIP archives there is no easy and consistent way to access + additional data files the plugin might want to provide. + - **Single central repository:** All official plugins must be located in the official [picard-plugins](https://github.com/metabrainz/picard-plugins) git repository. Only plugins located there can be installed directly from the UI From ac794318bb8027a095ee4387abeb56a93f1b8050 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 11 Feb 2024 17:46:48 +0100 Subject: [PATCH 08/15] PICARD-1861: Basic plugin3 functionality --- picard/const/appdirs.py | 5 +- picard/plugin3/api.py | 3 +- picard/plugin3/manager.py | 85 ++++++++++++++++++++++++++++ picard/plugin3/plugin.py | 108 ++++++++++++++++++++++++++++++++++++ picard/tagger.py | 16 +++--- requirements-macos-11.0.txt | 1 + requirements-win.txt | 1 + requirements.txt | 1 + test/test_const_appdirs.py | 6 +- 9 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 picard/plugin3/manager.py create mode 100644 picard/plugin3/plugin.py diff --git a/picard/const/appdirs.py b/picard/const/appdirs.py index 925652ab0f..8fdd999054 100644 --- a/picard/const/appdirs.py +++ b/picard/const/appdirs.py @@ -48,6 +48,5 @@ def cache_folder(): def plugin_folder(): - # FIXME: This really should be in QStandardPaths.StandardLocation.AppDataLocation instead, - # but this is a breaking change that requires data migration - return os.path.normpath(os.environ.get('PICARD_PLUGIN_DIR', os.path.join(config_folder(), 'plugins'))) + appdata_folder = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation) + return os.path.normpath(os.environ.get('PICARD_PLUGIN_DIR', os.path.join(appdata_folder, 'plugins3'))) diff --git a/picard/plugin3/api.py b/picard/plugin3/api.py index a420d47678..7f56ecef5c 100644 --- a/picard/plugin3/api.py +++ b/picard/plugin3/api.py @@ -31,7 +31,6 @@ from picard.config import ( Config, ConfigSection, - config, get_config, ) from picard.coverart.providers import ( @@ -77,7 +76,7 @@ def __init__(self, manifest: PluginManifest, tagger) -> None: self._manifest = manifest full_name = f'plugin.{self._manifest.module_name}' self._logger = getLogger(full_name) - self._api_config = ConfigSection(config, full_name) + self._api_config = ConfigSection(get_config(), full_name) @property def web_service(self) -> WebService: diff --git a/picard/plugin3/manager.py b/picard/plugin3/manager.py new file mode 100644 index 0000000000..4b76a66925 --- /dev/null +++ b/picard/plugin3/manager.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2024 Philipp Wolfer +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import os +from typing import List + +from picard import ( + api_versions_tuple, + log, +) +from picard.plugin3.plugin import Plugin + + +class PluginManager: + """Installs, loads and updates plugins from multiple plugin directories. + """ + _primary_plugin_dir: str = None + _plugin_dirs: List[str] = [] + _plugins: List[Plugin] = [] + + def __init__(self, tagger): + from picard.tagger import Tagger + self._tagger: Tagger = tagger + + def add_directory(self, dir_path: str, primary: bool = False) -> None: + log.debug('Registering plugin directory %s', dir_path) + dir_path = os.path.normpath(dir_path) + + for entry in os.scandir(dir_path): + if entry.is_dir(): + plugin = self._load_plugin(dir_path, entry.name) + if plugin: + log.debug('Found plugin %s in %s', plugin.plugin_name, plugin.local_path) + self._plugins.append(plugin) + + self._plugin_dirs.append(dir_path) + if primary: + self._primary_plugin_dir = dir_path + + def init_plugins(self): + # TODO: Only load and enable plugins enabled in configuration + for plugin in self._plugins: + try: + plugin.load_module() + plugin.enable(self._tagger) + except Exception as ex: + log.error('Failed initializing plugin %s from %s', + plugin.plugin_name, plugin.local_path, exc_info=ex) + + def _load_plugin(self, plugin_dir: str, plugin_name: str): + plugin = Plugin(plugin_dir, plugin_name) + try: + plugin.read_manifest() + # TODO: Check version compatibility + compatible_versions = _compatible_api_versions(plugin.manifest.api_versions) + if compatible_versions: + return plugin + else: + log.warning('Plugin "%s" from "%s" is not compatible with this version of Picard.', + plugin.plugin_name, plugin.local_path) + except Exception as ex: + log.warning('Could not read plugin manifest from %r', + os.path.join(plugin_dir, plugin_name), exc_info=ex) + return None + + +def _compatible_api_versions(api_versions): + return set(api_versions) & set(api_versions_tuple) diff --git a/picard/plugin3/plugin.py b/picard/plugin3/plugin.py new file mode 100644 index 0000000000..044c09d8db --- /dev/null +++ b/picard/plugin3/plugin.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2024 Laurent Monin +# Copyright (C) 2024 Philipp Wolfer +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +import importlib.util +import os +import sys + +from picard.plugin3.api import PluginApi +from picard.plugin3.manifest import PluginManifest + +import pygit2 + + +class GitRemoteCallbacks(pygit2.RemoteCallbacks): + + def transfer_progress(self, stats): + print(f'{stats.indexed_objects}/{stats.total_objects}') + + +class Plugin: + local_path: str = None + remote_url: str = None + ref = None + plugin_name: str = None + module_name: str = None + manifest: PluginManifest = None + _module = None + + def __init__(self, plugins_dir: str, plugin_name: str): + if not os.path.exists(plugins_dir): + os.makedirs(plugins_dir) + self.plugins_dir = plugins_dir + self.plugin_name = plugin_name + self.module_name = f'picard.plugins.{self.plugin_name}' + self.local_path = os.path.join(self.plugins_dir, self.plugin_name) + + def sync(self, url: str = None, ref: str = None): + """Sync plugin source + Use remote url or local path, and sets the repository to ref + """ + if url: + self.remote_url = url + if os.path.isdir(self.local_path): + print(f'{self.local_path} exists, fetch changes') + repo = pygit2.Repository(self.local_path) + for remote in repo.remotes: + remote.fetch(callbacks=GitRemoteCallbacks()) + else: + print(f'Cloning {url} to {self.local_path}') + repo = pygit2.clone_repository(url, self.local_path, callbacks=GitRemoteCallbacks()) + + print(list(repo.references)) + print(list(repo.branches)) + print(list(repo.remotes)) + + if ref: + commit = repo.revparse_single(ref) + else: + commit = repo.revparse_single('HEAD') + + print(commit) + print(commit.message) + # hard reset to passed ref or HEAD + repo.reset(commit.id, pygit2.enums.ResetMode.HARD) + + def read_manifest(self): + """Reads metadata for the plugin from the plugin's MANIFEST.toml + """ + manifest_path = os.path.join(self.local_path, 'MANIFEST.toml') + with open(manifest_path, 'rb') as manifest_file: + self.manifest = PluginManifest(self.plugin_name, manifest_file) + + def load_module(self): + """Load corresponding module from source path""" + module_file = os.path.join(self.local_path, '__init__.py') + spec = importlib.util.spec_from_file_location(self.module_name, module_file) + module = importlib.util.module_from_spec(spec) + sys.modules[self.module_name] = module + spec.loader.exec_module(module) + self._module = module + return module + + def enable(self, tagger) -> None: + """Enable the plugin""" + api = PluginApi(self.manifest, tagger) + self._module.enable(api) + + def disable(self) -> None: + """Disable the plugin""" + self._module.disable() diff --git a/picard/tagger.py b/picard/tagger.py index 2d222846ee..01cd39f848 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -95,6 +95,7 @@ ) from picard.config_upgrade import upgrade_config from picard.const import USER_DIR +from picard.const.appdirs import plugin_folder from picard.const.sys import ( IS_HAIKU, IS_MACOS, @@ -116,10 +117,8 @@ ) from picard.item import MetadataItem from picard.options import init_options -from picard.pluginmanager import ( - PluginManager, - plugin_dirs, -) +from picard.plugin3.manager import PluginManager +from picard.pluginmanager import PluginManager as LegacyPluginManager from picard.releasegroup import ReleaseGroup from picard.track import ( NonAlbumTrack, @@ -356,10 +355,12 @@ def __init__(self, picard_args, localedir, autoupdate, pipe_handler=None): self.enable_menu_icons(config.setting['show_menu_icons']) # Load plugins - self.pluginmanager = PluginManager() + # FIXME: Legacy, remove as soong no longer used by other code + self.pluginmanager = LegacyPluginManager() + + self.pluginmanager3 = PluginManager(self) if not self._no_plugins: - for plugin_dir in plugin_dirs(): - self.pluginmanager.load_plugins_from_directory(plugin_dir) + self.pluginmanager3.add_directory(plugin_folder(), primary=True) self.browser_integration = BrowserIntegration() self.browser_integration.listen_port_changed.connect(self.on_listen_port_changed) @@ -771,6 +772,7 @@ def _run_init(self): def run(self): self.update_browser_integration() self.window.show() + self.pluginmanager3.init_plugins() QtCore.QTimer.singleShot(0, self._run_init) res = self.exec() self.exit() diff --git a/requirements-macos-11.0.txt b/requirements-macos-11.0.txt index 303fc80a3a..7d4182353d 100644 --- a/requirements-macos-11.0.txt +++ b/requirements-macos-11.0.txt @@ -1,6 +1,7 @@ discid==1.2.0 Markdown==3.7 mutagen==1.47.0 +pygit2==1.17.0 PyJWT==2.10.1 pyobjc-core==10.3.2 pyobjc-framework-Cocoa==10.3.2 diff --git a/requirements-win.txt b/requirements-win.txt index ba26ecdd66..bef754a435 100644 --- a/requirements-win.txt +++ b/requirements-win.txt @@ -1,6 +1,7 @@ discid==1.2.0 Markdown==3.7 mutagen==1.47.0 +pygit2==1.17.0 PyJWT==2.10.1 PyQt6==6.8.0 PyQt6-Qt6==6.8.1 diff --git a/requirements.txt b/requirements.txt index eef603752f..f732059101 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ discid~=1.0 Markdown~=3.2 mutagen~=1.37 +pygit2~=1.17 PyJWT~=2.0 pyobjc-core>=6.2, <11; sys_platform == 'darwin' pyobjc-framework-Cocoa>=6.2, <11; sys_platform == 'darwin' diff --git a/test/test_const_appdirs.py b/test/test_const_appdirs.py index 32ac18e2aa..369880eab9 100644 --- a/test/test_const_appdirs.py +++ b/test/test_const_appdirs.py @@ -67,12 +67,12 @@ def test_cache_folder_linux(self): @unittest.skipUnless(IS_WIN, "Windows test") def test_plugin_folder_win(self): - self.assert_home_path_equals('~/AppData/Local/MusicBrainz/Picard/plugins', plugin_folder()) + self.assert_home_path_equals('~/AppData/Roaming/MusicBrainz/Picard/plugins3', plugin_folder()) @unittest.skipUnless(IS_MACOS, "macOS test") def test_plugin_folder_macos(self): - self.assert_home_path_equals('~/Library/Preferences/MusicBrainz/Picard/plugins', plugin_folder()) + self.assert_home_path_equals('~/Library/Application Support/MusicBrainz/Picard/plugins3', plugin_folder()) @unittest.skipUnless(IS_LINUX, "Linux test") def test_plugin_folder_linux(self): - self.assert_home_path_equals('~/.config/MusicBrainz/Picard/plugins', plugin_folder()) + self.assert_home_path_equals('~/.local/share/MusicBrainz/Picard/plugins3', plugin_folder()) From a7fc194a600e1aa871b8202bf6391595227fa885 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 30 Apr 2024 09:02:49 +0200 Subject: [PATCH 09/15] Some structure improvements for PLUGINS.md --- PLUGINS.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/PLUGINS.md b/PLUGINS.md index 888094b38d..51cd0d95d3 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -84,7 +84,7 @@ The package directory MAY contain additional files, such as Python modules to lo A basic plugin `example` could have the following structure: -``` +```text example/ __init__.py MANIFEST.toml @@ -144,6 +144,7 @@ the `enable` and `disable` functions and any plugin hooks registered in `enable` ### Manifest format + The plugin's package directory MUST contain a file `MANIFEST.toml`. > ***Discussion:** Is TOML the proper format, or should something like JSON or YAML be preferred?* @@ -326,11 +327,13 @@ class PluginApi: ``` -### Localization +### Localization (l10n) and internationalization (i18n) + TBD ### Plugin life cycle + TBD @@ -416,10 +419,10 @@ configuration from JSON files. Which additional extension points should be supported? -### Support for ZIP compressed plugins: +### Support for ZIP compressed plugins As before plugins in a single ZIP archive could also be supported. The "Format" -section above could be extended with: +section above could be extended with following paragraph. > The plugin package MAY be put into a ZIP archive. In this case the filename > must be the same as the plugin package name followed by `.picard.zip`, e.g. From 3ade250a024672c86d5e6d60f49df30902dd15d3 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Mon, 3 Jun 2024 11:01:41 +0200 Subject: [PATCH 10/15] Update imports (extension_points) --- picard/plugin3/api.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/picard/plugin3/api.py b/picard/plugin3/api.py index 7f56ecef5c..e94f63818e 100644 --- a/picard/plugin3/api.py +++ b/picard/plugin3/api.py @@ -33,10 +33,21 @@ ConfigSection, get_config, ) -from picard.coverart.providers import ( - CoverArtProvider, +from picard.coverart.providers import CoverArtProvider +from picard.extension_points.cover_art_providers import ( register_cover_art_provider, ) +from picard.extension_points.formats import register_format +from picard.extension_points.item_actions import ( + BaseAction, + register_album_action, + register_cluster_action, + register_clusterlist_action, + register_file_action, + register_track_action, +) +from picard.extension_points.options_pages import register_options_page +from picard.extension_points.script_functions import register_script_function from picard.file import ( File, register_file_post_addition_to_track_processor, @@ -44,29 +55,16 @@ register_file_post_removal_from_track_processor, register_file_post_save_processor, ) -from picard.formats.util import register_format from picard.metadata import ( register_album_metadata_processor, register_track_metadata_processor, ) from picard.plugin3.manifest import PluginManifest from picard.plugin import PluginPriority -from picard.script.functions import register_script_function from picard.webservice import WebService from picard.webservice.api_helpers import MBAPIHelper -from picard.ui.itemviews import ( - BaseAction, - register_album_action, - register_cluster_action, - register_clusterlist_action, - register_file_action, - register_track_action, -) -from picard.ui.options import ( - OptionsPage, - register_options_page, -) +from picard.ui.options import OptionsPage class PluginApi: From 9e910859fa10db3a69d11240597ef716bda22b42 Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Mon, 3 Jun 2024 12:55:04 +0200 Subject: [PATCH 11/15] Add tomlkit & pygit2 to test-requirements dependencies --- .github/workflows/run-tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b50d77357f..8bb215ecd0 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -66,11 +66,11 @@ jobs: os: [ubuntu-latest] python-version: ['3.9'] dependencies: [ - "PyQt6==6.5.3 PyQt6-Qt6==6.5.3 mutagen==1.37 python-dateutil==2.7 PyYAML==5.1", # minimal versions, minimum dependencies - "PyQt6>=6.5.3 mutagen~=1.37 python-dateutil~=2.7 PyYAML~=6.0 discid==1.0", - "PyQt6>=6.5.3 mutagen~=1.37 python-dateutil~=2.7 PyYAML~=6.0 python-libdiscid", - "PyQt6>=6.5.3 mutagen~=1.37 python-dateutil~=2.7 PyYAML~=6.0 charset-normalizer==2.0.6", - "PyQt6>=6.5.3 mutagen~=1.37 python-dateutil~=2.7 PyYAML~=6.0 chardet==3.0.4", + "PyQt6==6.5.3 PyQt6-Qt6==6.5.3 mutagen==1.37 python-dateutil==2.7 PyYAML==5.1 tomlkit==0.12.3 pygit2==1.14.1", # minimal versions, minimum dependencies + "PyQt6>=6.5.3 mutagen~=1.37 python-dateutil~=2.7 PyYAML~=6.0 discid==1.0 tomlkit==0.12.3 pygit2==1.14.1", + "PyQt6>=6.5.3 mutagen~=1.37 python-dateutil~=2.7 PyYAML~=6.0 python-libdiscid tomlkit==0.12.3 pygit2==1.14.1", + "PyQt6>=6.5.3 mutagen~=1.37 python-dateutil~=2.7 PyYAML~=6.0 charset-normalizer==2.0.6 tomlkit==0.12.3 pygit2==1.14.1", + "PyQt6>=6.5.3 mutagen~=1.37 python-dateutil~=2.7 PyYAML~=6.0 chardet==3.0.4 tomlkit==0.12.3 pygit2==1.14.1", ] steps: From 9a2a714f1cd71edb4035b85afede31face167e3e Mon Sep 17 00:00:00 2001 From: Laurent Monin Date: Mon, 3 Jun 2024 15:27:38 +0200 Subject: [PATCH 12/15] Introduce PluginSource, PluginSourceGit, PluginSourceLocal - they'll be used to install or update plugins - the idea is to support both raw directories and git repos from local media - and of course support for git remote repositories - PluginSources are expected to be configured by UI --- picard/plugin3/plugin.py | 83 +++++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 26 deletions(-) diff --git a/picard/plugin3/plugin.py b/picard/plugin3/plugin.py index 044c09d8db..75c4074880 100644 --- a/picard/plugin3/plugin.py +++ b/picard/plugin3/plugin.py @@ -35,6 +35,57 @@ def transfer_progress(self, stats): print(f'{stats.indexed_objects}/{stats.total_objects}') +class PluginSourceSyncError(Exception): + pass + + +class PluginSource: + """Abstract class for plugin sources""" + + def sync(self, target_directory: str): + raise NotImplementedError + + +class PluginSourceGit(PluginSource): + """Plugin is stored in a git repository, local or remote""" + def __init__(self, url: str, ref: str = None): + super().__init__() + # Note: url can be a local directory + self.url = url + self.ref = ref or 'main' + + def sync(self, target_directory: str): + if os.path.isdir(target_directory): + print(f'{target_directory} exists, fetch changes') + repo = pygit2.Repository(target_directory) + for remote in repo.remotes: + remote.fetch(callbacks=GitRemoteCallbacks()) + else: + print(f'Cloning {self.url} to {target_directory}') + repo = pygit2.clone_repository(self.url, target_directory, callbacks=GitRemoteCallbacks()) + print(list(repo.references)) + print(list(repo.branches)) + print(list(repo.remotes)) + + if self.ref: + commit = repo.revparse_single(self.ref) + else: + commit = repo.revparse_single('HEAD') + + print(commit) + print(commit.message) + # hard reset to passed ref or HEAD + repo.reset(commit.id, pygit2.enums.ResetMode.HARD) + + +class PluginSourceLocal(PluginSource): + """Plugin is stored in a local directory, but is not a git repo""" + + def sync(self, target_directory: str): + # TODO: copy tree to plugin directory (?) + pass + + class Plugin: local_path: str = None remote_url: str = None @@ -52,34 +103,14 @@ def __init__(self, plugins_dir: str, plugin_name: str): self.module_name = f'picard.plugins.{self.plugin_name}' self.local_path = os.path.join(self.plugins_dir, self.plugin_name) - def sync(self, url: str = None, ref: str = None): + def sync(self, plugin_source: PluginSource = None): """Sync plugin source - Use remote url or local path, and sets the repository to ref """ - if url: - self.remote_url = url - if os.path.isdir(self.local_path): - print(f'{self.local_path} exists, fetch changes') - repo = pygit2.Repository(self.local_path) - for remote in repo.remotes: - remote.fetch(callbacks=GitRemoteCallbacks()) - else: - print(f'Cloning {url} to {self.local_path}') - repo = pygit2.clone_repository(url, self.local_path, callbacks=GitRemoteCallbacks()) - - print(list(repo.references)) - print(list(repo.branches)) - print(list(repo.remotes)) - - if ref: - commit = repo.revparse_single(ref) - else: - commit = repo.revparse_single('HEAD') - - print(commit) - print(commit.message) - # hard reset to passed ref or HEAD - repo.reset(commit.id, pygit2.enums.ResetMode.HARD) + if plugin_source: + try: + plugin_source.sync(self.local_path) + except Exception as e: + raise PluginSourceSyncError(e) def read_manifest(self): """Reads metadata for the plugin from the plugin's MANIFEST.toml From 5b2189ef1df126c666983c101cb221f6ec75f283 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Tue, 24 Sep 2024 08:48:46 +0200 Subject: [PATCH 13/15] PICARD-1861: Adapt plugins to extension point API changes --- picard/plugin3/api.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/picard/plugin3/api.py b/picard/plugin3/api.py index e94f63818e..d13342110c 100644 --- a/picard/plugin3/api.py +++ b/picard/plugin3/api.py @@ -27,7 +27,6 @@ Type, ) -from picard.album import register_album_post_removal_processor from picard.config import ( Config, ConfigSection, @@ -37,6 +36,13 @@ from picard.extension_points.cover_art_providers import ( register_cover_art_provider, ) +from picard.extension_points.event_hooks import ( + register_album_post_removal_processor, + register_file_post_addition_to_track_processor, + register_file_post_load_processor, + register_file_post_removal_from_track_processor, + register_file_post_save_processor, +) from picard.extension_points.formats import register_format from picard.extension_points.item_actions import ( BaseAction, @@ -46,21 +52,14 @@ register_file_action, register_track_action, ) -from picard.extension_points.options_pages import register_options_page -from picard.extension_points.script_functions import register_script_function -from picard.file import ( - File, - register_file_post_addition_to_track_processor, - register_file_post_load_processor, - register_file_post_removal_from_track_processor, - register_file_post_save_processor, -) -from picard.metadata import ( +from picard.extension_points.metadata import ( register_album_metadata_processor, register_track_metadata_processor, ) +from picard.extension_points.options_pages import register_options_page +from picard.extension_points.script_functions import register_script_function +from picard.file import File from picard.plugin3.manifest import PluginManifest -from picard.plugin import PluginPriority from picard.webservice import WebService from picard.webservice.api_helpers import MBAPIHelper @@ -98,26 +97,26 @@ def plugin_config(self) -> ConfigSection: return self._api_config # Metadata processors - def register_album_metadata_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + def register_album_metadata_processor(function: Callable, priority: int = 0) -> None: return register_album_metadata_processor(function, priority) - def register_track_metadata_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + def register_track_metadata_processor(function: Callable, priority: int = 0) -> None: return register_track_metadata_processor(function, priority) # Event hooks - def register_album_post_removal_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + def register_album_post_removal_processor(function: Callable, priority: int = 0) -> None: return register_album_post_removal_processor(function, priority) - def register_file_post_load_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + def register_file_post_load_processor(function: Callable, priority: int = 0) -> None: return register_file_post_load_processor(function, priority) - def register_file_post_addition_to_track_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + def register_file_post_addition_to_track_processor(function: Callable, priority: int = 0) -> None: return register_file_post_addition_to_track_processor(function, priority) - def register_file_post_removal_from_track_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + def register_file_post_removal_from_track_processor(function: Callable, priority: int = 0) -> None: return register_file_post_removal_from_track_processor(function, priority) - def register_file_post_save_processor(function: Callable, priority: PluginPriority = PluginPriority.NORMAL) -> None: + def register_file_post_save_processor(function: Callable, priority: int = 0) -> None: return register_file_post_save_processor(function, priority) # Cover art From 95b9030aabd7894f7e7a0a24a6b8f90425dee403 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Wed, 25 Sep 2024 10:45:35 +0530 Subject: [PATCH 14/15] PICARD-1861: Plugins doc changes after discussion --- PLUGINS.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/PLUGINS.md b/PLUGINS.md index 51cd0d95d3..a3ea025c66 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -153,11 +153,11 @@ The file MUST define the following mandatory metadata fields: | Field name | Type | Description | |----------------|--------|------------------------------------------------------------------| -| id | string | The plugin's unique name. Must be a valid Python package name and only consist of the characters `[a-z0-9_]` | +| ~~id~~ | ~~string~~ | ~~The plugin's unique name. Must be a valid Python package name and only consist of the characters `[a-z0-9_]`~~ | | name | table | Table of multi-lingual display names. The keys are locale names. At least an English description is mandatory. | -| author | string | The plugin author | +| authors | string[] | The plugin author | | description | table | Table of multi-lingual detailed plugin descriptions. The keys are locale names. At least an English description is mandatory. Supports Markdown formatting. | -| version | string | Plugin version. Use semantic versioning in the format "x.y.z" | +| ~~version~~ | ~~string~~ | ~~Plugin version. Use semantic versioning in the format "x.y.z"~~ | | api | list | The Picard API versions supported by the plugin | | license | string | License, should be a [SPDX license name](https://spdx.org/licenses/) and GPLv2 compatible | @@ -389,6 +389,32 @@ activate the plugin. The UI must make it clear that the user is installing the plugin at their own risk and that the plugin can execute arbitrary code. +### Blacklisting plugins +TBD + + +## Plugin management + +Picard will provide a command line interface and a options user interface to +manage plugins. + +### Command line interface + +``` +picard plugin list +picard plugin install https://git.sr.ht/~phw/picard-plugin-example +picard plugin info https://git.sr.ht/~phw/picard-plugin-example +picard plugin uninstall ... +picard plugin enable ... +picard plugin disable ... +``` + +Plugins can be referenced by repository URI or by `{uri-hash}-{last-path-part}`. +E.g. the plugin can be referenced by +`https://git.sr.ht/~phw/picard-plugin-example` or by +`0c43dd9b75eebb260a83e6ac57b4128f-picard-plugin-example`. + + ## To be discussed ### Localization From 26d94775e3336a772cf692340caa3fb3253bbc17 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Wed, 15 Jan 2025 07:39:03 +0100 Subject: [PATCH 15/15] PICARD-1861: Remove old plugin options page --- picard/ui/options/dialog.py | 1 - picard/ui/options/plugins.py | 732 -------------------------------- test/test_ui_options_plugins.py | 33 -- 3 files changed, 766 deletions(-) delete mode 100644 picard/ui/options/plugins.py delete mode 100644 test/test_ui_options_plugins.py diff --git a/picard/ui/options/dialog.py b/picard/ui/options/dialog.py index bc193ec2b1..99f643a9ff 100644 --- a/picard/ui/options/dialog.py +++ b/picard/ui/options/dialog.py @@ -83,7 +83,6 @@ matching, metadata, network, - plugins, profiles, ratings, releases, diff --git a/picard/ui/options/plugins.py b/picard/ui/options/plugins.py deleted file mode 100644 index ecb70e61eb..0000000000 --- a/picard/ui/options/plugins.py +++ /dev/null @@ -1,732 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Picard, the next-generation MusicBrainz tagger -# -# Copyright (C) 2007 Lukáš Lalinský -# Copyright (C) 2009 Carlin Mangar -# Copyright (C) 2009, 2018-2023 Philipp Wolfer -# Copyright (C) 2011-2013 Michael Wiencek -# Copyright (C) 2013, 2015, 2018-2024 Laurent Monin -# Copyright (C) 2013, 2017 Sophist-UK -# Copyright (C) 2014 Shadab Zafar -# Copyright (C) 2015, 2017 Wieland Hoffmann -# Copyright (C) 2016-2018 Sambhav Kothari -# Copyright (C) 2017 Suhas -# Copyright (C) 2018 Vishal Choudhary -# Copyright (C) 2018 yagyanshbhatia -# Copyright (C) 2023 tuspar -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - - -from functools import partial -from html import escape -from operator import attrgetter -import os.path -import re - -from PyQt6 import ( - QtCore, - QtGui, - QtWidgets, -) -from PyQt6.QtWidgets import QTreeWidgetItemIterator - -from picard import log -from picard.config import get_config -from picard.const import ( - PLUGINS_API, - USER_PLUGIN_DIR, -) -from picard.extension_points.options_pages import register_options_page -from picard.i18n import ( - N_, - gettext as _, -) -from picard.util import ( - icontheme, - open_local_path, - reconnect, -) - -from picard.ui import HashableTreeWidgetItem -from picard.ui.forms.ui_options_plugins import Ui_PluginsOptionsPage -from picard.ui.options import OptionsPage -from picard.ui.theme import theme -from picard.ui.util import FileDialog - - -COLUMN_NAME, COLUMN_VERSION, COLUMN_ACTIONS = range(3) - - -class PluginActionButton(QtWidgets.QToolButton): - - def __init__(self, icon=None, tooltip=None, retain_space=False, - switch_method=None, parent=None): - super().__init__(parent=parent) - if tooltip is not None: - self.setToolTip(tooltip) - - if icon is not None: - self.setIcon(self, icon) - - if retain_space is True: - sp_retain = self.sizePolicy() - sp_retain.setRetainSizeWhenHidden(True) - self.setSizePolicy(sp_retain) - if switch_method is not None: - self.switch_method = switch_method - - def mode(self, mode): - if self.switch_method is not None: - self.switch_method(self, mode) - - def setIcon(self, icon): - super().setIcon(icon) - # Workaround for Qt sometimes not updating the icon. - # See https://tickets.metabrainz.org/browse/PICARD-1647 - self.repaint() - - -class PluginTreeWidgetItem(HashableTreeWidgetItem): - - def __init__(self, icons, *args, **kwargs): - super().__init__(*args, **kwargs) - self._icons = icons - self._sortData = {} - self.upgrade_to_version = None - self.new_version = None - self.is_enabled = False - self.is_installed = False - self.installed_font = None - self.enabled_font = None - self.available_font = None - - self.buttons = {} - self.buttons_widget = QtWidgets.QWidget() - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(0, 2, 5, 2) - layout.addStretch(1) - self.buttons_widget.setLayout(layout) - - def add_button(name, method): - button = PluginActionButton(switch_method=method) - layout.addWidget(button) - self.buttons[name] = button - button.mode('hide') - - add_button('update', self.show_update) - add_button('uninstall', self.show_uninstall) - add_button('enable', self.show_enable) - add_button('install', self.show_install) - - self.treeWidget().setItemWidget(self, COLUMN_ACTIONS, - self.buttons_widget) - - def show_install(self, button, mode): - if mode == 'hide': - button.hide() - else: - button.show() - button.setToolTip(_("Download and install plugin")) - button.setIcon(self._icons['download']) - - def show_update(self, button, mode): - if mode == 'hide': - button.hide() - else: - button.show() - button.setToolTip(_("Download and upgrade plugin to version %s") % self.new_version.short_str()) - button.setIcon(self._icons['update']) - - def show_enable(self, button, mode): - if mode == 'enabled': - button.show() - button.setToolTip(_("Enabled")) - button.setIcon(self._icons['enabled']) - elif mode == 'disabled': - button.show() - button.setToolTip(_("Disabled")) - button.setIcon(self._icons['disabled']) - else: - button.hide() - - def show_uninstall(self, button, mode): - if mode == 'hide': - button.hide() - else: - button.show() - button.setToolTip(_("Uninstall plugin")) - button.setIcon(self._icons['uninstall']) - - def save_state(self): - return { - 'is_enabled': self.is_enabled, - 'upgrade_to_version': self.upgrade_to_version, - 'new_version': self.new_version, - 'is_installed': self.is_installed, - } - - def restore_state(self, states): - self.upgrade_to_version = states['upgrade_to_version'] - self.new_version = states['new_version'] - self.is_enabled = states['is_enabled'] - self.is_installed = states['is_installed'] - - def __lt__(self, other): - if not isinstance(other, PluginTreeWidgetItem): - return super().__lt__(other) - - tree = self.treeWidget() - if not tree: - column = 0 - else: - column = tree.sortColumn() - - return self.sortData(column) < other.sortData(column) - - def sortData(self, column): - return self._sortData.get(column, self.text(column)) - - def setSortData(self, column, data): - self._sortData[column] = data - - @property - def plugin(self): - return self.data(COLUMN_NAME, QtCore.Qt.ItemDataRole.UserRole) - - def enable(self, boolean, greyout=None): - if boolean is not None: - self.is_enabled = boolean - if self.is_enabled: - self.buttons['enable'].mode('enabled') - else: - self.buttons['enable'].mode('disabled') - if greyout is not None: - self.buttons['enable'].setEnabled(not greyout) - - -class PluginsOptionsPage(OptionsPage): - - NAME = 'plugins' - TITLE = N_("Plugins") - PARENT = None - SORT_ORDER = 70 - ACTIVE = True - HELP_URL = "/config/options_plugins.html" - - def __init__(self, parent=None): - super().__init__(parent=parent) - self.ui = Ui_PluginsOptionsPage() - self.ui.setupUi(self) - plugins = self.ui.plugins - - # fix for PICARD-1226, QT bug (https://bugreports.qt.io/browse/QTBUG-22572) workaround - plugins.setStyleSheet('') - - plugins.itemSelectionChanged.connect(self.change_details) - plugins.mimeTypes = self.mimeTypes - plugins.dropEvent = self.dropEvent - plugins.dragEnterEvent = self.dragEnterEvent - plugins.dragMoveEvent = self.dragMoveEvent - - self.ui.install_plugin.clicked.connect(self.open_plugins) - self.ui.folder_open.clicked.connect(self.open_plugin_dir) - self.ui.reload_list_of_plugins.clicked.connect(self.reload_list_of_plugins) - - self.manager = self.tagger.pluginmanager - self.manager.plugin_installed.connect(self.plugin_installed) - self.manager.plugin_updated.connect(self.plugin_updated) - self.manager.plugin_removed.connect(self.plugin_removed) - self.manager.plugin_errored.connect(self.plugin_loading_error) - - self._preserve = {} - self._preserve_selected = None - - self.icons = { - 'download': self.create_icon('plugin-download'), - 'update': self.create_icon('plugin-update'), - 'uninstall': self.create_icon('plugin-uninstall'), - 'enabled': self.create_icon('plugin-enabled'), - 'disabled': self.create_icon('plugin-disabled'), - } - - def create_icon(self, icon_name): - if theme.is_dark_theme: - icon_name += '-dark' - return icontheme.lookup(icon_name, icontheme.ICON_SIZE_MENU) - - def items(self): - iterator = QTreeWidgetItemIterator(self.ui.plugins, QTreeWidgetItemIterator.IteratorFlag.All) - while iterator.value(): - item = iterator.value() - iterator += 1 - yield item - - def find_item_by_plugin_name(self, plugin_name): - for item in self.items(): - if plugin_name == item.plugin.module_name: - return item - return None - - def selected_item(self): - try: - return self.ui.plugins.selectedItems()[COLUMN_NAME] - except IndexError: - return None - - def save_state(self): - header = self.ui.plugins.header() - config = get_config() - config.persist['plugins_list_state'] = header.saveState() - config.persist['plugins_list_sort_section'] = header.sortIndicatorSection() - config.persist['plugins_list_sort_order'] = header.sortIndicatorOrder() - - def set_current_item(self, item, scroll=False): - if scroll: - self.ui.plugins.scrollToItem(item) - self.ui.plugins.setCurrentItem(item) - self.refresh_details(item) - - def restore_state(self): - header = self.ui.plugins.header() - config = get_config() - header.restoreState(config.persist['plugins_list_state']) - idx = config.persist['plugins_list_sort_section'] - order = config.persist['plugins_list_sort_order'] - header.setSortIndicator(idx, order) - self.ui.plugins.sortByColumn(idx, order) - - @staticmethod - def is_plugin_enabled(plugin): - config = get_config() - return bool(plugin.module_name in config.setting['enabled_plugins']) - - def available_plugins_name_version(self): - return {p.module_name: p.version for p in self.manager.available_plugins} - - def installable_plugins(self): - if self.manager.available_plugins is not None: - installed_plugins = [plugin.module_name for plugin in - self.installed_plugins()] - for plugin in sorted(self.manager.available_plugins, - key=attrgetter('name')): - if plugin.module_name not in installed_plugins: - yield plugin - - def installed_plugins(self): - return sorted(self.manager.plugins, key=attrgetter('name')) - - def enabled_plugins(self): - return [item.plugin.module_name for item in self.items() if item.is_enabled] - - def _populate(self): - self._user_interaction(False) - if self.manager.available_plugins is None: - available_plugins = {} - self.manager.query_available_plugins(self._reload) - else: - available_plugins = self.available_plugins_name_version() - - self.ui.details.setText("") - - self.ui.plugins.setSortingEnabled(False) - for plugin in self.installed_plugins(): - new_version = None - if plugin.module_name in available_plugins: - latest = available_plugins[plugin.module_name] - if latest > plugin.version: - new_version = latest - self.update_plugin_item(None, plugin, - enabled=self.is_plugin_enabled(plugin), - new_version=new_version, - is_installed=True - ) - - for plugin in self.installable_plugins(): - self.update_plugin_item(None, plugin, enabled=False, - is_installed=False) - - self.ui.plugins.setSortingEnabled(True) - self._user_interaction(True) - header = self.ui.plugins.header() - header.setStretchLastSection(False) - header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeMode.Fixed) - header.setSectionResizeMode(COLUMN_NAME, QtWidgets.QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(COLUMN_VERSION, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(COLUMN_ACTIONS, QtWidgets.QHeaderView.ResizeMode.ResizeToContents) - - def _remove_all(self): - for item in self.items(): - idx = self.ui.plugins.indexOfTopLevelItem(item) - self.ui.plugins.takeTopLevelItem(idx) - - def restore_defaults(self): - self._user_interaction(False) - self._remove_all() - super().restore_defaults() - self.set_current_item(self.ui.plugins.topLevelItem(0), scroll=True) - - def load(self): - self._populate() - self.restore_state() - - def _preserve_plugins_states(self): - self._preserve = {item.plugin.module_name: item.save_state() for item in self.items()} - item = self.selected_item() - if item: - self._preserve_selected = item.plugin.module_name - else: - self._preserve_selected = None - - def _restore_plugins_states(self): - for item in self.items(): - plugin = item.plugin - if plugin.module_name in self._preserve: - item.restore_state(self._preserve[plugin.module_name]) - if self._preserve_selected == plugin.module_name: - self.set_current_item(item, scroll=True) - - def _reload(self): - if self.deleted: - return - self._remove_all() - self._populate() - self._restore_plugins_states() - - def _user_interaction(self, enabled): - self.ui.plugins.blockSignals(not enabled) - self.ui.plugins_container.setEnabled(enabled) - - def reload_list_of_plugins(self): - self.ui.details.setText(_("Reloading list of available plugins…")) - self._user_interaction(False) - self._preserve_plugins_states() - self.manager.query_available_plugins(callback=self._reload) - - def plugin_loading_error(self, plugin_name, error): - QtWidgets.QMessageBox.critical( - self, - _('Plugin "%(plugin)s"') % {'plugin': plugin_name}, - _('An error occurred while loading the plugin "%(plugin)s":\n\n%(error)s') % { - 'plugin': plugin_name, - 'error': error, - }) - - def plugin_installed(self, plugin): - log.debug("Plugin %r installed", plugin.name) - if not plugin.compatible: - params = {'plugin': plugin.name} - QtWidgets.QMessageBox.warning( - self, - _('Plugin "%(plugin)s"') % params, - _('The plugin "%(plugin)s" is not compatible with this version of Picard.') % params - ) - return - item = self.find_item_by_plugin_name(plugin.module_name) - if item: - self.update_plugin_item(item, plugin, make_current=True, - enabled=True, is_installed=True) - else: - self._reload() - item = self.find_item_by_plugin_name(plugin.module_name) - if item: - self.set_current_item(item, scroll=True) - - def plugin_updated(self, plugin_name): - log.debug("Plugin %r updated", plugin_name) - item = self.find_item_by_plugin_name(plugin_name) - if item: - plugin = item.plugin - QtWidgets.QMessageBox.information( - self, - _('Plugin "%(plugin)s"') % {'plugin': plugin_name}, - _('The plugin "%(plugin)s" will be upgraded to version %(version)s on next run of Picard.') % { - 'plugin': plugin.name, - 'version': item.new_version.short_str(), - }) - - item.upgrade_to_version = item.new_version - self.update_plugin_item(item, plugin, make_current=True) - - def plugin_removed(self, plugin_name): - log.debug("Plugin %r removed", plugin_name) - item = self.find_item_by_plugin_name(plugin_name) - if item: - if self.manager.is_available(plugin_name): - self.update_plugin_item(item, None, make_current=True, - is_installed=False) - else: # Remove local plugin - self.ui.plugins.invisibleRootItem().removeChild(item) - - def uninstall_plugin(self, item): - plugin = item.plugin - params = {'plugin': plugin.name} - buttonReply = QtWidgets.QMessageBox.question( - self, - _('Uninstall plugin "%(plugin)s"?') % params, - _('Do you really want to uninstall the plugin "%(plugin)s"?') % params, - QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, - QtWidgets.QMessageBox.StandardButton.No - ) - if buttonReply == QtWidgets.QMessageBox.StandardButton.Yes: - self.manager.remove_plugin(plugin.module_name, with_update=True) - - def update_plugin_item(self, item, plugin, - make_current=False, - enabled=None, - new_version=None, - is_installed=None - ): - if item is None: - item = PluginTreeWidgetItem(self.icons, self.ui.plugins) - if plugin is not None: - item.setData(COLUMN_NAME, QtCore.Qt.ItemDataRole.UserRole, plugin) - else: - plugin = item.plugin - if new_version is not None: - item.new_version = new_version - if is_installed is not None: - item.is_installed = is_installed - if enabled is None: - enabled = item.is_enabled - - def update_text(): - if item.new_version is not None: - version = "%s → %s" % (plugin.version.short_str(), - item.new_version.short_str()) - else: - version = plugin.version.short_str() - - if item.installed_font is None: - item.installed_font = item.font(COLUMN_NAME) - if item.enabled_font is None: - item.enabled_font = QtGui.QFont(item.installed_font) - item.enabled_font.setBold(True) - if item.available_font is None: - item.available_font = QtGui.QFont(item.installed_font) - - if item.is_enabled: - item.setFont(COLUMN_NAME, item.enabled_font) - else: - if item.is_installed: - item.setFont(COLUMN_NAME, item.installed_font) - else: - item.setFont(COLUMN_NAME, item.available_font) - - item.setText(COLUMN_NAME, plugin.name) - item.setText(COLUMN_VERSION, version) - - def toggle_enable(): - item.enable(not item.is_enabled, greyout=not item.is_installed) - log.debug("Plugin %r enabled: %r", item.plugin.name, item.is_enabled) - update_text() - - reconnect(item.buttons['enable'].clicked, toggle_enable) - - install_enabled = not item.is_installed or bool(item.new_version) - if item.upgrade_to_version: - if item.upgrade_to_version != item.new_version: - # case when a new version is known after a plugin was marked for update - install_enabled = True - else: - install_enabled = False - - if install_enabled: - if item.new_version is not None: - def download_and_update(): - self.download_plugin(item, update=True) - - reconnect(item.buttons['update'].clicked, download_and_update) - item.buttons['install'].mode('hide') - item.buttons['update'].mode('show') - else: - def download_and_install(): - self.download_plugin(item) - - reconnect(item.buttons['install'].clicked, download_and_install) - item.buttons['install'].mode('show') - item.buttons['update'].mode('hide') - - if item.is_installed: - item.buttons['install'].mode('hide') - item.buttons['uninstall'].mode( - 'show' if plugin.is_user_installed else 'hide') - item.enable(enabled, greyout=False) - - def uninstall_processor(): - self.uninstall_plugin(item) - - reconnect(item.buttons['uninstall'].clicked, uninstall_processor) - else: - item.buttons['uninstall'].mode('hide') - item.enable(False) - item.buttons['enable'].mode('hide') - - update_text() - - if make_current: - self.set_current_item(item) - - actions_sort_score = 2 - if item.is_installed: - if item.is_enabled: - actions_sort_score = 0 - else: - actions_sort_score = 1 - - item.setSortData(COLUMN_ACTIONS, actions_sort_score) - item.setSortData(COLUMN_NAME, plugin.name.lower()) - - def v2int(elem): - try: - return int(elem) - except ValueError: - return 0 - item.setSortData(COLUMN_VERSION, plugin.version) - - return item - - def save(self): - config = get_config() - config.setting['enabled_plugins'] = self.enabled_plugins() - self.save_state() - - def refresh_details(self, item): - plugin = item.plugin - text = [] - if item.new_version is not None: - if item.upgrade_to_version: - label = _("Restart Picard to upgrade to new version") - else: - label = _("New version available") - version_str = item.new_version.short_str() - text.append("{0}: {1}".format(label, version_str)) - if plugin.description: - text.append(plugin.description + "
") - infos = [ - (_("Name"), escape(plugin.name)), - (_("Authors"), self.link_authors(plugin.author)), - (_("License"), plugin.license), - (_("Files"), escape(plugin.files_list)), - (_("User Guide"), self.link_user_guide(plugin.user_guide_url)), - ] - for label, value in infos: - if value: - text.append("{0}: {1}".format(label, value)) - self.ui.details.setText("

{0}

".format("
\n".join(text))) - - @staticmethod - def link_authors(authors): - formatted_authors = [] - re_author = re.compile(r"(?P.*?)\s*<(?P.*?@.*?)>") - for author in authors.split(','): - author = author.strip() - match_ = re_author.fullmatch(author) - if match_: - author_str = '{author}'.format( - email=escape(match_['email']), - author=escape(match_['author']), - ) - formatted_authors.append(author_str) - else: - formatted_authors.append(escape(author)) - return ', '.join(formatted_authors) - - @staticmethod - def link_user_guide(user_guide): - if user_guide: - user_guide = '{url}'.format( - url=escape(user_guide) - ) - return user_guide - - def change_details(self): - item = self.selected_item() - if item: - self.refresh_details(item) - - def open_plugins(self): - files, _filter = FileDialog.getOpenFileNames( - parent=self, - dir=QtCore.QDir.homePath(), - filter="Picard plugin (*.py *.pyc *.zip)", - ) - if files: - for path in files: - self.manager.install_plugin(path) - - def download_plugin(self, item, update=False): - plugin = item.plugin - - self.tagger.webservice.get_url( - url=PLUGINS_API['urls']['download'], - handler=partial(self.download_handler, update, plugin=plugin), - parse_response_type=None, - priority=True, - important=True, - unencoded_queryargs={'id': plugin.module_name, 'version': plugin.version.short_str()}, - ) - - def download_handler(self, update, response, reply, error, plugin): - if self.deleted: - return - if error: - params = {'plugin': plugin.module_name} - msgbox = QtWidgets.QMessageBox(self) - msgbox.setText(_('The plugin "%(plugin)s" could not be downloaded.') % params) - msgbox.setInformativeText(_("Please try again later.")) - msgbox.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok) - msgbox.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Ok) - msgbox.exec() - log.error('Error occurred while trying to download the plugin: "%(plugin)s"', params) - return - - self.manager.install_plugin( - None, - update=update, - plugin_name=plugin.module_name, - plugin_data=response, - ) - - @staticmethod - def open_plugin_dir(): - open_local_path(USER_PLUGIN_DIR) - - def mimeTypes(self): - return ['text/uri-list'] - - def dragEnterEvent(self, event): - event.setDropAction(QtCore.Qt.DropAction.CopyAction) - event.accept() - - def dragMoveEvent(self, event): - event.setDropAction(QtCore.Qt.DropAction.CopyAction) - event.accept() - - def dropEvent(self, event): - if event.proposedAction() == QtCore.Qt.DropAction.IgnoreAction: - event.acceptProposedAction() - return - - for path in (os.path.normpath(u.toLocalFile()) for u in event.mimeData().urls()): - self.manager.install_plugin(path) - - event.setDropAction(QtCore.Qt.DropAction.CopyAction) - event.accept() - - -register_options_page(PluginsOptionsPage) diff --git a/test/test_ui_options_plugins.py b/test/test_ui_options_plugins.py deleted file mode 100644 index 11c2c1f230..0000000000 --- a/test/test_ui_options_plugins.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Picard, the next-generation MusicBrainz tagger -# -# Copyright (C) 2022 Philipp Wolfer -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - - -from test.picardtestcase import PicardTestCase - -from picard.ui.options.plugins import PluginsOptionsPage - - -class PluginsOptionsPageTest(PicardTestCase): - - def test_link_authors(self): - self.assertEqual( - 'Wile E. Coyote, Road <Runner>', - PluginsOptionsPage.link_authors('Wile E. Coyote , Road '), - )