|
| 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 |
0 commit comments