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:"
+ "
- a secure (https) URL
"
+ "- a string in the format `{package}:{resource}`, where `package` and "
+ "`resource` are arguments to `importlib.resources.path(package, resource)` "
+ "(e.g. `my_plugin.some_module:my_logo.png`). This resource must be "
+ "shipped with the sdist)"
+ "
- a [superqt](https://github.com/napari/superqt) fonticon key, 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))
",
)
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:"
+ "- a secure (https) URL
"
+ "- a string in the format `{package}:{resource}`, where `package` and "
+ "`resource` are arguments to `importlib.resources.path(package, resource)` "
+ "(e.g. `my_plugin.some_module:my_logo.png`). This resource must be "
+ "shipped with the sdist)"
+ "
- a [superqt](https://github.com/napari/superqt) fonticon key, 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))
",
)
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: "
- "- a secure (https) URL
"
- "- a path relative to the manifest file, (must be shipped in the sdist)
"
+ icon: Annotated[str | Icon | None, AfterValidator(_validators.coerce_icon)] = Field(
+ None,
+ description="Icon used to represent this plugin 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:"
+ "- a secure (https) URL
"
"- a string in the format `{package}:{resource}`, where `package` and "
- "`resource` are arguments to `importlib.resources.path(package, resource)`, "
- "(e.g. `top_module.some_folder:my_logo.png`).
",
+ "`resource` are arguments to `importlib.resources.path(package, resource)` "
+ "(e.g. `my_plugin.some_module:my_logo.png`). This resource must be "
+ "shipped with the sdist)"
+ "- a [superqt](https://github.com/napari/superqt) fonticon key, 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))
",
)
-
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():