"""
Built-in extensions for PyScaffold.
"""
import argparse
import sys
import textwrap
from typing import Callable, Iterable, List, Optional, Type
from ..actions import Action, register, unregister
from ..exceptions import ErrorLoadingExtension
from ..identification import dasherize, deterministic_sort, underscore
if sys.version_info[:2] >= (3, 8):
# TODO: Import directly (no need for conditional) when `python_requires = >= 3.8`
from importlib.metadata import EntryPoint, entry_points # pragma: no cover
else:
from importlib_metadata import EntryPoint, entry_points # pragma: no cover
ENTRYPOINT_GROUP = "pyscaffold.cli"
[docs]class Extension:
"""Base class for PyScaffold's extensions
Args:
name (str): How the extension should be named. Default: name of class
By default, this value is used to create the activation flag in
PyScaffold cli.
See our docs on how to create extensions in:
https://pyscaffold.org/en/latest/extensions.html
Also check :obj:`~pyscaffold.actions`, :obj:`~pyscaffold.structure.Structure` and
:obj:`~pyscaffold.operations.ScaffoldOpts` for more details.
Note:
Please name your class using a CamelCase version of the name you use in the
setuptools entrypoint (alternatively you will need to overwrite the ``name``
property to match the entrypoint name).
"""
persist = True
"""When ``True`` PyScaffold will store the extension in the PyScaffold's section of
``setup.cfg``. Useful for updates. Set to ``False`` if the extension should not be
re-invoked on updates.
"""
def __init__(self, name: Optional[str] = None):
self._name = name or underscore(self.__class__.__name__)
@property
def name(self):
return self._name
@property
def flag(self) -> str:
return f"--{dasherize(self.name)}"
@property
def help_text(self) -> str:
if self.__doc__ is None:
raise NotImplementedError("Please provide a help text for your extension")
doc = textwrap.dedent(self.__doc__)
return doc[0].lower() + doc[1:]
[docs] def augment_cli(self, parser: argparse.ArgumentParser):
"""Augments the command-line interface parser.
A command line argument ``--FLAG`` where FLAG=``self.name`` is added
which appends ``self.activate`` to the list of extensions. As help
text the docstring of the extension class is used.
In most cases this method does not need to be overwritten.
Args:
parser: current parser object
"""
parser.add_argument(
self.flag,
dest="extensions",
action="append_const",
const=self,
help=self.help_text,
)
return self
[docs] def activate(self, actions: List[Action]) -> List[Action]:
"""Activates the extension by registering its functionality
Args:
actions (List[Action]): list of action to perform
Returns:
List[Action]: updated list of actions
"""
raise NotImplementedError(f"Extension {self.name} has no actions registered")
register = staticmethod(register)
"""Shortcut for :obj:`pyscaffold.actions.register`"""
unregister = staticmethod(unregister)
"""Shortcut for :obj:`pyscaffold.actions.unregister`"""
def __call__(self, actions: List[Action]) -> List[Action]:
"""Just delegating to :obj:`self.activate`"""
return self.activate(actions)
[docs]def include(*extensions: Extension) -> Type[argparse.Action]:
"""Create a custom :obj:`argparse.Action` that saves multiple extensions for
activation.
Args:
*extensions: extension objects to be saved
"""
class IncludeExtensions(argparse.Action):
"""Appends the given extensions to the extensions list."""
def __call__(self, parser, namespace, values, option_string=None):
ext_list = list(getattr(namespace, "extensions", []))
namespace.extensions = ext_list + list(extensions)
return IncludeExtensions
[docs]def store_with(*extensions: Extension) -> Type[argparse.Action]:
"""Create a custom :obj:`argparse.Action` that stores the value of the given option
in addition to saving the extension for activation.
Args:
*extensions: extension objects to be saved for activation
"""
class AddExtensionAndStore(include(*extensions)): # type: ignore
"""\
Consumes the values provided, but also appends the given extension
to the extensions list.
"""
def __call__(self, parser, namespace, values, option_string=None):
super().__call__(parser, namespace, values, option_string)
setattr(namespace, self.dest, values)
return AddExtensionAndStore
[docs]def iterate_entry_points(group=ENTRYPOINT_GROUP) -> Iterable[EntryPoint]:
"""Produces a generator yielding an EntryPoint object for each extension registered
via `setuptools`_ entry point mechanism.
This method can be used in conjunction with :obj:`load_from_entry_point` to filter
the extensions before actually loading them.
.. _setuptools: https://setuptools.readthedocs.io/en/latest/userguide/entry_point.html
""" # noqa
entries = entry_points()
if hasattr(entries, "select"):
# The select method was introduced in importlib_metadata 3.9 (and Python 3.10)
# and the previous dict interface was declared deprecated
return entries.select(group=group) # type: ignore
else:
# TODO: Once Python 3.10 becomes the oldest version supported, this fallback and
# conditional statement can be removed.
return (extension for extension in entries.get(group, [])) # type: ignore
[docs]def load_from_entry_point(entry_point: EntryPoint) -> Extension:
"""Carefully load the extension, raising a meaningful message in case of errors"""
try:
return entry_point.load()(entry_point.name)
except Exception as ex:
raise ErrorLoadingExtension(entry_point=entry_point) from ex
[docs]def list_from_entry_points(
group: str = ENTRYPOINT_GROUP,
filtering: Callable[[EntryPoint], bool] = lambda _: True,
) -> List[Extension]:
"""Produces a list of extension objects for each extension registered
via `setuptools`_ entry point mechanism.
Args:
group: name of the setuptools' entry_point group where extensions is being
registered
filtering: function returning a boolean deciding if the entry point should be
loaded and included (or not) in the final list. A ``True`` return means the
extension should be included.
.. _setuptools: https://setuptools.readthedocs.io/en/latest/userguide/entry_point.html
""" # noqa
return deterministic_sort(
load_from_entry_point(e) for e in iterate_entry_points(group) if filtering(e)
)