Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
13 changes: 0 additions & 13 deletions src/npe2/manifest/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,3 @@ def display_name(v: str) -> str:
"non-word character."
)
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
17 changes: 12 additions & 5 deletions src/npe2/manifest/contributions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,18 @@ class CommandContribution(BaseModel):
)
icon: str | Icon | None = Field(
Comment thread
brisvag marked this conversation as resolved.
Outdated
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.files(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
17 changes: 12 additions & 5 deletions src/npe2/manifest/contributions/_submenu.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@ class SubmenuContribution(BaseModel):
)
icon: str | Icon | None = Field(
Comment thread
brisvag marked this conversation as resolved.
Outdated
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.files(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>",
)
43 changes: 34 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: str | Icon | None = 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.files(package, resource)` "
Comment thread
brisvag marked this conversation as resolved.
Outdated
"(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 Expand Up @@ -245,6 +250,26 @@ def is_visible(self) -> bool:
def _coerce_none_contributions(cls, value):
return ContributionPoints() if value is None else value

@field_validator("icon", mode="after")
def _coerce_icon(cls, value):
if isinstance(value, str) and value.startswith("http"):
if not value.startswith("https://"):
raise ValueError(
f"{value} is not a valid icon URL. It must start with 'https://'"
)
if isinstance(value, 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

@model_validator(mode="before")
@classmethod
def _validate_root(cls, values: dict) -> dict:
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