Skip to content

Commit daf5f67

Browse files
committed
Vendor click-plugins, PyPI package no longer maintained.
1 parent 87a0688 commit daf5f67

File tree

3 files changed

+249
-3
lines changed

3 files changed

+249
-3
lines changed

requirements.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
click
2-
click-plugins
32
colorama
43
requests>=2.2.1
54
XlsxWriter
65
ipaddress;python_version<='2.7'
7-
tldextract
6+
tldextract

shodan/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@
4949
from shodan.cli.host import HOST_PRINT
5050

5151
# Allow 3rd-parties to develop custom commands
52-
from click_plugins import with_plugins
5352
from pkg_resources import iter_entry_points
5453

5554
# Large subcommands are stored in separate modules
5655
from shodan.cli.alert import alert
5756
from shodan.cli.data import data
5857
from shodan.cli.organization import org
5958
from shodan.cli.scan import scan
59+
from shodan.click_plugins import with_plugins
6060

6161

6262
# Make "-h" work like "--help"

shodan/click_plugins.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
# This file is part of 'click-plugins': https://github.com/click-contrib/click-plugins
2+
#
3+
# New BSD License
4+
#
5+
# Copyright (c) 2015-2025, Kevin D. Wurster, Sean C. Gillies
6+
# All rights reserved.
7+
#
8+
# Redistribution and use in source and binary forms, with or without
9+
# modification, are permitted provided that the following conditions are met:
10+
#
11+
# * Redistributions of source code must retain the above copyright notice, this
12+
# list of conditions and the following disclaimer.
13+
#
14+
# * Redistributions in binary form must reproduce the above copyright notice,
15+
# this list of conditions and the following disclaimer in the documentation
16+
# and/or other materials provided with the distribution.
17+
#
18+
# * Neither click-plugins nor the names of its contributors may not be used to
19+
# endorse or promote products derived from this software without specific prior
20+
# written permission.
21+
#
22+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
23+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
24+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
25+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
26+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
27+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
28+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
30+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
31+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
32+
33+
34+
"""Support CLI plugins with click and entry points.
35+
36+
See :func:`with_plugins`.
37+
"""
38+
39+
40+
import importlib.metadata
41+
import os
42+
import sys
43+
import traceback
44+
45+
import click
46+
47+
48+
__version__ = '2.0'
49+
50+
51+
def with_plugins(entry_points):
52+
53+
"""Decorator for loading and attaching plugins to a ``click.Group()``.
54+
55+
Plugins are loaded from an ``importlib.metadata.EntryPoint()``. Each entry
56+
point must point to a ``click.Command()``. An entry point that fails to
57+
load will be wrapped in a ``BrokenCommand()`` to allow the CLI user to
58+
discover and potentially debug the problem.
59+
60+
>>> from importlib.metadata import entry_points
61+
>>>
62+
>>> import click
63+
>>> from click_plugins import with_plugins
64+
>>>
65+
>>> @with_plugins('group_name')
66+
>>> @click.group()
67+
>>> def group():
68+
... '''Group'''
69+
>>>
70+
>>> @with_plugins(entry_points('group_name'))
71+
>>> @click.group()
72+
>>> def group():
73+
... '''Group'''
74+
>>>
75+
>>> @with_plugins(importlib.metadata.EntryPoint(...))
76+
>>> @click.group()
77+
>>> def group():
78+
... '''Group'''
79+
>>>
80+
>>> @with_plugins("group1")
81+
>>> @with_plugins("group2")
82+
>>> def group():
83+
... '''Group'''
84+
85+
:param str or EntryPoint or sequence[EntryPoint] entry_points:
86+
Entry point group name, a single ``importlib.metadata.EntryPoint()``,
87+
or a sequence of ``EntryPoint()``s.
88+
89+
:rtype function:
90+
"""
91+
92+
# Note that the explicit full path reference to:
93+
#
94+
# importlib.metadata.entry_points()
95+
#
96+
# in this function allows the call to be mocked in the tests. Replacing
97+
# with:
98+
#
99+
# from importlib.metadata import entry_points
100+
#
101+
# breaks this ability.
102+
103+
def decorator(group):
104+
if not isinstance(group, click.Group):
105+
raise TypeError(
106+
f"plugins can only be attached to an instance of"
107+
f" 'click.Group()' not: {repr(group)}")
108+
109+
# Load 'EntryPoint()' objects.
110+
if isinstance(entry_points, str):
111+
112+
# Older versions of Python do not support filtering.
113+
if sys.version_info >= (3, 10):
114+
all_entry_points = importlib.metadata.entry_points(
115+
group=entry_points)
116+
117+
else:
118+
all_entry_points = importlib.metadata.entry_points()
119+
all_entry_points = all_entry_points[entry_points]
120+
121+
# A single 'importlib.metadata.EntryPoint()'
122+
elif isinstance(entry_points, importlib.metadata.EntryPoint):
123+
all_entry_points = [entry_points]
124+
125+
# Sequence of 'EntryPoints()'.
126+
else:
127+
all_entry_points = entry_points
128+
129+
for ep in all_entry_points:
130+
131+
try:
132+
group.add_command(ep.load())
133+
134+
# Catch all exceptions (technically not 'BaseException') and
135+
# instead register a special 'BrokenCommand()'. Otherwise, a single
136+
# plugin that fails to load and/or register will make the CLI
137+
# inoperable. 'BrokenCommand()' explains the situation to users.
138+
except Exception as e:
139+
group.add_command(BrokenCommand(ep, e))
140+
141+
return group
142+
143+
return decorator
144+
145+
146+
class BrokenCommand(click.Command):
147+
148+
"""Represents a plugin ``click.Command()`` that failed to load.
149+
150+
Can be executed just like a ``click.Command()``, but prints information
151+
for debugging and exits with an error code.
152+
"""
153+
154+
def __init__(self, entry_point, exception):
155+
156+
"""
157+
:param importlib.metadata.EntryPoint entry_point:
158+
Entry point that failed to load.
159+
:param Exception exception:
160+
Raised when attempting to load the entry point associated with
161+
this instance.
162+
"""
163+
164+
super().__init__(entry_point.name)
165+
166+
# There are several ways to get a traceback from an exception, but
167+
# 'TracebackException()' seems to be the most portable across actively
168+
# supported versions of Python.
169+
tbe = traceback.TracebackException.from_exception(exception)
170+
171+
# A message for '$ cli command --help'. Contains full traceback and a
172+
# helpful note. The intention is to nudge users to figure out which
173+
# project should get a bug report since users are likely to report the
174+
# issue to the developers of the CLI utility they are directly
175+
# interacting with. These are not necessarily the right developers.
176+
self.help = (
177+
"{ls}ERROR: entry point '{module}:{name}' could not be loaded."
178+
" Contact its author for help.{ls}{ls}{tb}").format(
179+
module=_module(entry_point),
180+
name=entry_point.name,
181+
ls=os.linesep,
182+
tb=''.join(tbe.format())
183+
)
184+
185+
# Replace the broken command's summary with a warning about how it
186+
# was not loaded successfully. The idea is that '$ cli --help' should
187+
# include a clear indicator that a subcommand is not functional, and
188+
# a little hint for what to do about it. U+2020 is a "dagger", whose
189+
# modern use typically indicates a footnote.
190+
self.short_help = (
191+
f"\u2020 Warning: could not load plugin. Invoke command with"
192+
f" '--help' for traceback."
193+
)
194+
195+
def invoke(self, ctx):
196+
197+
"""Print traceback and debugging message.
198+
199+
:param click.Context ctx:
200+
Active context.
201+
"""
202+
203+
click.echo(self.help, color=ctx.color, err=True)
204+
ctx.exit(1)
205+
206+
def parse_args(self, ctx, args):
207+
208+
"""Pass arguments along without parsing.
209+
210+
:param click.Context ctx:
211+
Active context.
212+
:param list args:
213+
List of command line arguments.
214+
"""
215+
216+
# Do not attempt to parse these arguments. We do not know why the
217+
# entry point failed to load, but it is reasonable to assume that
218+
# argument parsing will not work. Ultimately the goal is to get the
219+
# 'Command.invoke()' method (overloaded in this class) to execute
220+
# and provide the user with a bit of debugging information.
221+
222+
return args
223+
224+
225+
def _module(ep):
226+
227+
"""Module name for a given entry point.
228+
229+
Parameters
230+
----------
231+
ep : importlib.metadata.EntryPoint
232+
Determine parent module for this entry point.
233+
234+
Returns
235+
-------
236+
str
237+
"""
238+
239+
if sys.version_info >= (3, 10):
240+
module = ep.module
241+
242+
else:
243+
# From 'importlib.metadata.EntryPoint.module'.
244+
match = ep.pattern.match(ep.value)
245+
module = match.group('module')
246+
247+
return module

0 commit comments

Comments
 (0)