Skip to content

Commit 0cbe18c

Browse files
committed
Add new commands module, and move SuggestionGroup there.
1 parent 2773291 commit 0cbe18c

20 files changed

+817
-43
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,4 @@ profile_default/
8585
ipython_config.py
8686
Pipfile.lock
8787
.pyre/
88-
consolekit/command.py
88+
consolekit/_command.py

consolekit/__init__.py

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -65,46 +65,6 @@
6565
"SuggestionGroup",
6666
]
6767

68-
69-
class SuggestionGroup(click.Group):
70-
"""
71-
Subclass of :class:`click.Group` which suggests the most similar command if the command is not found.
72-
73-
.. versionadded 0.7.1
74-
"""
75-
76-
def resolve_command(self, ctx, args): # noqa: D102
77-
cmd_name = make_str(args[0])
78-
original_cmd_name = cmd_name
79-
80-
# Get the command
81-
cmd = self.get_command(ctx, cmd_name)
82-
83-
# If we can't find the command but there is a normalization
84-
# function available, we try with that one.
85-
if cmd is None and ctx.token_normalize_func is not None:
86-
cmd_name = ctx.token_normalize_func(cmd_name)
87-
cmd = self.get_command(ctx, cmd_name)
88-
89-
# If we don't find the command we want to show an error message
90-
# to the user that it was not provided. However, there is
91-
# something else we should do: if the first argument looks like
92-
# an option we want to kick off parsing again for arguments to
93-
# resolve things like --help which now should go to the main
94-
# place.
95-
if cmd is None and not ctx.resilient_parsing:
96-
if split_opt(cmd_name)[0]:
97-
self.parse_args(ctx, ctx.args)
98-
99-
closest = difflib.get_close_matches(original_cmd_name, self.commands, n=1)
100-
message = [f"No such command '{original_cmd_name}'."]
101-
if closest:
102-
message.append(f"The most similar command is {closest[0]!r}.")
103-
ctx.fail('\n'.join(message))
104-
105-
return cmd_name, cmd, args[1:]
106-
107-
10868
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"], max_content_width=120)
10969
click_command = partial(click.command, context_settings=CONTEXT_SETTINGS)
11070
click_group = partial(click.group, context_settings=CONTEXT_SETTINGS, cls=SuggestionGroup)

