diff --git a/npe2/__init__.py b/npe2/__init__.py index d62037df..2c30c5a3 100644 --- a/npe2/__init__.py +++ b/npe2/__init__.py @@ -5,6 +5,7 @@ __author__ = "Talley Lambert" __email__ = "talley.lambert@gmail.com" + from ._dynamic_plugin import DynamicPlugin from ._inspection._fetch import fetch_manifest, get_manifest_from_wheel from ._plugin_manager import PluginContext, PluginManager diff --git a/npe2/_inspection/_compile.py b/npe2/_inspection/_compile.py index 6bb3f62e..3c3d645d 100644 --- a/npe2/_inspection/_compile.py +++ b/npe2/_inspection/_compile.py @@ -2,7 +2,7 @@ from typing import Iterator, List, Sequence, Tuple, Union, cast from ..manifest import PluginManifest, contributions -from ..manifest.utils import merge_contributions +from ..manifest.utils import merge_contributions, merge_manifests from ._setuputils import get_package_dir_info from ._visitors import find_npe2_module_contributions @@ -21,6 +21,7 @@ def compile( dest: Union[str, Path, None] = None, packages: Sequence[str] = (), plugin_name: str = "", + template: Union[str, Path, None] = None, ) -> PluginManifest: """Compile plugin manifest from `src_dir`, where is a top-level repo. @@ -31,6 +32,20 @@ def compile( ---------- src_dir : Union[str, Path] Repo root. Should contain a pyproject or setup.cfg file. + dest : Union[str, Path, None] + If provided, path where output manifest should be written. + packages : Sequence[str] + List of packages to include in the manifest. By default, all packages + (subfolders that have an `__init__.py`) will be included. + plugin_name : str + Name of the plugin. If not provided, the name will be derived from the + package structure (this assumes a setuptools package.) + template : Union[str, Path, None] + If provided, path to a template manifest file to use. This file can contain + "non-command" contributions, like `display_name`, or `themes`, etc... + In the case of conflicts (discovered, decoratated contributions with the same + id as something in the template), discovered contributions will take + precedence. Returns ------- @@ -38,7 +53,6 @@ def compile( Manifest including all discovered contribution points, combined with any existing contributions explicitly stated in the manifest. """ - src_path = Path(src_dir) assert src_path.exists(), f"src_dir {src_dir} does not exist" @@ -50,6 +64,9 @@ def compile( f"dest {dest!r} must have an extension of .json, .yaml, or .toml" ) + if template is not None: + template_mf = PluginManifest.from_file(template) + _packages = find_packages(src_path) if packages: _packages = [p for p in _packages if p.name in packages] @@ -75,6 +92,9 @@ def compile( contributions=merge_contributions(contribs), ) + if template is not None: + mf = merge_manifests([template_mf, mf], overwrite=True) + if dest is not None: manifest_string = getattr(mf, cast(str, suffix))(indent=2) pdest.write_text(manifest_string) diff --git a/npe2/_inspection/_full_install.py b/npe2/_inspection/_full_install.py index ee08da84..32862ee1 100644 --- a/npe2/_inspection/_full_install.py +++ b/npe2/_inspection/_full_install.py @@ -12,9 +12,9 @@ from logging import getLogger from typing import TYPE_CHECKING, Iterator, Optional -from build.env import IsolatedEnv, IsolatedEnvBuilder - if TYPE_CHECKING: + from build.env import IsolatedEnv + from npe2.manifest import PluginManifest logger = getLogger(__name__) @@ -56,6 +56,10 @@ def isolated_plugin_env( build.env.IsolatedEnv env object that has an `install` method. """ + # it's important that this import be lazy, otherwise we'll get a circular + # import when serving as a setuptools plugin with `python -m build` + from build.env import IsolatedEnvBuilder + with IsolatedEnvBuilder() as env: # install the package pkg = f"{package}=={version}" if version else package diff --git a/npe2/_setuptools_plugin.py b/npe2/_setuptools_plugin.py new file mode 100644 index 00000000..9a614648 --- /dev/null +++ b/npe2/_setuptools_plugin.py @@ -0,0 +1,199 @@ +""" +# pyproject.toml +[build-system] +requires = ["setuptools", "wheel", "setuptools_scm", "npe2"] +build-backend = "setuptools.build_meta" + +[tool.npe2] +""" +from __future__ import annotations + +import os +import re +import sys +import warnings +from typing import TYPE_CHECKING, Optional, Tuple, cast + +from setuptools import Distribution +from setuptools.command.build_py import build_py + +if TYPE_CHECKING: + from distutils.cmd import Command + from typing import Any, Union + + PathT = Union["os.PathLike[str]", str] + +NPE2_ENTRY = "napari.manifest" +DEBUG = bool(os.environ.get("SETUPTOOLS_NPE2_DEBUG")) +EP_PATTERN = re.compile( + r"(?P[\w.]+)\s*(:\s*(?P[\w.]+)\s*)?((?P\[.*\])\s*)?$" +) + + +def trace(*k: object) -> None: + if DEBUG: + print(*k, file=sys.stderr, flush=True) + + +def _lazy_tomli_load(data: str) -> dict[str, Any]: + from pytomlpp import loads + + return loads(data) + + +def _read_dist_name_from_setup_cfg() -> str | None: + # minimal effort to read dist_name off setup.cfg metadata + import configparser + + parser = configparser.ConfigParser() + parser.read(["setup.cfg"]) + return parser.get("metadata", "name", fallback=None) + + +def _check_absolute_root(root: PathT, relative_to: PathT | None) -> str: + trace("abs root", repr(locals())) + if relative_to: + if ( + os.path.isabs(root) + and os.path.commonpath([root, relative_to]) != relative_to + ): + warnings.warn( + f"absolute root path '{root}' overrides relative_to '{relative_to}'" + ) + if os.path.isdir(relative_to): + warnings.warn( + "relative_to is expected to be a file," + " its the directory {relative_to!r}\n" + "assuming the parent directory was passed" + ) + trace("dir", relative_to) + root = os.path.join(relative_to, root) + else: + trace("file", relative_to) + root = os.path.join(os.path.dirname(relative_to), root) + return os.path.abspath(root) + + +class Configuration: + """Global configuration model""" + + def __init__( + self, + relative_to: PathT | None = None, + root: PathT = ".", + write_to: PathT | None = None, + write_to_template: str | None = None, + dist_name: str | None = None, + template: str | None = None, + ): + self._relative_to = None if relative_to is None else os.fspath(relative_to) + self._root = "." + self.root = os.fspath(root) + self.write_to = write_to + self.write_to_template = write_to_template + self.dist_name = dist_name + self.template = template + + @property + def relative_to(self) -> str | None: + return self._relative_to + + @property + def root(self) -> str: + return self._root + + @root.setter + def root(self, value: PathT) -> None: + self._absolute_root = _check_absolute_root(value, self._relative_to) + self._root = os.fspath(value) + trace("root", repr(self._absolute_root)) + trace("relative_to", repr(self._relative_to)) + + @property + def absolute_root(self) -> str: + return self._absolute_root + + @classmethod + def from_file( + cls, name: str = "pyproject.toml", dist_name: str | None = None, **kwargs: Any + ) -> Configuration: + """ + Read Configuration from pyproject.toml (or similar). + Raises exceptions when file is not found or toml is + not installed or the file has invalid format or does + not contain the [tool.npe2] section. + """ + + with open(name, encoding="UTF-8") as strm: + data = strm.read() + defn = _lazy_tomli_load(data) + try: + section = defn.get("tool", {})["npe2"] + except LookupError as e: + raise LookupError(f"{name} does not contain a tool.npe2 section") from e + if "dist_name" in section: + if dist_name is None: + dist_name = section.pop("dist_name") + else: + assert dist_name == section["dist_name"] + del section["dist_name"] + if dist_name is None and "project" in defn: + # minimal pep 621 support for figuring the pretend keys + dist_name = defn["project"].get("name") + if dist_name is None: + dist_name = _read_dist_name_from_setup_cfg() + + return cls(dist_name=dist_name, **section, **kwargs) + + +def _mf_entry_from_dist(dist: Distribution) -> Optional[Tuple[str, str]]: + """Return (module, attr) for a distribution's npe2 entry point.""" + eps: dict = getattr(dist, "entry_points", {}) + if napari_entrys := eps.get(NPE2_ENTRY, []): + if match := EP_PATTERN.search(napari_entrys[0]): + return match.group("module"), match.group("attr") + return None + + +class npe2_compile(build_py): + def run(self) -> None: + trace("RUN npe2_compile") + if ep := _mf_entry_from_dist(self.distribution): + from npe2._inspection._compile import compile + + module, attr = ep + src = self.distribution.src_root or os.getcwd() + dest = os.path.join(self.get_package_dir(module), attr) + compile(src, dest, template=self.distribution.config.template) + else: + name = self.distribution.metadata.name + trace(f"no {NPE2_ENTRY!r} found in entry_points for {name}") + + +def finalize_npe2(dist: Distribution): + # this hook is declared in the setuptools.finalize_distribution_options + # entry point in our setup.cfg + # https://setuptools.pypa.io/en/latest/userguide/extension.html#customizing-distribution-options + trace("finalize hook", vars(dist.metadata)) + dist_name = dist.metadata.name + if dist_name is None: + dist_name = _read_dist_name_from_setup_cfg() + if not os.path.isfile("pyproject.toml"): + return + if dist_name == "npe2": + # if we're packaging npe2 itself, don't do anything + return + try: + # config will *only* be detected if there is a [tool.npe2] + # section in pyproject.toml. This is how plugins opt in + # to the npe2 compile feature during build + config = Configuration.from_file(dist_name=dist_name) + except LookupError as e: + trace(e) + else: + # inject our `npe2_compile` command to be called whenever we're building an + # sdist or a wheel + dist.config = config + for cmd in ("build", "sdist"): + if base := dist.get_command_class(cmd): + cast("Command", base).sub_commands.insert(0, ("npe2_compile", None)) diff --git a/npe2/manifest/utils.py b/npe2/manifest/utils.py index 7e4c5a49..a73e0ab0 100644 --- a/npe2/manifest/utils.py +++ b/npe2/manifest/utils.py @@ -288,9 +288,10 @@ def merge_manifests( assert ( len({mf.package_version for mf in manifests}) == 1 ), "All manifests must have same version" - assert ( - len({mf.display_name for mf in manifests}) == 1 - ), "All manifests must have same display_name" + if not overwrite: + assert ( + len({mf.display_name for mf in manifests}) == 1 + ), "All manifests must have same display_name" mf0 = manifests[0] info = mf0.dict(exclude={"contributions"}, exclude_unset=True) diff --git a/setup.cfg b/setup.cfg index ce111826..a7ae5c11 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,8 +40,12 @@ zip_safe = False [options.entry_points] console_scripts = npe2 = npe2.cli:main +distutils.commands = + npe2_compile = npe2._setuptools_plugin:npe2_compile pytest11 = npe2 = npe2._pytest_plugin +setuptools.finalize_distribution_options = + finalize_npe2 = npe2._setuptools_plugin:finalize_npe2 [options.extras_require] dev = @@ -111,6 +115,9 @@ omit = npe2/manifest/contributions/_keybindings.py npe2/manifest/menus.py npe2/manifest/package_metadata.py + # due to all of the isolated sub-environments and sub-processes, + # it's really hard to get coverage on the setuptools plugin. + npe2/_setuptools_plugin.py [coverage:report] exclude_lines = diff --git a/tests/fixtures/my-compiled-plugin/setup.cfg b/tests/fixtures/my-compiled-plugin/setup.cfg index 7221b7b4..1189b101 100644 --- a/tests/fixtures/my-compiled-plugin/setup.cfg +++ b/tests/fixtures/my-compiled-plugin/setup.cfg @@ -5,3 +5,6 @@ version = 0.1.0 [options.entry_points] napari.manifest = my-compiled-plugin = my_module:napari.yaml + +[options.package_data] +my_module = *.yaml diff --git a/tests/test_compile.py b/tests/test_compile.py index ce92ddca..678ef4e4 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -20,3 +20,12 @@ def test_compile(compiled_plugin_dir: Path, tmp_path: Path): assert mf.contributions.commands and len(mf.contributions.commands) == 5 assert dest.exists() assert PluginManifest.from_file(dest) == mf + + +def test_compile_with_template(compiled_plugin_dir: Path, tmp_path: Path): + """Test building from a template with npe2 compile.""" + template = tmp_path / "template.yaml" + template.write_text("name: my_compiled_plugin\ndisplay_name: Display Name\n") + mf = compile(compiled_plugin_dir, template=template) + assert mf.name == "my_compiled_plugin" + assert mf.display_name == "Display Name" diff --git a/tests/test_setuptools_plugin.py b/tests/test_setuptools_plugin.py new file mode 100644 index 00000000..d039cf1b --- /dev/null +++ b/tests/test_setuptools_plugin.py @@ -0,0 +1,61 @@ +import os +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +from npe2 import PluginManifest + +ROOT = Path(__file__).parent.parent + +TEMPLATE = Path("my_module") / "_napari.yaml" +PYPROJECT = """ +[build-system] +requires = ["setuptools", "wheel", "npe2 @ file://{}"] +build-backend = "setuptools.build_meta" + +[tool.npe2] +template="{}" +""".format( + ROOT, TEMPLATE +) + + +@pytest.mark.skipif(not os.getenv("CI"), reason="slow, only run on CI") +@pytest.mark.parametrize("dist_type", ["sdist", "wheel"]) +def test_compile(compiled_plugin_dir: Path, tmp_path: Path, dist_type: str) -> None: + """ + Test that the plugin manager can be compiled. + """ + pyproject = compiled_plugin_dir / "pyproject.toml" + pyproject.write_text(PYPROJECT) + + template = compiled_plugin_dir / TEMPLATE + template.write_text("name: my_compiled_plugin\ndisplay_name: My Compiled Plugin\n") + os.chdir(compiled_plugin_dir) + subprocess.check_call([sys.executable, "-m", "build", f"--{dist_type}"]) + dist_dir = compiled_plugin_dir / "dist" + assert dist_dir.is_dir() + if dist_type == "sdist": + # for sdist, test pip install into a temporary directory + # and make sure the compiled manifest is there + dist = next(dist_dir.glob("*.tar.gz")) + site = tmp_path / "site" + subprocess.check_call( + [sys.executable, "-m", "pip", "install", str(dist), "--target", str(site)] + ) + mf_file = site / "my_module" / "napari.yaml" + else: + # for wheel, make sure that the manifest is included in the wheel + dist = next(dist_dir.glob("*.whl")) + with zipfile.ZipFile(dist) as zip: + zip.extractall(dist_dir) + mf_file = dist_dir / "my_module" / "napari.yaml" + + assert mf_file.exists() + mf = PluginManifest.from_file(mf_file) + assert mf.display_name == "My Compiled Plugin" + assert len(mf.contributions.readers) == 1 + assert len(mf.contributions.writers) == 2