Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add setuptools plugin to compile manifest at build #194

Merged
merged 81 commits into from
Dec 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
bb5e67f
working decorators!
tlambert03 Dec 21, 2021
86d8ab4
change sample plugin name
tlambert03 Dec 21, 2021
bc8134c
Merge branch 'change-sample-plugin-name' into decorators
tlambert03 Dec 21, 2021
30861e1
move and simplify sample
tlambert03 Dec 21, 2021
fb27028
add tests
tlambert03 Dec 21, 2021
72d1c3b
add import test
tlambert03 Dec 21, 2021
044558d
add pragma
tlambert03 Dec 21, 2021
869f8f6
Merge branch 'main' into decorators
tlambert03 Mar 15, 2022
e1b9340
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 15, 2022
7a26548
Merge branch 'main' into decorators
tlambert03 Jun 12, 2022
0b4cd00
visit returns contrib points
tlambert03 Jun 12, 2022
de85e2c
more docs
tlambert03 Jun 12, 2022
9f4d302
add type stubs
tlambert03 Jun 12, 2022
30e6255
remove excludes from setup
tlambert03 Jun 12, 2022
fb9cff9
fix tests
tlambert03 Jun 12, 2022
c6d9f89
add compile func
tlambert03 Jun 12, 2022
2f8a804
Merge branch 'main' into decorators
tlambert03 Jun 13, 2022
5284e9c
Merge branch 'main' into decorators
tlambert03 Jun 13, 2022
d50eaf9
Merge branch 'decorators' into compile
tlambert03 Jun 20, 2022
986b8ca
Merge branch 'main' into decorators
tlambert03 Jun 20, 2022
cf01c2a
Merge branch 'decorators' into compile
tlambert03 Jun 20, 2022
da12d61
Merge branch 'main' into compile
tlambert03 Jun 21, 2022
786b87f
add __all__
tlambert03 Jun 21, 2022
615faea
adding comments support
tlambert03 Jun 21, 2022
2a8c46b
add ensure_args_valid = False
tlambert03 Jun 21, 2022
b5574f6
suppress any potential errors
tlambert03 Jun 21, 2022
44e0e42
Merge branch 'update-implements' into compile
tlambert03 Jun 21, 2022
c11fb2f
add import
tlambert03 Jun 21, 2022
82a0442
Merge branch 'main' into compile
tlambert03 Jun 21, 2022
5237ebf
remove comments code, add type_checking example
tlambert03 Jun 21, 2022
670e0d8
Merge branch 'main' into compile
tlambert03 Jun 22, 2022
4c3d642
fix command merge
tlambert03 Jun 22, 2022
6aa4aee
wip
tlambert03 Jun 22, 2022
e045782
wip playing
tlambert03 Jun 22, 2022
da73a38
use setuptools-scm pattern
tlambert03 Jun 22, 2022
fbe652f
basic setuptools plugin
tlambert03 Jun 22, 2022
ec50368
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 22, 2022
29a00e3
add back fetch
tlambert03 Jun 22, 2022
56c2557
Merge branch 'setuptools-compile-plugin' of https://github.com/tlambe…
tlambert03 Jun 22, 2022
05f88d4
Merge branch 'main' into compile
tlambert03 Jun 22, 2022
e27a72f
remove comments arg
tlambert03 Jun 22, 2022
ef121b3
make theme module level
tlambert03 Jun 22, 2022
d40f1eb
fix typing
tlambert03 Jun 23, 2022
ac24345
Merge branch 'main' into compile
tlambert03 Jun 29, 2022
6e4577d
Merge branch 'compile' into setuptools-compile-plugin
tlambert03 Jun 29, 2022
ce9ca83
Merge branch 'setuptools-compile-plugin' of https://github.com/tlambe…
tlambert03 Jun 29, 2022
5cd47cf
wip
tlambert03 Jun 29, 2022
81e4bf3
merge in setuptools changes
tlambert03 Jun 29, 2022
04cb85c
wip
tlambert03 Jun 29, 2022
91da686
Merge branch 'main' into compile
tlambert03 Jul 11, 2022
0418f0f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 11, 2022
f443112
Merge branch 'compile' of https://github.com/tlambert03/npe2 into com…
tlambert03 Jul 11, 2022
2e8e225
test: more tests
tlambert03 Jul 11, 2022
ba7cfb9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 11, 2022
b049706
Merge branch 'main' into compile
tlambert03 Jul 19, 2022
a307485
Merge branch 'compile' of https://github.com/tlambert03/npe2 into com…
tlambert03 Jul 19, 2022
c27afc0
Merge branch 'compile' of https://github.com/tlambert03/npe2 into com…
tlambert03 Jul 21, 2022
0e28f75
fix test
tlambert03 Jul 21, 2022
85fcadf
more tests
tlambert03 Jul 21, 2022
d6ed000
Merge branch 'main' into compile
tlambert03 Aug 3, 2022
ff9fcc7
Merge branch 'compile' into setuptools-compile-plugin
tlambert03 Aug 3, 2022
d687c24
Merge branch 'main' into compile
tlambert03 Aug 3, 2022
d9cfd94
Merge branch 'compile' into setuptools-compile-plugin
tlambert03 Aug 3, 2022
3c57d30
Merge branch 'main' into compile
tlambert03 Aug 3, 2022
8639244
Merge branch 'main' into compile
tlambert03 Aug 3, 2022
9c9eaf0
rearrange
tlambert03 Aug 3, 2022
473ed85
Merge branch 'compile' of https://github.com/tlambert03/npe2 into com…
tlambert03 Aug 3, 2022
8d2cc82
Merge branch 'compile' into setuptools-compile-plugin
tlambert03 Aug 3, 2022
4d60981
Merge branch 'main' into setuptools-compile-plugin
tlambert03 Aug 3, 2022
fba8402
Merge branch 'main' into setuptools-compile-plugin
tlambert03 Aug 6, 2022
a815581
undo makefile change
tlambert03 Aug 6, 2022
3dc315b
fix some build issues, add template
tlambert03 Aug 7, 2022
407734c
style: [pre-commit.ci] auto fixes [...]
pre-commit-ci[bot] Aug 7, 2022
f4599aa
add a test
tlambert03 Aug 7, 2022
fca7e68
Merge branch 'setuptools-compile-plugin' of https://github.com/tlambe…
tlambert03 Aug 7, 2022
59b885c
Merge branch 'main' into setuptools-compile-plugin
tlambert03 Aug 7, 2022
8def1d9
add compile from template test
tlambert03 Aug 8, 2022
aae1d18
Merge branch 'setuptools-compile-plugin' of https://github.com/tlambe…
tlambert03 Aug 8, 2022
514f0d8
update docstring
tlambert03 Aug 8, 2022
9c26f2f
Merge branch 'main' into setuptools-compile-plugin
tlambert03 Aug 8, 2022
26aa856
Merge branch 'main' into setuptools-compile-plugin
tlambert03 Dec 16, 2022
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
1 change: 1 addition & 0 deletions npe2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions npe2/_inspection/_compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand All @@ -31,14 +32,27 @@ 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
-------
PluginManifest
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"

Expand All @@ -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]
Expand All @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions npe2/_inspection/_full_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down
199 changes: 199 additions & 0 deletions npe2/_setuptools_plugin.py
Original file line number Diff line number Diff line change
@@ -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<module>[\w.]+)\s*(:\s*(?P<attr>[\w.]+)\s*)?((?P<extras>\[.*\])\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))
7 changes: 4 additions & 3 deletions npe2/manifest/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 =
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/my-compiled-plugin/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions tests/test_compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading