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

feat: add entry point loader #1009

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ alabaster==0.7.12
# Testing
pytest-relaxed>=2
pytest-cov>=4
# XXX due to find_packages
./tests/_support/entry_point; python_version>='3.7'
# Formatting
# Flake8 5.x seems to have an odd importlib-metadata incompatibility?
flake8>=4,<5
Expand Down
17 changes: 17 additions & 0 deletions invoke/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .util import Lexicon, helpline

from .config import merge_dicts, copy_dict
from .loader import EntryPointLoader
from .parser import Context as ParserContext
from .tasks import Task

Expand Down Expand Up @@ -123,6 +124,9 @@ def _add_object(self, obj: Any, name: Optional[str] = None) -> None:
raise TypeError("No idea how to insert {!r}!".format(type(obj)))
method(obj, name=name)

def __str__(self) -> str:
return self.name or 'root'

def __repr__(self) -> str:
task_names = list(self.tasks.keys())
collections = ["{}...".format(x) for x in self.collections.keys()]
Expand All @@ -142,6 +146,19 @@ def __eq__(self, other: object) -> bool:
def __bool__(self) -> bool:
return bool(self.task_names)

@classmethod
def from_entry_point(
cls, group: str, name: str, **kwargs: Any
) -> 'Collection':
"""Load collection stack from entrypoint."""
loader = EntryPointLoader(group=group)
collection = loader.load(name)[0]
if collection.name is None:
collection.name = name
if kwargs:
collection.configure(kwargs)
return collection

@classmethod
def from_module(
cls,
Expand Down
130 changes: 86 additions & 44 deletions invoke/loader.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import os
import sys
from importlib.machinery import ModuleSpec
from importlib.metadata import entry_points
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from types import ModuleType
from typing import Any, Optional, Tuple
from typing import Any, Iterable, Optional, Tuple, Union

from . import Config
from .config import Config
from .exceptions import CollectionNotFound
from .util import debug

Expand All @@ -18,7 +17,9 @@ class Loader:
.. versionadded:: 1.0
"""

def __init__(self, config: Optional["Config"] = None) -> None:
def __init__(
self, config: Optional["Config"] = None, **kwargs: Any
) -> None:
"""
Set up a new loader with some `.Config`.

Expand All @@ -27,17 +28,15 @@ def __init__(self, config: Optional["Config"] = None) -> None:
config options. Defaults to an anonymous ``Config()`` if none is
given.
"""
if config is None:
config = Config()
self.config = config
self.config = Config() if config is None else config

def find(self, name: str) -> Optional[ModuleSpec]:
def find(self, name: str) -> Optional[Union[Iterable[Any], Any]]:
"""
Implementation-specific finder method seeking collection ``name``.

Must return a ModuleSpec valid for use by `importlib`, which is
typically a name string followed by the contents of the 3-tuple
returned by `importlib.module_from_spec` (``name``, ``loader``,
Must return module valid for use by repsective ``load`` implementation,
which is typically a name string followed by the contents of the
3-tuple returned by `importlib.module_from_spec` (``name``, ``loader``,
``origin``.)

For a sample implementation, see `.FilesystemLoader`.
Expand All @@ -46,7 +45,7 @@ def find(self, name: str) -> Optional[ModuleSpec]:
"""
raise NotImplementedError

def load(self, name: Optional[str] = None) -> Tuple[ModuleType, str]:
def load(self, name: Optional[str] = None) -> Tuple[Any, str]:
"""
Load and return collection module identified by ``name``.

Expand All @@ -65,40 +64,12 @@ def load(self, name: Optional[str] = None) -> Tuple[ModuleType, str]:

.. versionadded:: 1.0
"""
if name is None:
name = self.config.tasks.collection_name
spec = self.find(name)
if spec and spec.loader and spec.origin:
# Typically either tasks.py or tasks/__init__.py
source_file = Path(spec.origin)
# Will be 'the dir tasks.py is in', or 'tasks/', in both cases this
# is what wants to be in sys.path for "from . import sibling"
enclosing_dir = source_file.parent
# Will be "the directory above the spot that 'import tasks' found",
# namely the parent of "your task tree", i.e. "where project level
# config files are looked for". So, same as enclosing_dir for
# tasks.py, but one more level up for tasks/__init__.py...
module_parent = enclosing_dir
if spec.parent: # it's a package, so we have to go up again
module_parent = module_parent.parent
# Get the enclosing dir on the path
enclosing_str = str(enclosing_dir)
if enclosing_str not in sys.path:
sys.path.insert(0, enclosing_str)
# Actual import
module = module_from_spec(spec)
sys.modules[spec.name] = module # so 'from . import xxx' works
spec.loader.exec_module(module)
# Return the module and the folder it was found in
return module, str(module_parent)
msg = "ImportError loading {!r}, raising ImportError"
debug(msg.format(name))
raise ImportError
raise NotImplementedError


class FilesystemLoader(Loader):
"""
Loads Python files from the filesystem (e.g. ``tasks.py``.)
Loads Python files from the filesystem (e.g. ``tasks.py``).

Searches recursively towards filesystem root from a given start point.

Expand All @@ -120,7 +91,7 @@ def start(self) -> str:
# Lazily determine default CWD if configured value is falsey
return self._start or os.getcwd()

def find(self, name: str) -> Optional[ModuleSpec]:
def find(self, name: str) -> Optional[Any]:
debug("FilesystemLoader find starting at {!r}".format(self.start))
spec = None
module = "{}.py".format(name)
Expand Down Expand Up @@ -152,3 +123,74 @@ def find(self, name: str) -> Optional[ModuleSpec]:
debug(msg.format(name))
raise CollectionNotFound(name=name, start=self.start)
return None

def load(self, name: Optional[str] = None) -> Tuple[Any, str]:
if name is None:
name = self.config.tasks.collection_name
spec = self.find(name)
if spec and spec.loader and spec.origin:
# Typically either tasks.py or tasks/__init__.py
source_file = Path(spec.origin)
# Will be 'the dir tasks.py is in', or 'tasks/', in both cases this
# is what wants to be in sys.path for "from . import sibling"
enclosing_dir = source_file.parent
# Will be "the directory above the spot that 'import tasks' found",
# namely the parent of "your task tree", i.e. "where project level
# config files are looked for". So, same as enclosing_dir for
# tasks.py, but one more level up for tasks/__init__.py...
module_parent = enclosing_dir
if spec.parent: # it is a package, so we have to go up again
module_parent = module_parent.parent
# Get the enclosing dir on the path
enclosing_str = str(enclosing_dir)
if enclosing_str not in sys.path:
sys.path.insert(0, enclosing_str)
# Actual import
module = module_from_spec(spec)
sys.modules[spec.name] = module # so 'from . import xxx' works
spec.loader.exec_module(module)
# Return the module and the folder it was found in
return module, str(module_parent)
msg = "ImportError loading {!r}, raising ImportError"
debug(msg.format(name))
raise ImportError


class EntryPointLoader(Loader):
"""Load collections from entry point."""

def __init__(self, config: Optional[Config] = None, **kwargs: Any) -> None:
"""Initialize entry point plugins for invoke."""
self.group = kwargs.pop('group', None)
super().__init__(config, **kwargs)

def find(
self, name: Optional[str] = None
) -> Union[Iterable[Any], Any]:
"""Find entrypoints for invoke."""
params = {}
if name:
params['name'] = name
if self.group:
params['group'] = self.group
modules = entry_points(**params)
if modules:
return modules
elif self.group:
if name:
raise CollectionNotFound(
name=name or 'entry_point', start=self.group
)
else:
raise ModuleNotFoundError(
'no entry point matching group %r found',
self.group,
)
raise ModuleNotFoundError('no entry points found')

def load(self, name: Optional[str] = None) -> Tuple[Any, str]:
"""Load entrypoints for invoke."""
modules = self.find(name)
for module in modules:
return (module.load(), os.getcwd())
raise ImportError
15 changes: 15 additions & 0 deletions tests/_support/entry_point/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[build-system]
requires = ['setuptools>=59.4.0', 'wheel']
build-backend = 'setuptools.build_meta'

[project]
name = "invoke-example"
version = "0.1.0"
description = "Example project using entry point with invoke."
requires-python = ">=3.7"
dependencies = [
"invoke"
]

[project.entry-points."invoke"]
test = "example.module:namespace"
Loading