diff --git a/.gitignore b/.gitignore index 6f7a6d9..90bad5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,111 +1,13 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so +# Python bytecode +*.pyc -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ +# Python packaging *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec +__pycache__/ +build/ -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# python3 -m venv venv +venv -# Unit test / coverage reports -htmlcov/ +# tox .tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json diff --git a/AUTHORS.txt b/AUTHORS.txt deleted file mode 100644 index 17b68ca..0000000 --- a/AUTHORS.txt +++ /dev/null @@ -1,5 +0,0 @@ -Authors -======= - -Kevin Wurster -Sean Gillies diff --git a/CHANGES.md b/CHANGES.md index 12ae758..f2f8dff 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,12 @@ Changelog ========= +2.0 - TBD +--------- + +Final release. Repository now serves as a reference implementation, and +contains a file that users may vendor in order to use `click-plugins`. + 1.1.1.2 - 2025-06-24 -------------------- diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..b7f9465 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,86 @@ +######### +Changelog +######### + +2.0 - 2025-06-24 +================ + +Final release. Repository now serves as a reference implementation, and +contains a file that users may vendor in order to use ``click-plugins``. + +* Handle ``click`` version ``8.2.0`` behavior change in tests. +* Migrate from the deprecated ``pkg_resources.iter_entry_points()`` to + ``importlib.metadata.entry_points()``. +* Convert ``click_plugins/`` to a single ``click_plugins.py`` file. Users may + copy this file into their project to use ``click-plugins``. +* Drop Travis-CI and optionally use `Tox `_ for a full test + matrix. This project is winding down and no longer needs a CI system. +* Use ``unittest`` instead of ``pytest`` for tests. This eliminates one direct + and several transitive dependencies, and makes it easier for users to test + and deploy ``click_plugins.py`` in their environment. +* Error messages for broken plugins are now emitted to ``stderr`` instead of + ``stdout``. +* ``@with_plugins()`` accepts an entrypoint group name, an ``EntryPoint()``, + or a sequence of ``EntryPoint()`` instances. +* Remove ``pip`` packaging machinery. Users should vendor. + +1.1.1.2 - 2025-06-24 +==================== + +- Add a clear note stating that the package is no longer maintained, but the library can be vendored. + +1.1.1.1 - 2025-06-24 +==================== + +- Mark the project as inactive. + +1.1.1 - 2019-04-04 +================== + +* Fixed a version mismatch in ``click_plugins/__init__.py``. See ``1.1``. + +1.1 - 2019-04-04 +================ + +* `#25 `_ - Fix an + issue where a broken command's traceback would not be emitted. +* `#28 `_ - Bump + required click version to ``click>=4``. +* `#28 `_ - Runs Travis + tests for the latest release of ``click`` versions ``>=4,<8`` + (approximately). + +1.0.4 - 2018-09-15 +================== + +* `#9 `_ - Preemptive + fix for a breaking change in ``click`` v7. CLI command names generated from + functions with underscores will have dashes instead of underscores. + + +1.0.3 - 2016-01-05 +================== + +* Include tests in ``MANIFEST.in``. See further discussion in + `#8 `_. + + +1.0.2 - 2015-09-23 +------------------ + +* General packaging and Travis-CI improvements. +* `#8 `_ - Don't + include tests in ``MANIFEST.in`` + + +1.0.1 - 2015-08-20 +================== + +* `#5 `_ - Fixed a typo + in an error message. + + +1.0 - 2015-07-20 +================ + +- Initial release. diff --git a/LICENSE.txt b/LICENSE.txt index becff56..d28acec 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -26,4 +26,4 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index a17a69f..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,7 +0,0 @@ -include AUTHORS.txt -include CHANGES.md -include LICENSE.txt -include MANIFEST.in -include README.rst -include setup.py -recursive-include tests *.py diff --git a/README.rst b/README.rst index cfb75ca..4e590f7 100644 --- a/README.rst +++ b/README.rst @@ -1,184 +1,100 @@ -============= click-plugins ============= -This PyPI package is no longer actively maintained, but the underlying -library can be vendored. See `homepage `_ -for more information. - -An extension module for `click `_ to register -external CLI commands via setuptools entry-points. - - -Why? ----- - -Lets say you develop a commandline interface and someone requests a new feature -that is absolutely related to your project but would have negative consequences -like additional dependencies, major refactoring, or maybe its just too domain -specific to be supported directly. Rather than developing a separate standalone -utility you could offer up a `setuptools entry point `_ -that allows others to use your commandline utility as a home for their related -sub-commands. You get to choose where these sub-commands or sub-groups CAN be -registered but the plugin developer gets to choose they ARE registered. You -could have all plugins register alongside the core commands, in a special -sub-group, across multiple sub-groups, or some combination. - - -Enabling Plugins ----------------- - -For a more detailed example see the `examples `_ section. - -The only requirement is decorating ``click.group()`` with ``click_plugins.with_plugins()`` -which handles attaching external commands and groups. In this case the core CLI developer -registers CLI plugins from ``core_package.cli_plugins``. - -.. code-block:: python - - from pkg_resources import iter_entry_points - - import click - from click_plugins import with_plugins - - - @with_plugins(iter_entry_points('core_package.cli_plugins')) - @click.group() - def cli(): - """Commandline interface for yourpackage.""" - - @cli.command() - def subcommand(): - """Subcommand that does something.""" - - -Developing Plugins ------------------- - -Plugin developers need to register their sub-commands or sub-groups to an -entry-point in their ``setup.py`` that is loaded by the core package. - -.. code-block:: python - - from setuptools import setup - - setup( - name='yourscript', - version='0.1', - py_modules=['yourscript'], - install_requires=[ - 'click', - ], - entry_points=''' - [core_package.cli_plugins] - cool_subcommand=yourscript.cli:cool_subcommand - another_subcommand=yourscript.cli:another_subcommand - ''', - ) +A method for registering plugins on command line interfaces built with +`click`_. -Broken and Incompatible Plugins -------------------------------- - -Any sub-command or sub-group that cannot be loaded is caught and converted to -a ``click_plugins.core.BrokenCommand()`` rather than just crashing the entire -CLI. The short-help is converted to a warning message like: - -.. code-block:: console - - Warning: could not load plugin. See `` --help``. - -and if the sub-command or group is executed the entire traceback is printed. - - -Best Practices and Extra Credit -------------------------------- - -Opening a CLI to plugins encourages other developers to independently extend -functionality independently but there is no guarantee these new features will -be "on brand". Plugin developers are almost certainly already using features -in the core package the CLI belongs to so defining commonly used arguments and -options in one place lets plugin developers reuse these flags to produce a more -cohesive CLI. If the CLI is simple maybe just define them at the top of -``yourpackage/cli.py`` or for more complex packages something like -``yourpackage/cli/options.py``. These common options need to be easy to find -and be well documented so that plugin developers know what variable to give to -their sub-command's function and what object they can expect to receive. Don't -forget to document non-obvious callbacks. - -Keep in mind that plugin developers also have access to the parent group's -``ctx.obj``, which is very useful for passing things like verbosity levels or -config values around to sub-commands. - -Here's some code that sub-commands could re-use: - -.. code-block:: python +History +------- - from multiprocessing import cpu_count +This project was originally distributed as the `click-plugins `_ +package via the Python Package Index. That package still exists, but will not +be updated. Instead, users should vendor files from this repository in order +to use ``click-plugins``. See `click_plugins.rst`_ for more +information. - import click +This project is no longer actively maintained, but has been structured for +maximum longevity. It has no dependencies aside from `click`_, and does not +offer any mechanism for building a package. Users are free to vendor, and +modify as needed in accordance with the license. Users are also free to build +their own package. - jobs_opt = click.option( - '-j', '--jobs', metavar='CORES', type=click.IntRange(min=1, max=cpu_count()), default=1, - show_default=True, help="Process data across N cores." - ) +Users may want to treat this project as a reference for their own +implementation. -Plugin developers can access this with: -.. code-block:: python +Vendoring +~~~~~~~~~ - import click - import parent_cli_package.cli.options +Users interested in vendoring ``click-plugins`` should consider adding the +files listed below to their project. +* `click_plugins.py`_ - Core library file. Required. +* `click_plugins_tests.py`_ - Tests for `click_plugins.py`. Not required, but + can be integrated into an application's test suite. +* `click_plugins.rst`_ - Documentation for `click_plugins.py`_. Not required, + but can be integrated into a project's documentation. +* `click_plugins.html`_ - An HTML versin of `click_plugins.rst`_. The version + in this repository is manually generated, and may be out of sync with + `click_plugins.rst`_. It is included for convenience for users who cannot + easily render reStructuredText as HTML. - @click.command() - @parent_cli_package.cli.options.jobs_opt - def subcommand(jobs): - """I do something domain specific.""" +Users are responsible for any adjustments required to satisfy their project's +typing, linting, and code style requirements. -Installation ------------- +Testing +~~~~~~~ -With ``pip``: +Tests are built and executed using Python's builtin `unittest `_ +library. .. code-block:: console - $ pip install click-plugins + $ python -m unittest click_plugins_tests.py -From source: +`tox `_ (see `tox.ini `_) can be used to test +multiple versions of Python and `click`_. The goal is to support as many +versions of Python and `click`_ as reasonably possible, including versions +that are potentially no longer +officially supported by Python Software Foundation or the `click`_ maintainers. +Versions that have reached end of life and and are difficult to support may be +dropped. -.. code-block:: console - $ git clone https://github.com/click-contrib/click-plugins.git - $ cd click-plugins - $ python setup.py install +Documentation +~~~~~~~~~~~~~ - -Developing ----------- +This project uses `reStructuredText `_, +but users who do not support this markup language will not be able to process +`click_plugins.rst`_ when building documentation. Most +users can probably support a HTML file, which can be rendered with: .. code-block:: console - $ git clone https://github.com/click-contrib/click-plugins.git - $ cd click-plugins - $ pip install -e .\[dev\] - $ pytest tests --cov click_plugins --cov-report term-missing - + $ docutils \ + click_plugins.rst \ + click_plugins.html \ + --date \ + --leave-comments \ + --no-generator \ + --no-source-link \ + --stylesheet-path "" \ + --root-prefix ./ -Changelog ---------- -See ``CHANGES.txt`` +Release +~~~~~~~ +* Consider bumping the version in `click_plugins.py`_ and `click_plugins.rst`_ + based on the magnitude of the change. +* Build and check in a new version of the documentation. +* Update `CHANGES.rst `_. -Authors -------- - -See ``AUTHORS.txt`` - - -License -------- -See ``LICENSE.txt`` +.. _click: https://palletsprojects.com/projects/click/ +.. _click_plugins.py: click_plugins.py +.. _click_plugins_tests.py: click_plugins_tests.py +.. _click_plugins.rst: click_plugins.rst +.. _click_plugins.html: click_plugins.html diff --git a/click_plugins.html b/click_plugins.html new file mode 100644 index 0000000..1d90fb9 --- /dev/null +++ b/click_plugins.html @@ -0,0 +1,158 @@ + + + + + + +click-plugins + + + +
+

