diff --git a/src/npe2/manifest/_validators.py b/src/npe2/manifest/_validators.py index 2b6235b1..d37c9e72 100644 --- a/src/npe2/manifest/_validators.py +++ b/src/npe2/manifest/_validators.py @@ -67,14 +67,23 @@ def display_name(v: str) -> str: return v -def icon_path(v: str) -> str: - if not v: - return "" - if v.startswith("http"): - if not v.startswith("https://"): +def coerce_icon(cls, value): + if value is None: + return None + if isinstance(value, str) and value.startswith("http"): + if not value.startswith("https://"): raise ValueError( - f"{v} is not a valid icon URL. It must start with 'https://'" + f"{value} is not a valid icon URL. It must start with 'https://'" ) - return v - assert isinstance(v, str), f"{v} must be a string" - return v + # aftervalidator, so it's guaranteed to be of type Icon + if value.light is not None and value.light.startswith("http"): + if not value.light.startswith("https://"): + raise ValueError( + f"{value.light} is not a valid icon URL. It must start with 'https://'" + ) + if value.dark is not None and value.dark.startswith("http"): + if not value.dark.startswith("https://"): + raise ValueError( + f"{value.dark} is not a valid icon URL. It must start with 'https://'" + ) + return value diff --git a/src/npe2/manifest/contributions/_commands.py b/src/npe2/manifest/contributions/_commands.py index 7f6dfafc..ad3180ba 100644 --- a/src/npe2/manifest/contributions/_commands.py +++ b/src/npe2/manifest/contributions/_commands.py @@ -71,13 +71,20 @@ class CommandContribution(BaseModel): None, description="Category string by which the command may be grouped in the UI.", ) - icon: str | Icon | None = Field( + icon: Annotated[str | Icon | None, AfterValidator(_validators.coerce_icon)] = Field( None, - description="Icon used to represent this command in the UI, on " - "buttons or in menus. These may be [superqt](https://github.com/napari/superqt)" - " fonticon keys, such as `'fa6s.arrow_down'`; though note that plugins are " - "expected to depend on any fonticon libraries they use, e.g " - "[fonticon-fontawesome6](https://github.com/tlambert03/fonticon-fontawesome6).", + description="Icon used to represent this command in the UI, on" + " buttons or in menus. Can be a single string or two different options" + " for light and dark themes. These values may be:" + "", ) enablement: str | None = Field( None, diff --git a/src/npe2/manifest/contributions/_submenu.py b/src/npe2/manifest/contributions/_submenu.py index 797b3dba..71a084d0 100644 --- a/src/npe2/manifest/contributions/_submenu.py +++ b/src/npe2/manifest/contributions/_submenu.py @@ -1,4 +1,8 @@ -from pydantic import BaseModel, Field +from typing import Annotated + +from pydantic import AfterValidator, BaseModel, Field + +from npe2.manifest import _validators from ._icon import Icon @@ -15,11 +19,18 @@ class SubmenuContribution(BaseModel): label: str = Field( description="The label of the menu item which leads to this submenu." ) - icon: str | Icon | None = Field( + icon: Annotated[str | Icon | None, AfterValidator(_validators.coerce_icon)] = Field( None, - description=( - "(Optional) Icon which is used to represent the command in the UI." - " Either a file path, an object with file paths for dark and light" - "themes, or a theme icon references, like `$(zap)`" - ), + description="Icon used to represent this submenu in the UI, on" + " buttons or in menus. Can be a single string or two different options" + " for light and dark themes. These values may be:" + "", ) diff --git a/src/npe2/manifest/schema.py b/src/npe2/manifest/schema.py index 686b273c..6ec1693f 100644 --- a/src/npe2/manifest/schema.py +++ b/src/npe2/manifest/schema.py @@ -27,6 +27,7 @@ from ._bases import ImportExportModel from ._package_metadata import PackageMetadata from .contributions import ContributionPoints +from .contributions._icon import Icon from .utils import Executable, Version __all__ = ("Category",) @@ -122,17 +123,21 @@ class PluginManifest(ImportExportModel): "results, change this to `'hidden'`.", ) - icon: Annotated[str, AfterValidator(_validators.icon_path)] = Field( - "", - description="The path to a square PNG icon of at least 128x128 pixels (256x256 " - "for Retina screens). May be one of: " - "", ) - categories: list[Category] = Field( default_factory=list, description="A list of categories that this plugin belongs to. This is used to " diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 1628cb62..6f189984 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -7,6 +7,7 @@ from npe2 import PluginManifest from npe2.manifest import PackageMetadata +from npe2.manifest.contributions._icon import Icon from npe2.manifest.schema import ENTRY_POINT SAMPLE_PLUGIN_NAME = "my-plugin" @@ -182,7 +183,19 @@ def test_visibility(): def test_icon(): - PluginManifest(name="myplugin", icon="my_plugin:myicon.png") + pm = PluginManifest(name="myplugin", icon="my_plugin:myicon.png") + assert pm.icon == "my_plugin:myicon.png" + pm = PluginManifest(name="myplugin", icon="https://example.com/icon.png") + assert pm.icon == "https://example.com/icon.png" + with pytest.raises(ValueError, match="not a valid icon URL"): + pm = PluginManifest(name="myplugin", icon="http://example.com/bad_icon.png") + pm = PluginManifest( + name="myplugin", + icon={"dark": "my_plugin:myicon.png", "light": "https://example.com/icon.png"}, + ) + assert isinstance(pm.icon, Icon) + assert pm.icon.dark == "my_plugin:myicon.png" + assert pm.icon.light == "https://example.com/icon.png" def test_dotted_plugin_name():