Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions src/npe2/manifest/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,20 @@ 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://"):
raise ValueError(
f"{v} 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
def _ensure_valid_https(value):
if value.startswith("http") and not value.startswith("https://"):
raise ValueError(
f"{value} is not a valid icon URL. It must start with 'https://'"
)


def coerce_icon(value):
if value is None:
return None
if isinstance(value, str):
_ensure_valid_https(value)
if light_url := getattr(value, "light", None):
_ensure_valid_https(light_url)
if dark_url := getattr(value, "dark", None):
_ensure_valid_https(dark_url)
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")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that we should allow remote icons in plugins. This is a big privacy issue.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this is a really important point, it let's someone track things, like the 1px by 1px white image in your emails.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I agree. I was just trying to keep previous "behaviour" :)

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