click-plugins

+ + +

Load click commands from +entry points. +Allows a click-based command line interface to load commands from external +packages.

+ +
+

What is a plugin?

+

A plugin is similar to installing and importing a Python package, except the +code conforms to a specific protocol, and is loaded through other means.

+
+
+

Why would I want a plugin?

+

Library developers providing a command line interface can load plugins to +extend its features by allowing other developers to register additional +commands or groups. This allows for an extensible command line, and a natural +home for commands that aren't a great fit for the primary CLI, but belong in +the broader ecosystem. For example, a plugin might provide a more advanced set +of features, but require additional dependencies.

+
+
+

I am a developer wanting to support plugins on my CLI

+

A click-plugins package exists on +the Python Package Index. This is an older version that is no longer supported +and will not be updated. Instead, developers should vendor +click_plugins.py, and consider vendoring click_plugins_tests.py, and +click_plugins.rst. Alternatively, developers are free to use this project +as a reference for their own implementation, or make modifications in +accordance with the license.

+

Some considerations for vendoring are speed, and packaging. Entrypoints are +known to be slow to load, and some alternative approaches exist at the cost of +additional dependencies, or assumptions about what happens when a plugin fails +to load. Vendoring click-plugins might include changing the entry point +loading mechanism to one that is more appropriate for your use. Python +packaging can be quite complicated in some cases, and vendoring may require +adjustments for your specific packaging setup.

+

In order to support loading plugins, developers must document where their +library is looking for entry points. Exactly how to do this varies based on +packaging tooling, but it is supported by setuptools. +A project may offer several entry points allowing plugins to choose where they +are registered in the CLI. Including the package name in the entrypoint is +good, so an example might look like package.plugins or +package.subcommand.plugins. If click-plugins offered plugins, it might +want to register them at click_plugins.plugins.

+

This entry point should be associated with a click.Group() where the +plugins will live:

+
from click
+from click_plugins import with_plugins
+
+@with_plugins('example.entry.point')
+@click.group('External plugins')
+def group():
+    ...
+

click_plugins.with_plugins() has a docstring describing alternate +invocations.

+

Some developers use click-plugins as an easy way to assemble the CLI for +their project in addition to supporting plugins. This approach does work, but +can cause CLI startup to be slow. Developers taking this approach might +consider entry point for the primary CLI, and one for plugins.

+

Packages offering plugins of the same name will experience collisions.

+
+

Support

+

Offering a home for plugins comes with a certain amount of support. The primary +CLI author is likely to sometimes receive bug reports or feature requests for +plugins that are not part of the core project. click-plugins attempts to +gracefully handle plugins that fail to load, and nudges the user towards the +plugin author, but the plugin origin may at times not be clear. Consider that +your users are primarily interacting with your CLI, but may be experiencing +problems with a plugin, or even a bad interaction between plugins. It may be +worth including a brief description about this in your documentation to help +users report issues to the correct location.

+
+
+
+

I am a plugin author

+

Register your click.Command() or click.Group() as an +entry point. +The exact mechanism depends on your packaging choices, but for a +pyproject.toml with setuptools as a backend, it looks like:

+
[tool.setuptools.dynamic]
+entry-points =
+    name = library.submodule:object
+

If click_plugins had a plugins.py submodule, it might contain a +plugin structured as the click.Command() below:

+
import click
+
+@click.command('uppercase')
+def uppercase():
+    """Echo stdin in uppercase."""
+    with click.get_text_stream('stdin') as f:
+        for line in f:
+            click.echo(f.upper())
+

This would be attached to an entry point like:

+
[tool.setuptools.dynamic]
+entry-points =
+    bold = click_plugins.plugins:bold
+
+
+

License

+

New BSD License

+

Copyright (c) 2015-2025, Kevin D. Wurster, Sean C. Gillies +All rights reserved.

+

Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met:

+
    +
  • Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer.

  • +
  • Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution.

  • +
  • Neither click-plugins nor the names of its contributors may not be used to +endorse or promote products derived from this software without specific prior +written permission.

  • +
+

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+
+
+
+

Generated on: 2025-06-09. +

+
+ + diff --git a/click_plugins.py b/click_plugins.py new file mode 100644 index 0000000..ed9e5dc --- /dev/null +++ b/click_plugins.py @@ -0,0 +1,247 @@ +# This file is part of 'click-plugins': https://github.com/click-contrib/click-plugins +# +# New BSD License +# +# Copyright (c) 2015-2025, Kevin D. Wurster, Sean C. Gillies +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither click-plugins nor the names of its contributors may not be used to +# endorse or promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +"""Support CLI plugins with click and entry points. + +See :func:`with_plugins`. +""" + + +import importlib.metadata +import os +import sys +import traceback + +import click + + +__version__ = '2.0' + + +def with_plugins(entry_points): + + """Decorator for loading and attaching plugins to a ``click.Group()``. + + Plugins are loaded from an ``importlib.metadata.EntryPoint()``. Each entry + point must point to a ``click.Command()``. An entry point that fails to + load will be wrapped in a ``BrokenCommand()`` to allow the CLI user to + discover and potentially debug the problem. + + >>> from importlib.metadata import entry_points + >>> + >>> import click + >>> from click_plugins import with_plugins + >>> + >>> @with_plugins('group_name') + >>> @click.group() + >>> def group(): + ... '''Group''' + >>> + >>> @with_plugins(entry_points('group_name')) + >>> @click.group() + >>> def group(): + ... '''Group''' + >>> + >>> @with_plugins(importlib.metadata.EntryPoint(...)) + >>> @click.group() + >>> def group(): + ... '''Group''' + >>> + >>> @with_plugins("group1") + >>> @with_plugins("group2") + >>> def group(): + ... '''Group''' + + :param str or EntryPoint or sequence[EntryPoint] entry_points: + Entry point group name, a single ``importlib.metadata.EntryPoint()``, + or a sequence of ``EntryPoint()``s. + + :rtype function: + """ + + # Note that the explicit full path reference to: + # + # importlib.metadata.entry_points() + # + # in this function allows the call to be mocked in the tests. Replacing + # with: + # + # from importlib.metadata import entry_points + # + # breaks this ability. + + def decorator(group): + if not isinstance(group, click.Group): + raise TypeError( + f"plugins can only be attached to an instance of" + f" 'click.Group()' not: {repr(group)}") + + # Load 'EntryPoint()' objects. + if isinstance(entry_points, str): + + # Older versions of Python do not support filtering. + if sys.version_info >= (3, 10): + all_entry_points = importlib.metadata.entry_points( + group=entry_points) + + else: + all_entry_points = importlib.metadata.entry_points() + all_entry_points = all_entry_points[entry_points] + + # A single 'importlib.metadata.EntryPoint()' + elif isinstance(entry_points, importlib.metadata.EntryPoint): + all_entry_points = [entry_points] + + # Sequence of 'EntryPoints()'. + else: + all_entry_points = entry_points + + for ep in all_entry_points: + + try: + group.add_command(ep.load()) + + # Catch all exceptions (technically not 'BaseException') and + # instead register a special 'BrokenCommand()'. Otherwise, a single + # plugin that fails to load and/or register will make the CLI + # inoperable. 'BrokenCommand()' explains the situation to users. + except Exception as e: + group.add_command(BrokenCommand(ep, e)) + + return group + + return decorator + + +class BrokenCommand(click.Command): + + """Represents a plugin ``click.Command()`` that failed to load. + + Can be executed just like a ``click.Command()``, but prints information + for debugging and exits with an error code. + """ + + def __init__(self, entry_point, exception): + + """ + :param importlib.metadata.EntryPoint entry_point: + Entry point that failed to load. + :param Exception exception: + Raised when attempting to load the entry point associated with + this instance. + """ + + super().__init__(entry_point.name) + + # There are several ways to get a traceback from an exception, but + # 'TracebackException()' seems to be the most portable across actively + # supported versions of Python. + tbe = traceback.TracebackException.from_exception(exception) + + # A message for '$ cli command --help'. Contains full traceback and a + # helpful note. The intention is to nudge users to figure out which + # project should get a bug report since users are likely to report the + # issue to the developers of the CLI utility they are directly + # interacting with. These are not necessarily the right developers. + self.help = ( + "{ls}ERROR: entry point '{module}:{name}' could not be loaded." + " Contact its author for help.{ls}{ls}{tb}").format( + module=_module(entry_point), + name=entry_point.name, + ls=os.linesep, + tb=''.join(tbe.format()) + ) + + # Replace the broken command's summary with a warning about how it + # was not loaded successfully. The idea is that '$ cli --help' should + # include a clear indicator that a subcommand is not functional, and + # a little hint for what to do about it. U+2020 is a "dagger", whose + # modern use typically indicates a footnote. + self.short_help = ( + f"\u2020 Warning: could not load plugin. Invoke command with" + f" '--help' for traceback." + ) + + def invoke(self, ctx): + + """Print traceback and debugging message. + + :param click.Context ctx: + Active context. + """ + + click.echo(self.help, color=ctx.color, err=True) + ctx.exit(1) + + def parse_args(self, ctx, args): + + """Pass arguments along without parsing. + + :param click.Context ctx: + Active context. + :param list args: + List of command line arguments. + """ + + # Do not attempt to parse these arguments. We do not know why the + # entry point failed to load, but it is reasonable to assume that + # argument parsing will not work. Ultimately the goal is to get the + # 'Command.invoke()' method (overloaded in this class) to execute + # and provide the user with a bit of debugging information. + + return args + + +def _module(ep): + + """Module name for a given entry point. + + Parameters + ---------- + ep : importlib.metadata.EntryPoint + Determine parent module for this entry point. + + Returns + ------- + str + """ + + if sys.version_info >= (3, 10): + module = ep.module + + else: + # From 'importlib.metadata.EntryPoint.module'. + match = ep.pattern.match(ep.value) + module = match.group('module') + + return module diff --git a/click_plugins.rst b/click_plugins.rst new file mode 100644 index 0000000..c0313ae --- /dev/null +++ b/click_plugins.rst @@ -0,0 +1,169 @@ +.. + This file is part of 'click-plugins' version 2.0: https://github.com/click-contrib/click-plugins + + +``click-plugins`` +================= + +Load `click `_ commands from +`entry points `_. +Allows a click-based command line interface to load commands from external +packages. + +.. contents:: Table of Contents + :depth: 2 + + +What is a plugin? +----------------- + +A plugin is similar to installing and importing a Python package, except the +code conforms to a specific protocol, and is loaded through other means. + + +Why would I want a plugin? +-------------------------- + +Library developers providing a command line interface can load plugins to +extend its features by allowing other developers to register additional +commands or groups. This allows for an extensible command line, and a natural +home for commands that aren't a great fit for the primary CLI, but belong in +the broader ecosystem. For example, a plugin might provide a more advanced set +of features, but require additional dependencies. + + +I am a developer wanting to support plugins on my CLI +----------------------------------------------------- + +A `click-plugins `_ package exists on +the Python Package Index. This is an older version that is no longer supported +and will not be updated. Instead, developers should vendor +``click_plugins.py``, and consider vendoring ``click_plugins_tests.py``, and +``click_plugins.rst``. Alternatively, developers are free to use this project +as a reference for their own implementation, or make modifications in +accordance with the license. + +Some considerations for vendoring are speed, and packaging. Entrypoints are +known to be slow to load, and some alternative approaches exist at the cost of +additional dependencies, or assumptions about what happens when a plugin fails +to load. Vendoring ``click-plugins`` might include changing the entry point +loading mechanism to one that is more appropriate for your use. Python +packaging can be quite complicated in some cases, and vendoring may require +adjustments for your specific packaging setup. + +In order to support loading plugins, developers must document where their +library is looking for entry points. Exactly how to do this varies based on +packaging tooling, but it is supported by `setuptools `_. +A project may offer several entry points allowing plugins to choose where they +are registered in the CLI. Including the package name in the entrypoint is +good, so an example might look like ``package.plugins`` or +``package.subcommand.plugins``. If ``click-plugins`` offered plugins, it might +want to register them at ``click_plugins.plugins``. + +This entry point should be associated with a ``click.Group()`` where the +plugins will live: + +.. code-block:: python + + from click + from click_plugins import with_plugins + + @with_plugins('example.entry.point') + @click.group('External plugins') + def group(): + ... + + +``click_plugins.with_plugins()`` has a docstring describing alternate +invocations. + +Some developers use ``click-plugins`` as an easy way to assemble the CLI for +their project in addition to supporting plugins. This approach does work, but +can cause CLI startup to be slow. Developers taking this approach might +consider entry point for the primary CLI, and one for plugins. + +Packages offering plugins of the same name will experience collisions. + +Support +~~~~~~~ + +Offering a home for plugins comes with a certain amount of support. The primary +CLI author is likely to sometimes receive bug reports or feature requests for +plugins that are not part of the core project. ``click-plugins`` attempts to +gracefully handle plugins that fail to load, and nudges the user towards the +plugin author, but the plugin origin may at times not be clear. Consider that +your users are primarily interacting with your CLI, but may be experiencing +problems with a plugin, or even a bad interaction between plugins. It may be +worth including a brief description about this in your documentation to help +users report issues to the correct location. + + +I am a plugin author +-------------------- + +Register your ``click.Command()`` or ``click.Group()`` as an +`entry point `_. +The exact mechanism depends on your packaging choices, but for a +``pyproject.toml`` with ``setuptools`` as a backend, it looks like: + +.. code-block:: toml + + [tool.setuptools.dynamic] + entry-points = + name = library.submodule:object + +If ``click_plugins`` had a ``plugins.py`` submodule, it might contain a +plugin structured as the ``click.Command()`` below: + +.. code-block:: python + + import click + + @click.command('uppercase') + def uppercase(): + """Echo stdin in uppercase.""" + with click.get_text_stream('stdin') as f: + for line in f: + click.echo(f.upper()) + +This would be attached to an entry point like: + +.. code-block:: toml + + [tool.setuptools.dynamic] + entry-points = + bold = click_plugins.plugins:bold + + +License +------- + +New BSD License + +Copyright (c) 2015-2025, Kevin D. Wurster, Sean C. Gillies +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither click-plugins nor the names of its contributors may not be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/click_plugins/__init__.py b/click_plugins/__init__.py deleted file mode 100644 index 00157ff..0000000 --- a/click_plugins/__init__.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -An extension module for click to enable registering CLI commands via setuptools -entry-points. - - - from pkg_resources import iter_entry_points - - import click - from click_plugins import with_plugins - - - @with_plugins(iter_entry_points('entry_point.name')) - @click.group() - def cli(): - '''Commandline interface for something.''' - - @cli.command() - @click.argument('arg') - def subcommand(arg): - '''A subcommand for something else''' -""" - - -from click_plugins.core import with_plugins - - -__version__ = '1.1.1.2' -__author__ = 'Kevin Wurster, Sean Gillies' -__email__ = 'wursterk@gmail.com, sean.gillies@gmail.com' -__source__ = 'https://github.com/click-contrib/click-plugins' -__license__ = ''' -New BSD License - -Copyright (c) 2015-2025, Kevin D. Wurster, Sean C. Gillies -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither click-plugins nor the names of its contributors may not be used to - endorse or promote products derived from this software without specific prior - written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -''' diff --git a/click_plugins/core.py b/click_plugins/core.py deleted file mode 100644 index 0d7f5e9..0000000 --- a/click_plugins/core.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Core components for click_plugins -""" - - -import click - -import os -import sys -import traceback - - -def with_plugins(plugins): - - """ - A decorator to register external CLI commands to an instance of - `click.Group()`. - - Parameters - ---------- - plugins : iter - An iterable producing one `pkg_resources.EntryPoint()` per iteration. - attrs : **kwargs, optional - Additional keyword arguments for instantiating `click.Group()`. - - Returns - ------- - click.Group() - """ - - def decorator(group): - if not isinstance(group, click.Group): - raise TypeError("Plugins can only be attached to an instance of click.Group()") - - for entry_point in plugins or (): - try: - group.add_command(entry_point.load()) - except Exception: - # Catch this so a busted plugin doesn't take down the CLI. - # Handled by registering a dummy command that does nothing - # other than explain the error. - group.add_command(BrokenCommand(entry_point.name)) - - return group - - return decorator - - -class BrokenCommand(click.Command): - - """ - Rather than completely crash the CLI when a broken plugin is loaded, this - class provides a modified help message informing the user that the plugin is - broken and they should contact the owner. If the user executes the plugin - or specifies `--help` a traceback is reported showing the exception the - plugin loader encountered. - """ - - def __init__(self, name): - - """ - Define the special help messages after instantiating a `click.Command()`. - """ - - click.Command.__init__(self, name) - - util_name = os.path.basename(sys.argv and sys.argv[0] or __file__) - - if os.environ.get('CLICK_PLUGINS_HONESTLY'): # pragma no cover - icon = u'\U0001F4A9' - else: - icon = u'\u2020' - - self.help = ( - "\nWarning: entry point could not be loaded. Contact " - "its author for help.\n\n\b\n" - + traceback.format_exc()) - self.short_help = ( - icon + " Warning: could not load plugin. See `%s %s --help`." - % (util_name, self.name)) - - def invoke(self, ctx): - - """ - Print the traceback instead of doing nothing. - """ - - click.echo(self.help, color=ctx.color) - ctx.exit(1) - - def parse_args(self, ctx, args): - return args diff --git a/click_plugins_tests.py b/click_plugins_tests.py new file mode 100644 index 0000000..a10a357 --- /dev/null +++ b/click_plugins_tests.py @@ -0,0 +1,514 @@ +# This file is part of 'click-plugins': https://github.com/click-contrib/click-plugins +# +# New BSD License +# +# Copyright (c) 2015-2025, Kevin D. Wurster, Sean C. Gillies +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither click-plugins nor the names of its contributors may not be used to +# endorse or promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +"""Tests for ``click_plugins``.""" + + +from collections import defaultdict +import configparser +import importlib.metadata +from io import StringIO +import importlib.metadata +import os +import sys +import unittest +from unittest import mock + +import click +from click.testing import CliRunner + +from click_plugins import _module, with_plugins + + +############################################################################### +# Version-Specific Behavior + +def _click_version(): + + """Attempt to parse :attr:`click.__version__` into a friendly construct. + + Unfortunately, the Python standard library offers no method for comparing + version strings. The official recommended approach is to use the + `packaging `_ module, however + ``click-plugins`` strives to only use libraries available in the standard + library. + + This implementation is incomplete. Only the major and minor components are + extracted and converted to integers. The idea is that we only have to + be aware of a specific version of :mod:`click`. + """ + + # 'click.__version__' will be removed in v9 + click_version = importlib.metadata.version('click') + + parsed = [] + for part in click_version.split('.'): + + if not part.isdigit(): + raise RuntimeError(f'unexpected version: {click_version}') + + elif part.isdigit(): + parsed.append(int(part)) + + if len(parsed) >= 2: + break + + return tuple(parsed) + + +# In some configurations, executing a 'click' CLI will print help information +# and exit if no arguments are given. 'click' v8.2.0 changed the behavior from +# exit code 0 to 2. +# +# https://github.com/pallets/click/blob/main/CHANGES.rst#version-820 +# +if _click_version() < (8, 2): + EXIT_CODE_NO_ARGS_IS_HELP = 0 +else: + EXIT_CODE_NO_ARGS_IS_HELP = 2 + + +del _click_version + + +############################################################################### +# CLI Commands + +# These commands are later registered as entry points, and then loaded. They +# must exist in + + +EP_NO_EXIST_KEY = 'no_exist' +EP_NO_EXIST_VALUE = 'click_plugins_tests:__no__exist__' + + +@click.command() +@click.argument('arg') +def cmd1(arg): + """Test command 1""" + click.echo('passed') + + +@click.command() +@click.argument('arg') +def cmd2(arg): + """Test command 2""" + click.echo('passed') + + +############################################################################### +# Shim Entry Point Machinery + + +class VirtualDistribution(importlib.metadata.Distribution): + + """Representation of a package. + + Implements just enough methods to be used in testing. The tests need + + Represents an installed package. Implements just enough methods to be used + in testing. The tests require ``importlib.metadata.EntryPoint()`` objects, + and this class provides them. Otherwise, an installed package with entry + points would be required for testing. + """ + + def __init__(self, valid, invalid, extra_group): + + """Must set at least one of `valid` or `invalid`. + + Parameters + ---------- + valid : bool + Include functional plugins. + invalid : bool + Include broken plugins. + """ + + if not valid and not invalid: + raise RuntimeError( + f'would not load entry points: {valid=} {invalid=}' + ) + + self.valid = valid + self.invalid = invalid + self.extra_group = extra_group + + @property + def name(self): + return '' + + def locate_file(self, path): + # Base class requires an implementation, but it does not need to + # be functional. + raise NotImplementedError + + def read_text(self, filename): + + # Only supports reading 'entry_points.txt'. + + if filename != 'entry_points.txt': + raise RuntimeError(f'unsupported: {filename=}') + + cfg = configparser.ConfigParser() + + # Add valid plugins. These are expected to work properly. + if self.valid: + section = 'click_plugins_tests.valid' + cfg.add_section(section) + cfg.set(section, 'cmd1', 'click_plugins_tests:cmd1') + cfg.set(section, 'cmd2', 'click_plugins_tests:cmd2') + + # Add invalid plugins. Broken in a variety of ways. + if self.invalid: + section = 'click_plugins_tests.invalid' + cfg.add_section(section) + cfg.set(section, EP_NO_EXIST_KEY, EP_NO_EXIST_VALUE) + + if self.extra_group: + section = 'extra_entry_point_group' + cfg.add_section(section) + cfg.set( + section, 'extra_entry_point_key', 'extra_entry_point_value') + + with StringIO() as f: + cfg.write(f) + f.seek(0) + text = f.read() + + return text.strip() + + +############################################################################### +# Tests + + +def mock_entry_points(**params): + + """Load a fixed set of entry points without a package. + + ``click-plugins`` needs to exercise loading plugins, but managing a + one or more additional packages for testing this machinery is complicated. + Instead, this function mocks enough of the packaging machinery to present + a set of valid ``importlib.metadata.EntryPoint()`` objects. + + :param **kwargs params: + Keyword arguments. See ``importlib.metadata.entry_points()``. + + :rtype importlib.metadata.EntryPoints or tuple: + + :returns: + Python 3.8 returns a ``tuple()`` of ``EntryPoint()`` objects. Other + versions of Python return a ``importlib.metadata.EntryPoints()`` + object. + """ + + # This code is unfortunately a bit complicated. Different versions of + # Python have subtly different APIs. Hopefully Python 3.12 has provided + # stability. Some versions are more complete than others. Notably, Python + # 3.8 only supports the 'group' parameter. + + if (3, 8) <= sys.version_info <= (3, 9) and params: + raise RuntimeError( + f"'entry_points()' on Python 3.8 and 3.9 does not accept" + f" parameters" + ) + + dist = VirtualDistribution(valid=True, invalid=True, extra_group=True) + virtual_eps = dist.entry_points + + if sys.version_info >= (3, 12): + eps = importlib.metadata.EntryPoints(virtual_eps) + eps = eps.select(**params) + + elif sys.version_info >= (3, 10): + from importlib.metadata import SelectableGroups + eps = SelectableGroups.load(virtual_eps) + if params: + eps = eps.select(**params) + + elif sys.version_info >= (3, 8): + + # Based on the CPython v3.10.13 source code. Ultimately a 'tuple()' + # of 'EntryPoint()' objects is returned. + # Lib/importlib/metadata.py + + by_group = defaultdict(list) + for e in virtual_eps: + by_group[e.group].append(e) + + return dict(by_group) + + else: + raise RuntimeError( + f'unsupported Python: {".".join(map(str, sys.version_info[:3]))}') + + return eps + + +def mock_entry_points_from_group(group): + + """Shim for an API difference in older versions of Python.""" + + if sys.version_info >= (3, 10): + all_entry_points = mock_entry_points(group=group) + + else: + all_entry_points = mock_entry_points() + all_entry_points = all_entry_points[group] + + return all_entry_points + + +class TestLoad(unittest.TestCase): + + """Ensures plugins can be properly loaded.""" + + mapping = { + 'click_plugins_tests.valid': (cmd1.name, cmd2.name), + 'click_plugins_tests.invalid': (EP_NO_EXIST_KEY, ) + } + + def test_EntryPoint(self): + + """Load a plugin from a single ``EntryPoint()`` object.""" + + for group, expected_keys in self.mapping.items(): + entry_points = mock_entry_points_from_group(group) + for ep, key in zip(entry_points, expected_keys): + + @with_plugins(ep) + @click.group() + def group(): + """test_load_EntryPoint""" + + self.assertEqual((key, ), tuple(group.commands.keys())) + + def test_EntryPoint_objects(self): + + """Load plugins from an iterable of ``EntryPoint()`` objects.""" + + for group, expected_keys in self.mapping.items(): + + @with_plugins(mock_entry_points_from_group(group)) + @click.group() + def group(): + """test_load_EntryPoint""" + + self.assertEqual(expected_keys, tuple(group.commands.keys())) + + @mock.patch("importlib.metadata.entry_points") + def test_entry_point_group_name(self, patched): + + """Load plugins from an entry points group name.""" + + patched.side_effect = mock_entry_points + + for group, expected_keys in self.mapping.items(): + + @with_plugins(group) + @click.group() + def group(): + """test_load_entry_point_name""" + + self.assertEqual(expected_keys, tuple(group.commands.keys())) + + +class Tests(unittest.TestCase): + + def setUp(self): + + """Setup plugins and baseline CLI groups.""" + + # 'click' test runner. + self.runner = CliRunner() + + valid_dist = VirtualDistribution( + valid=True, invalid=False, extra_group=False) + invalid_dist = VirtualDistribution( + valid=False, invalid=True, extra_group=False) + + self.valid_entry_points = valid_dist.entry_points + self.invalid_entry_points = invalid_dist.entry_points + + # CLI group with valid plugins attached. + @with_plugins(self.valid_entry_points) + @click.group() + def good_cli(): + """Good CLI group.""" + self.good_cli = good_cli + + # CLi group with broken plugins attached. + @with_plugins(self.invalid_entry_points) + @click.group() + def broken_cli(): + """Broken CLI group.""" + self.broken_cli = broken_cli + + @mock.patch('importlib.metadata.entry_points', mock_entry_points) + def test_registered(self): + + """Ensure the test plugins are properly registered. + + If this test fails something about the overall test setup is not + correct. + """ + + for group in ( + 'click_plugins_tests.valid', 'click_plugins_tests.invalid'): + eps = mock_entry_points_from_group(group) + self.assertGreaterEqual(len(eps), 1) + + def test_load_and_run(self): + + """Load functional plugins and execute.""" + + # Arguments and expected exit codes. + mapping = { + None: EXIT_CODE_NO_ARGS_IS_HELP, + ('--help', ): 0 + } + + # Ensure parent group is functional + for args, expected_exit_code in mapping.items(): + parent_result = self.runner.invoke(self.good_cli, args) + self.assertEqual(parent_result.exit_code, expected_exit_code) + + # When a plugin is broken its help text is replaced and contains + # an indicator that something is not right. This indicator should + # *not* be present. + self.assertNotIn('\u2020 Warning:', parent_result.output) + + # Ensure each plugin executes without error. + for ep in self.valid_entry_points: + result = self.runner.invoke(self.good_cli, [ep.name, 'something']) + self.assertEqual(0, result.exit_code) + self.assertEqual(f'passed{os.linesep}', result.output) + + def test_load_and_run_broken(self): + + """Load broken plugins and execute.""" + + # Arguments and expected exit codes. + mapping = { + None: EXIT_CODE_NO_ARGS_IS_HELP, + ('--help', ): 0 + } + + # Ensure parent group is functional + for args, expected_exit_code in mapping.items(): + parent_result = self.runner.invoke(self.broken_cli, args) + self.assertEqual(parent_result.exit_code, expected_exit_code) + + # The output from executing the parent command should have an + # indicator that something is wrong. + self.assertIn('\u2020 Warning:', parent_result.output) + + # Ensure each plugin fails to execute, and also reports the full + # traceback when executed with '--help'. Perform this check with and + # without additional arguments. + for args in ([], ['-a', 'b']): + for ep in self.invalid_entry_points: + result = self.runner.invoke(self.broken_cli, [ep.name] + args) + self.assertEqual(1, result.exit_code) + self.assertIn('Traceback', result.output) + msg = ( + f"ERROR: entry point '{_module(ep)}:{ep.name}' could" + f" not be loaded." + ) + self.assertIn(msg, result.output) + + def test_group_chain(self): + + """Register on subgroup and execute.""" + + @self.good_cli.group() + def subgroup(): + """Subgroup.""" + + # The 'subgroup()' is empty, but should not interfere with already + # registered plugins. + result = self.runner.invoke(self.good_cli) + self.assertEqual(result.exit_code, EXIT_CODE_NO_ARGS_IS_HELP) + self.assertIn(subgroup.name, result.output) + for ep in self.valid_entry_points: + self.assertIn(ep.name, result.output) + + @with_plugins(self.valid_entry_points) + @self.good_cli.group(name='subgroup-with-plugins') + def subgroup_with_plugins(): + """Subgroup with plugins.""" + + # Same as above, but the subgroup also has plugins. + result = self.runner.invoke(self.good_cli, ['subgroup-with-plugins']) + self.assertEqual(result.exit_code, EXIT_CODE_NO_ARGS_IS_HELP) + for ep in self.valid_entry_points: + self.assertIn(ep.name, result.output) + + # Execute one of the subgroup's commands + result = self.runner.invoke( + self.good_cli, ['subgroup-with-plugins', 'cmd1', 'something']) + self.assertEqual(0, result.exit_code) + self.assertEqual(f'passed{os.linesep}', result.output) + + def test_exception(self): + + """Only attach plugins to ``click.Group()``.""" + + with self.assertRaises(TypeError) as e: + @with_plugins([]) + @click.command() + def cli(): + """Broken!""" + + self.assertIn('instance of', str(e.exception)) + self.assertIn('click.Group()', str(e.exception)) + + @mock.patch("importlib.metadata.entry_points") + def test_with_plugins_stacked(self, patched): + + """Multiple ``@with_plugins()``.""" + + patched.side_effect = mock_entry_points + + @with_plugins("click_plugins_tests.valid") + @with_plugins("click_plugins_tests.invalid") + @click.group() + def group(): + """test_with_plugins_stacked""" + + self.assertEqual( + sorted(group.commands.keys()), ['cmd1', 'cmd2', 'no_exist']) + + +if __name__ == '__main__': + unittest.main() diff --git a/example/PrintIt/README.rst b/example/PrintIt/README.rst deleted file mode 100644 index 4aea574..0000000 --- a/example/PrintIt/README.rst +++ /dev/null @@ -1,5 +0,0 @@ -PrintIt -======= - -This represents a core package with a CLI that registers external plugins. All -it does is print stuff. diff --git a/example/PrintIt/printit/__init__.py b/example/PrintIt/printit/__init__.py deleted file mode 100644 index ee810b0..0000000 --- a/example/PrintIt/printit/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Tools for printing things -""" diff --git a/example/PrintIt/printit/cli.py b/example/PrintIt/printit/cli.py deleted file mode 100644 index d6b327d..0000000 --- a/example/PrintIt/printit/cli.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Commandline interface for PrintIt -""" - - -from pkg_resources import iter_entry_points - -import click -from click_plugins import with_plugins - - -@with_plugins(iter_entry_points('printit.plugins')) -@click.group() -def cli(): - - """ - Format and print file contents. - - \b - For example: - \b - $ cat README.rst | printit lower - """ - - -@cli.command() -@click.argument('infile', type=click.File('r'), default='-') -@click.argument('outfile', type=click.File('w'), default='-') -def upper(infile, outfile): - - """ - Convert to upper case. - """ - - for line in infile: - outfile.write(line.upper()) - - -@cli.command() -@click.argument('infile', type=click.File('r'), default='-') -@click.argument('outfile', type=click.File('w'), default='-') -def lower(infile, outfile): - - """ - Convert to lower case. - """ - - for line in infile: - outfile.write(line.lower()) diff --git a/example/PrintIt/printit/core.py b/example/PrintIt/printit/core.py deleted file mode 100644 index 66e6f2f..0000000 --- a/example/PrintIt/printit/core.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Some other file that does other stuff. -""" diff --git a/example/PrintIt/setup.py b/example/PrintIt/setup.py deleted file mode 100755 index 0a6a874..0000000 --- a/example/PrintIt/setup.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python - - -""" -Setup script for `PrintIt` -""" - - -from setuptools import setup - - -setup( - name='PrintIt', - version='0.1dev0', - packages=['printit'], - entry_points=''' - [console_scripts] - printit=printit.cli:cli - ''' -) diff --git a/example/PrintItBold/README.rst b/example/PrintItBold/README.rst deleted file mode 100644 index a8b8bf4..0000000 --- a/example/PrintItBold/README.rst +++ /dev/null @@ -1,5 +0,0 @@ -PrintItBold -=========== - -This plugin should add bold styling to ``PrintIt`` but there is a typo in the -entry point section of the ``setup.py`` that prevents the plugin from loading. diff --git a/example/PrintItBold/printit_bold/__init__.py b/example/PrintItBold/printit_bold/__init__.py deleted file mode 100644 index 7012341..0000000 --- a/example/PrintItBold/printit_bold/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -A CLI plugin for `PrintIt` that adds bold text. -""" diff --git a/example/PrintItBold/printit_bold/core.py b/example/PrintItBold/printit_bold/core.py deleted file mode 100644 index 84e6a1b..0000000 --- a/example/PrintItBold/printit_bold/core.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Add bold styling to `printit` -""" - - -import click - - -@click.command() -@click.argument('infile', type=click.File('r'), default='-') -@click.argument('outfile', type=click.File('w'), default='-') -def bold(infile, outfile): - - """ - Make text bold. - """ - - for line in infile: - click.secho(line, bold=True, file=outfile) diff --git a/example/PrintItBold/setup.py b/example/PrintItBold/setup.py deleted file mode 100755 index 15f9c38..0000000 --- a/example/PrintItBold/setup.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python - - -""" -Setup script for `PrintItBold` -""" - - -from setuptools import setup - - -setup( - name='PrintItBold', - version='0.1dev0', - packages=['printit_bold'], - entry_points=''' - [printit.plugins] - bold=printit_bold.core:bolddddddddddd - ''' -) diff --git a/example/PrintItStyle/README.rst b/example/PrintItStyle/README.rst deleted file mode 100644 index 3881cad..0000000 --- a/example/PrintItStyle/README.rst +++ /dev/null @@ -1,4 +0,0 @@ -PrintItStyle -============ - -A plugin for ``PrintIt`` that adds commands for text styling. diff --git a/example/PrintItStyle/printit_style/__init__.py b/example/PrintItStyle/printit_style/__init__.py deleted file mode 100644 index bcba816..0000000 --- a/example/PrintItStyle/printit_style/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -A CLI plugin for `PrintIt` that adds styling options. -""" diff --git a/example/PrintItStyle/printit_style/core.py b/example/PrintItStyle/printit_style/core.py deleted file mode 100644 index 311f0f1..0000000 --- a/example/PrintItStyle/printit_style/core.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Core components for `PrintItStyle` -""" - - -import click - - -COLORS = ( - 'black', - 'red', - 'green', - 'yellow', - 'blue', - 'magenta', - 'cyan', - 'white', -) - - -@click.command() -@click.argument('infile', type=click.File('r'), default='-') -@click.argument('outfile', type=click.File('w'), default='-') -@click.option('-c', '--color', type=click.Choice(COLORS), required=True) -def background(infile, outfile, color): - - """ - Add a background color. - """ - - for line in infile: - click.secho(line, file=outfile, color=color) - - -@click.command() -@click.argument('infile', type=click.File('r'), default='-') -@click.argument('outfile', type=click.File('w'), default='-') -@click.option('-c', '--color', type=click.Choice(COLORS), required=True) -def color(infile, outfile, color): - - """ - Add color to text. - """ - - for line in infile: - click.echo(line, color=color, file=outfile) diff --git a/example/PrintItStyle/setup.py b/example/PrintItStyle/setup.py deleted file mode 100755 index 69e8961..0000000 --- a/example/PrintItStyle/setup.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python - - -""" -Setup script for `PrintItStyle` -""" - - -from setuptools import setup - - -setup( - name='PrintItStyle', - version='0.1dev0', - packages=['printit_style'], - entry_points=''' - [printit.plugins] - background=printit_style.core:background - color=printit_style.core:color - ''' -) diff --git a/example/README.rst b/example/README.rst deleted file mode 100644 index 5c8861d..0000000 --- a/example/README.rst +++ /dev/null @@ -1,155 +0,0 @@ -Plugin Example -============== - -A sample package that loads CLI plugins from another package. - - -Contents --------- - -* ``PrintIt`` - The core package. -* ``PrintItStyle`` - An external plugin for ``PrintIt``'s CLI that adds styling options. -* ``PrintItBold`` - A broken plugin that is should add a command to create bold text, but an error in its ``setup.py`` causes it to not work. - - -Workflow --------- - -From this directory, install the main package (the slash is mandatory): - -.. code-block:: console - - $ pip install PrintIt/ - -And run the commandline utility to see the usage: - -.. code-block:: console - - $ printit - Usage: printit [OPTIONS] COMMAND [ARGS]... - - Format and print file contents. - - For example: - - $ cat README.rst | printit lower - - Options: - --help Show this message and exit. - - Commands: - lower Convert to lower case. - upper Convert to upper case. - - -Try running ``cat README.rst | printit upper`` to convert this file to upper-case. - -The ``PrintItStyle`` directory is an external CLI plugin that is compatible with -``printit``. In this case ``PrintItStyle`` adds styling options to the ``printit`` -utility. - -Install it (don't forget the slash): - -.. code-block:: console - - $ pip install PrintItStyle/ - -And get the ``printit`` usage again, now with two additional commands: - -.. code-block:: console - - $ printit - Usage: printit [OPTIONS] COMMAND [ARGS]... - - Format and print file contents. - - For example: - - $ cat README.rst | printit lower - - Options: - --help Show this message and exit. - - Commands: - background Add a background color. - color Add color to text. - lower Convert to lower case. - upper Convert to upper case. - - -Broken Plugins --------------- - -Plugins that trigger an exception on load are flagged in the usage and the full -traceback can be viewed by executing the command. - -Install the included broken plugin, which we expect to give us a bold styling option: - -.. code-block:: console - - $ pip install BrokenPlugin/ - -And look at the ``printit`` usage again - notice the icon next to ``bold``: - -.. code-block:: console - - $ printit - Usage: printit [OPTIONS] COMMAND [ARGS]... - - Format and print file contents. - - For example: - - $ cat README.rst | printit lower - - Options: - --help Show this message and exit. - - Commands: - background Add a background color. - bold † Warning: could not load plugin. See `printit bold --help`. - color Add color to text. - lower Convert to lower case. - upper Convert to upper case. - -Executing ``printit bold`` reveals the full traceback: - -.. code-block:: console - - $ printit bold - - Warning: entry point could not be loaded. Contact its author for help. - - Traceback (most recent call last): - File "/Users/wursterk/github/click/venv/lib/python3.4/site-packages/pkg_resources/__init__.py", line 2353, in resolve - return functools.reduce(getattr, self.attrs, module) - AttributeError: 'module' object has no attribute 'bolddddddddddd' - - During handling of the above exception, another exception occurred: - - Traceback (most recent call last): - File "/Users/wursterk/github/click/click/decorators.py", line 145, in decorator - obj.add_command(entry_point.load()) - File "/Users/wursterk/github/click/venv/lib/python3.4/site-packages/pkg_resources/__init__.py", line 2345, in load - return self.resolve() - File "/Users/wursterk/github/click/venv/lib/python3.4/site-packages/pkg_resources/__init__.py", line 2355, in resolve - raise ImportError(str(exc)) - ImportError: 'module' object has no attribute 'bolddddddddddd' - -In this case the error is in the broken plugin's ``setup.py``. Note the typo -in the ``entry_points`` section. - -.. code-block:: python - - from setuptools import setup - - - setup( - name='PrintItBold', - version='0.1dev0', - packages=['printit_bold'], - entry_points=''' - [printit.plugins] - bold=printit_bold.core:bolddddddddddd - ''' - ) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7c2b287..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal = 1 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100755 index ebf50a5..0000000 --- a/setup.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python - - -""" -Setup script for click-plugins -""" - - -import codecs -import os - -from setuptools import find_packages -from setuptools import setup - - -with codecs.open('README.rst', encoding='utf-8') as f: - long_desc = f.read().strip() - - -version = None -author = None -email = None -source = None -with open(os.path.join('click_plugins', '__init__.py')) as f: - for line in f: - if line.strip().startswith('__version__'): - version = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif line.strip().startswith('__author__'): - author = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif line.strip().startswith('__email__'): - email = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif line.strip().startswith('__source__'): - source = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif None not in (version, author, email, source): - break - - -setup( - name='click-plugins', - author=author, - author_email=email, - classifiers=[ - 'Topic :: Utilities', - 'Intended Audience :: Developers', - 'Development Status :: 7 - Inactive', - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - ], - description="An extension module for click to enable registering CLI commands " - "via setuptools entry-points.", - extras_require={ - 'dev': [ - 'pytest>=3.6', - 'pytest-cov', - 'wheel', - 'coveralls' - ], - }, - include_package_data=True, - install_requires=['click>=4.0'], - keywords='click plugin setuptools entry-point', - license="New BSD", - long_description=long_desc, - packages=find_packages(exclude=['tests.*', 'tests']), - url=source, - version=version, - zip_safe=True -) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 3c1c95d..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# This file is required for some of the tests of Python 2 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 3aac933..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -from click.testing import CliRunner - -import pytest - - -@pytest.fixture(scope='function') -def runner(request): - return CliRunner() diff --git a/tests/test_plugins.py b/tests/test_plugins.py deleted file mode 100644 index 935f37a..0000000 --- a/tests/test_plugins.py +++ /dev/null @@ -1,162 +0,0 @@ -from pkg_resources import EntryPoint -from pkg_resources import iter_entry_points -from pkg_resources import working_set - -import click -from click_plugins import with_plugins -import pytest - - -# Create a few CLI commands for testing -@click.command() -@click.argument('arg') -def cmd1(arg): - """Test command 1""" - click.echo('passed') - -@click.command() -@click.argument('arg') -def cmd2(arg): - """Test command 2""" - click.echo('passed') - - -# Manually register plugins in an entry point and put broken plugins in a -# different entry point. - -# The `DistStub()` class gets around an exception that is raised when -# `entry_point.load()` is called. By default `load()` has `requires=True` -# which calls `dist.requires()` and the `click.group()` decorator -# doesn't allow us to change this. Because we are manually registering these -# plugins the `dist` attribute is `None` so we can just create a stub that -# always returns an empty list since we don't have any requirements. A full -# `pkg_resources.Distribution()` instance is not needed because there isn't -# a package installed anywhere. -class DistStub(object): - def requires(self, *args): - return [] - -working_set.by_key['click']._ep_map = { - '_test_click_plugins.test_plugins': { - 'cmd1': EntryPoint.parse( - 'cmd1=tests.test_plugins:cmd1', dist=DistStub()), - 'cmd2': EntryPoint.parse( - 'cmd2=tests.test_plugins:cmd2', dist=DistStub()) - }, - '_test_click_plugins.broken_plugins': { - 'before': EntryPoint.parse( - 'before=tests.broken_plugins:before', dist=DistStub()), - 'after': EntryPoint.parse( - 'after=tests.broken_plugins:after', dist=DistStub()), - 'do_not_exist': EntryPoint.parse( - 'do_not_exist=tests.broken_plugins:do_not_exist', dist=DistStub()) - } -} - - -# Main CLI groups - one with good plugins attached and the other broken -@with_plugins(iter_entry_points('_test_click_plugins.test_plugins')) -@click.group() -def good_cli(): - """Good CLI group.""" - pass - -@with_plugins(iter_entry_points('_test_click_plugins.broken_plugins')) -@click.group() -def broken_cli(): - """Broken CLI group.""" - pass - - -def test_registered(): - # Make sure the plugins are properly registered. If this test fails it - # means that some of the for loops in other tests may not be executing. - assert len([ep for ep in iter_entry_points('_test_click_plugins.test_plugins')]) > 1 - assert len([ep for ep in iter_entry_points('_test_click_plugins.broken_plugins')]) > 1 - - -def test_register_and_run(runner): - - result = runner.invoke(good_cli) - assert result.exit_code == 0 - - for ep in iter_entry_points('_test_click_plugins.test_plugins'): - cmd_result = runner.invoke(good_cli, [ep.name, 'something']) - assert cmd_result.exit_code == 0 - assert cmd_result.output.strip() == 'passed' - - -def test_broken_register_and_run(runner): - - result = runner.invoke(broken_cli) - assert result.exit_code == 0 - assert u'\U0001F4A9' in result.output or u'\u2020' in result.output - - for ep in iter_entry_points('_test_click_plugins.broken_plugins'): - cmd_result = runner.invoke(broken_cli, [ep.name]) - assert cmd_result.exit_code != 0 - assert 'Traceback' in cmd_result.output - - -def test_group_chain(runner): - - # Attach a sub-group to a CLI and get execute it without arguments to make - # sure both the sub-group and all the parent group's commands are present - @good_cli.group() - def sub_cli(): - """Sub CLI.""" - pass - - result = runner.invoke(good_cli) - assert result.exit_code == 0 - assert sub_cli.name in result.output - for ep in iter_entry_points('_test_click_plugins.test_plugins'): - assert ep.name in result.output - - # Same as above but the sub-group has plugins - @with_plugins(plugins=iter_entry_points('_test_click_plugins.test_plugins')) - @good_cli.group(name='sub-cli-plugins') - def sub_cli_plugins(): - """Sub CLI with plugins.""" - pass - - result = runner.invoke(good_cli, ['sub-cli-plugins']) - assert result.exit_code == 0 - for ep in iter_entry_points('_test_click_plugins.test_plugins'): - assert ep.name in result.output - - # Execute one of the sub-group's commands - result = runner.invoke(good_cli, ['sub-cli-plugins', 'cmd1', 'something']) - assert result.exit_code == 0 - assert result.output.strip() == 'passed' - - -def test_exception(): - # Decorating something that isn't a click.Group() should fail - with pytest.raises(TypeError): - @with_plugins([]) - @click.command() - def cli(): - """Whatever""" - - -def test_broken_register_and_run_with_help(runner): - result = runner.invoke(broken_cli) - assert result.exit_code == 0 - assert u'\U0001F4A9' in result.output or u'\u2020' in result.output - - for ep in iter_entry_points('_test_click_plugins.broken_plugins'): - cmd_result = runner.invoke(broken_cli, [ep.name, "--help"]) - assert cmd_result.exit_code != 0 - assert 'Traceback' in cmd_result.output - - -def test_broken_register_and_run_with_args(runner): - result = runner.invoke(broken_cli) - assert result.exit_code == 0 - assert u'\U0001F4A9' in result.output or u'\u2020' in result.output - - for ep in iter_entry_points('_test_click_plugins.broken_plugins'): - cmd_result = runner.invoke(broken_cli, [ep.name, "-a", "b"]) - assert cmd_result.exit_code != 0 - assert 'Traceback' in cmd_result.output diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..06475b7 --- /dev/null +++ b/tox.ini @@ -0,0 +1,17 @@ +[tox] +min_version = 4.0 +env_list = + py39-click{7,8} + py310-click{6,7,8} + py311-click{6,7,8} + py312-click{6,7,8} + py313-click{6,7,8} + +[testenv] +deps = + click6: click >=6, <7 + click7: click >=7, <8 + click8: click >=8, <9 + +commands = + python3 -W error -m unittest click_plugins_tests.py