consolekit/commands.py

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
#!/usr/bin/env python3
2+
#
3+
# commands.py
4+
"""
5+
Customised click commands and command groups.
6+
7+
.. versionadded:: 0.8.0
8+
"""
9+
#
10+
# Copyright © 2020 Dominic Davis-Foster <[email protected]>
11+
#
12+
# Permission is hereby granted, free of charge, to any person obtaining a copy
13+
# of this software and associated documentation files (the "Software"), to deal
14+
# in the Software without restriction, including without limitation the rights
15+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16+
# copies of the Software, and to permit persons to whom the Software is
17+
# furnished to do so, subject to the following conditions:
18+
#
19+
# The above copyright notice and this permission notice shall be included in all
20+
# copies or substantial portions of the Software.
21+
#
22+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
23+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
24+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
25+
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
26+
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
27+
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
28+
# OR OTHER DEALINGS IN THE SOFTWARE.
29+
#
30+
# MarkdownHelpCommand.parse_args based on https://github.com/pallets/click
31+
# Copyright 2014 Pallets
32+
# | Redistribution and use in source and binary forms, with or without modification,
33+
# | are permitted provided that the following conditions are met:
34+
# |
35+
# | * Redistributions of source code must retain the above copyright notice,
36+
# | this list of conditions and the following disclaimer.
37+
# | * Redistributions in binary form must reproduce the above copyright notice,
38+
# | this list of conditions and the following disclaimer in the documentation
39+
# | and/or other materials provided with the distribution.
40+
# | * Neither the name of the copyright holder nor the names of its contributors
41+
# | may be used to endorse or promote products derived from this software without
42+
# | specific prior written permission.
43+
# |
44+
# | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
45+
# | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
46+
# | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
47+
# | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER
48+
# | OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
49+
# | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
50+
# | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
51+
# | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
52+
# | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
53+
# | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
54+
# | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
55+
#
56+
57+
# stdlib
58+
import difflib
59+
from textwrap import indent
60+
from typing import List, Optional, Tuple
61+
62+
# 3rd party
63+
import click
64+
from click.core import iter_params_for_processing
65+
from click.parser import split_opt
66+
from click.utils import make_str
67+
from domdf_python_tools.stringlist import DelimitedList
68+
from domdf_python_tools.words import Plural
69+
from mistletoe import block_token # type: ignore
70+
71+
# this package
72+
from consolekit.terminal_colours import ColourTrilean, resolve_color_default, strip_ansi
73+
74+
__all__ = [
75+
"MarkdownHelpCommand",
76+
"MarkdownHelpGroup",
77+
"MarkdownHelpMixin",
78+
"RawHelpCommand",
79+
"RawHelpGroup",
80+
"RawHelpMixin",
81+
"SuggestionGroup",
82+
]
83+
84+
# this package
85+
from consolekit.utils import TerminalRenderer
86+
87+
_argument = Plural("argument", "arguments")
88+
89+
90+
class RawHelpMixin:
91+
"""
92+
Mixin class for :class:`click.Command` and :class:`click.Group` which leaves the help text unformatted.
93+
94+
.. seealso::
95+
96+
* :class:`~.RawHelpCommand`
97+
* :class:`~.RawHelpGroup`
98+
99+
.. tip:: This can be combined with groups such as :class:`~.SuggestionGroup`.
100+
101+
.. versionadded:: 0.8.0
102+
"""
103+
104+
help: Optional[str] # noqa: A003 # pylint: disable=redefined-builtin
105+
106+
def format_help_text(self, ctx: click.Context, formatter: click.formatting.HelpFormatter):
107+
"""
108+
Writes the help text to the formatter if it exists.
109+
110+
:param ctx:
111+
:param formatter:
112+
"""
113+
114+
formatter.write('\n')
115+
formatter.write(indent((self.help or ''), " "))
116+
formatter.write('\n')
117+
118+
119+
class RawHelpCommand(RawHelpMixin, click.Command):
120+
"""
121+
Subclass of :class:`click.Command` which leaves the help text unformatted.
122+
123+
.. versionadded:: 0.8.0
124+
"""
125+
126+
127+
class RawHelpGroup(RawHelpMixin, click.Group):
128+
"""
129+
Subclass of :class:`click.Group` which leaves the help text unformatted.
130+
131+
.. versionadded:: 0.8.0
132+
"""
133+
134+
135+
class MarkdownHelpMixin:
136+
"""
137+
Mixin class for :class:`click.Command` and :class:`click.Group` which treats the help text as markdown
138+
and prints a rendered representation.
139+
140+
.. seealso::
141+
142+
* :class:`~.MarkdownHelpCommand`
143+
* :class:`~.MarkdownHelpGroup`
144+
145+
.. tip:: This can be combined with groups such as :class:`~.SuggestionGroup`.
146+
147+
.. versionadded:: 0.8.0
148+
"""
149+
150+
help: Optional[str] # noqa: A003 # pylint: disable=redefined-builtin
151+
no_args_is_help: bool
152+
_colour: ColourTrilean = None
153+
154+
def format_help_text(self, ctx: click.Context, formatter: click.formatting.HelpFormatter):
155+
"""
156+
Writes the help text to the formatter if it exists.
157+
158+
:param ctx:
159+
:param formatter:
160+
"""
161+
162+
doc = block_token.Document(self.help or '')
163+
164+
with TerminalRenderer() as renderer:
165+
rendered_doc = indent(renderer.render(doc), " ")
166+
167+
if resolve_color_default(self._colour) is False:
168+
# Also remove 'COMBINING LONG STROKE OVERLAY', used for strikethrough.
169+
rendered_doc = strip_ansi(rendered_doc).replace('̶', '')
170+
171+
formatter.write('\n')
172+
formatter.write(rendered_doc)
173+
formatter.write('\n')
174+
175+
176+
class MarkdownHelpCommand(MarkdownHelpMixin, click.Command):
177+
"""
178+
Subclass of :class:`click.Command` which treats the help text as markdown
179+
and prints a rendered representation.
180+
181+
.. versionadded:: 0.8.0
182+
""" # noqa: D400
183+
184+
def parse_args(self, ctx: click.Context, args: List[str]) -> List[str]:
185+
"""
186+
Parse the given arguments and modify the context as necessary.
187+
188+
:param ctx:
189+
:param args:
190+
"""
191+
192+
# This is necessary to parse any --colour/--no-colour commands before generating the help,
193+
# to ensure the option is honoured.
194+
195+
if not args and self.no_args_is_help and not ctx.resilient_parsing:
196+
click.echo(ctx.get_help(), color=ctx.color)
197+
ctx.exit()
198+
199+
parser = self.make_parser(ctx)
200+
opts, args, param_order = parser.parse_args(args=args)
201+
202+
self._colour = opts.get("colour", ctx.color)
203+
204+
for param in iter_params_for_processing(param_order, self.get_params(ctx)):
205+
value, args = param.handle_parse_result(ctx, opts, args)
206+
207+
if args and not ctx.allow_extra_args and not ctx.resilient_parsing:
208+
args_string = DelimitedList(map(make_str, args))
209+
ctx.fail(f"Got unexpected extra {_argument(len(args))} ({args_string: })")
210+
211+
ctx.args = args
212+
return args
213+
214+
215+
class MarkdownHelpGroup(MarkdownHelpMixin, click.Group):
216+
"""
217+
Subclass of :class:`click.Group` which treats the help text as markdown
218+
and prints a rendered representation.
219+
220+
.. versionadded:: 0.8.0
221+
""" # noqa: D400
222+
223+
def parse_args(self, ctx: click.Context, args: List[str]) -> List[str]:
224+
"""
225+
Parse the given arguments and modify the context as necessary.
226+
227+
:param ctx:
228+
:param args:
229+
"""
230+
231+
# This is necessary to parse any --colour/--no-colour commands before generating the help,
232+
# to ensure the option is honoured.
233+
234+
if not args and self.no_args_is_help and not ctx.resilient_parsing:
235+
click.echo(ctx.get_help(), color=ctx.color)
236+
ctx.exit()
237+
238+
rest = MarkdownHelpCommand.parse_args(self, ctx, args) # type: ignore
239+
if self.chain:
240+
ctx.protected_args = rest
241+
ctx.args = []
242+
elif rest:
243+
ctx.protected_args, ctx.args = rest[:1], rest[1:]
244+
245+
return ctx.args
246+
247+
248+
class SuggestionGroup(click.Group):
249+
"""
250+
Subclass of :class:`click.Group` which suggests the most similar command if the command is not found.
251+
252+
.. versionadded 0.2.0
253+
254+
.. versionchanged:: 0.8.0
255+
256+
Moved to :mod:`consolekit.commands`.
257+
"""
258+
259+
def resolve_command(self, ctx: click.Context,
260+
args: List[str]) -> Tuple[str, click.Command, List[str]]: # noqa: D102
261+
"""
262+
Resolve the requested command belonging to this group, and print a suggestion if it can't be found.
263+
264+
:param ctx:
265+
:param args:
266+
267+
:return: The name of the matching command,
268+
the :class:`click.Command` object itself,
269+
and any remaining arguments.
270+
"""
271+
272+
cmd_name = make_str(args[0])
273+
original_cmd_name = cmd_name
274+
275+
# Get the command
276+
cmd = self.get_command(ctx, cmd_name)
277+
278+
# If we can't find the command but there is a normalization
279+
# function available, we try with that one.
280+
if cmd is None and ctx.token_normalize_func is not None:
281+
cmd_name = ctx.token_normalize_func(cmd_name)
282+
cmd = self.get_command(ctx, cmd_name)
283+
284+
# If we don't find the command we want to show an error message
285+
# to the user that it was not provided.
286+
# However, there is something else we should do:
287+
# if the first argument looks like an option we want to kick off parsing again
288+
# for arguments to resolve things like --help which now should go to the main place.
289+
if cmd is None and not ctx.resilient_parsing:
290+
if split_opt(cmd_name)[0]:
291+
self.parse_args(ctx, ctx.args)
292+
293+
closest = difflib.get_close_matches(original_cmd_name, self.commands, n=1)
294+
message = [f"No such command '{original_cmd_name}'."]
295+
if closest:
296+
message.append(f"The most similar command is {closest[0]!r}.")
297+
ctx.fail('\n'.join(message))
298+
299+
# TODO: cmd here is Optional[click.Command], typeshed says it should be just click.Command
300+
# I think typeshed is wrong.
301+
# https://github.com/python/typeshed/blob/484c014665cdf071b292dd9630f207c03e111895/third_party/2and3/click/core.pyi#L171
302+
return cmd_name, cmd, args[1:] # type: ignore

consolekit/terminal_colours.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"Style",
9898
"Cursor",
9999
"strip_ansi",
100+
"ColourTrilean",
100101
]
101102

102103
try:
@@ -106,6 +107,13 @@
106107
except ImportError:
107108
pass
108109

110+
ColourTrilean = Optional[bool]
111+
"""
112+
Represents the :py:obj:`True`/:py:obj:`False`/:py:obj:`None` state of colour options.
113+
114+
.. versionadded:: 0.8.0
115+
"""
116+
109117
CSI: Final[str] = "\u001b["
110118
OSC: Final[str] = "\u001b]"
111119
BEL: Final[str] = '\x07'

consolekit/terminal_colours.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ from typing import List, Optional
7070
# 3rd party
7171
from typing_extensions import Final
7272

73+
ColourTrilean = Optional[bool]
74+
7375
CSI: Final[str]
7476
OSC: Final[str]
7577
BEL: Final[str]

0 commit comments

Comments
 (0)