Skip to content
27 changes: 18 additions & 9 deletions src/npe2/manifest/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 13 additions & 6 deletions src/npe2/manifest/contributions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
"<ul><li> a secure (https) URL </li>"
"<li>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)"
"<li> 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))</li></ul>",
)
enablement: str | None = Field(
None,
Expand Down
25 changes: 18 additions & 7 deletions src/npe2/manifest/contributions/_submenu.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:"
"<ul><li> a secure (https) URL </li>"
"<li>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)"
"<li> 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))</li></ul>",
)
23 changes: 14 additions & 9 deletions src/npe2/manifest/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)
Expand Down Expand Up @@ -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: "
"<ul><li>a secure (https) URL </li>"
"<li>a path relative to the manifest file, (must be shipped in the sdist)</li>"
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:"
"<ul><li> a secure (https) URL </li>"
"<li>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`).</li></ul>",
"`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)"
"<li> 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))</li></ul>",
)

categories: list[Category] = Field(
default_factory=list,
description="A list of categories that this plugin belongs to. This is used to "
Expand Down
15 changes: 14 additions & 1 deletion tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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():
Expand Down
Loading