Repository: pallets/click
Pull request #938 (2018-02-23 18:37:52)

Make core extendable by making classes and initializations configurable
I started to create a parallel implementation of Command/Group/etc. to support asyncio (#85) and some other features I need, one of the problems I had is that even if I can instruct `cls=MyCommand` in some calls, internally the modules use hardcoded classes in many cases.

The idea of the PR is not to support directly asyncio, but to open to the possibility of doing so. Comments are welcome.

txomon: Is there any way to push this forward?
davidism: Possibly fixes #561 
sirosen: I self assigned this a while ago, but never finished writing up feedback. We should address this need, but I'm not sure I'm sold on this particular implementation.

Making a class attribute for this feels clunky, IMO.
I wanted to explore other ways of doing this; potentially seeing if the information can be passed around on the Context or something.

I don't have a clear alternative -- nothing I wrote and tested, anyway -- and maybe this is the best way of doing it. I'd like to try for a cleaner approach though, to see if it's doable.
schollii: Is there a list of such classes? Other than the obvious `_textwrap.TextWrapper` of #1218. Perhaps a list of use cases would be useful in guiding this: 
- expose  `_textwrap.TextWrapper` for fill when paragraph contains lists (#1218). 
- TBD
Constantin07: Is there any chance to push this forward ?
davidism: To answer @sirosen, in general, the idea here is not to add any new behavior, but to make it easier for custom classes to persist through the stack. Right now, `@click.command(cls=CustomCommand)` can't actually customize a lot of behavior consistently because specific classes are hard-coded rather than using `super()`.

As far as I can tell, `command_class` isn't doing anything here that couldn't be replaced with `super()`. For now, I'll remove that. Additionally, there are many more places where `super()` can be used. I'll add those.
davidism: Added a `Context.formatter_class` attribute that will be used by `Context.make_formatter`.

I'm considering what the `Group.command` and `Group.group` decorators should do. `group` could set `cls=type(self)` by default, so sub-groups would use the same type. However, I'm not sure how this would affect existing code. For example, Flask has a top-level `FlaskGroup`, but I don't think it would make sense for it to create sub-groups of that type. Bringing back `Group.command_class` for use by `Group.command` might make sense, to have a group with all commands behaving consistently. In that case, we could also add `Group.group_class`, but that might be a little weird. A custom group class could still customize these by overriding the decorator methods to set the `cls` argument themselves, it's just a little more manual.
davidism: Decided to add `Group.command_class` and `Group.group_class`. `group_class` can be set to the special value `type` to indicate that it should use its own type. That way a custom group can continue creating custom groups, without needing to set up a circular reference (`CustomGroup.group_class = CustomGroup`).

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 420ee3e..df689a8 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -43,6 +43,22 @@ Unreleased
     converted to string. :pr:`1146`
 -   ``edit(require_save=True)`` will detect saves for editors that exit
     very fast on filesystems with 1 second resolution. :pr:`1050`
+-   New class attributes make it easier to use custom core objects
+    throughout an entire application. :pr:`938`
+
+    -   ``Command.context_class`` controls the context created when
+        running the command.
+    -   ``Context.invoke`` creates new contexts of the same type, so a
+        custom type will persist to invoked subcommands.
+    -   ``Context.formatter_class`` controls the formatter used to
+        generate help and usage.
+    -   ``Group.command_class`` changes the default type for
+        subcommands with ``@group.command()``.
+    -   ``Group.group_class`` changes the default type for subgroups
+        with ``@group.group()``. Setting it to ``type`` will create
+        subgroups of the same type as the group itself.
+    -   Core objects use ``super()`` consistently for better support of
+        subclassing.
 
 
 Version 7.1.2
diff --git a/src/click/_winconsole.py b/src/click/_winconsole.py
index d10ca5c..074dff7 100644
--- a/src/click/_winconsole.py
+++ b/src/click/_winconsole.py
@@ -113,7 +113,7 @@ class _WindowsConsoleRawIOBase(io.RawIOBase):
         self.handle = handle
 
     def isatty(self):
-        io.RawIOBase.isatty(self)
+        super().isatty()
         return True
 
 
diff --git a/src/click/core.py b/src/click/core.py
index 726c69d..310ae6f 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -245,6 +245,11 @@ class Context:
                     this command-level setting overrides it.
     """
 
+    #: The formatter class to create with :meth:`make_formatter`.
+    #:
+    #: .. versionadded:: 8.0
+    formatter_class = HelpFormatter
+
     def __init__(
         self,
         command,
@@ -475,8 +480,16 @@ class Context:
         return self._meta
 
     def make_formatter(self):
-        """Creates the formatter for the help and usage output."""
-        return HelpFormatter(
+        """Creates the :class:`~click.HelpFormatter` for the help and
+        usage output.
+
+        To quickly customize the formatter class used without overriding
+        this method, set the :attr:`formatter_class` attribute.
+
+        .. versionchanged:: 8.0
+            Added the :attr:`formatter_class` attribute.
+        """
+        return self.formatter_class(
             width=self.terminal_width, max_width=self.max_content_width
         )
 
@@ -601,12 +614,15 @@ class Context:
         if isinstance(callback, Command):
             other_cmd = callback
             callback = other_cmd.callback
-            ctx = Context(other_cmd, info_name=other_cmd.name, parent=self)
+
             if callback is None:
                 raise TypeError(
                     "The given command does not have a callback that can be invoked."
                 )
 
+            # Create a new context of the same type as this context.
+            ctx = type(self)(other_cmd, info_name=other_cmd.name, parent=self)
+
             for param in other_cmd.params:
                 if param.name not in kwargs and param.expose_value:
                     kwargs[param.name] = param.get_default(ctx)
@@ -623,8 +639,7 @@ class Context:
         """
         self, cmd = args[:2]
 
-        # It's also possible to invoke another command which might or
-        # might not have a callback.
+        # Can only forward to other commands, not direct callbacks.
         if not isinstance(cmd, Command):
             raise TypeError("Callback is not a command.")
 
@@ -686,6 +701,10 @@ class BaseCommand:
                              passed to the context object.
     """
 
+    #: The context class to create with :meth:`make_context`.
+    #:
+    #: .. versionadded:: 8.0
+    context_class = Context
     #: the default for the :attr:`Context.allow_extra_args` flag.
     allow_extra_args = False
     #: the default for the :attr:`Context.allow_interspersed_args` flag.
@@ -718,6 +737,9 @@ class BaseCommand:
         off the parsing and create a new :class:`Context`.  It does not
         invoke the actual command callback though.
 
+        To quickly customize the context class used without overriding
+        this method, set the :attr:`context_class` attribute.
+
         :param info_name: the info name for this invokation.  Generally this
                           is the most descriptive name for the script or
                           command.  For the toplevel script it's usually
@@ -727,11 +749,16 @@ class BaseCommand:
         :param parent: the parent context if available.
         :param extra: extra keyword arguments forwarded to the context
                       constructor.
+
+        .. versionchanged:: 8.0
+            Added the :attr:`context_class` attribute.
         """
         for key, value in self.context_settings.items():
             if key not in extra:
                 extra[key] = value
-        ctx = Context(self, info_name=info_name, parent=parent, **extra)
+
+        ctx = self.context_class(self, info_name=info_name, parent=parent, **extra)
+
         with ctx.scope(cleanup=False):
             self.parse_args(ctx, args)
         return ctx
@@ -909,7 +936,7 @@ class Command(BaseCommand):
         hidden=False,
         deprecated=False,
     ):
-        BaseCommand.__init__(self, name, context_settings)
+        super().__init__(name, context_settings)
         #: the callback to execute when the command fires.  This might be
         #: `None` in which case nothing happens.
         self.callback = callback
@@ -1138,7 +1165,7 @@ class MultiCommand(Command):
         result_callback=None,
         **attrs,
     ):
-        Command.__init__(self, name, **attrs)
+        super().__init__(name, **attrs)
         if no_args_is_help is None:
             no_args_is_help = not invoke_without_command
         self.no_args_is_help = no_args_is_help
@@ -1163,12 +1190,12 @@ class MultiCommand(Command):
                     )
 
     def collect_usage_pieces(self, ctx):
-        rv = Command.collect_usage_pieces(self, ctx)
+        rv = super().collect_usage_pieces(ctx)
         rv.append(self.subcommand_metavar)
         return rv
 
     def format_options(self, ctx, formatter):
-        Command.format_options(self, ctx, formatter)
+        super().format_options(ctx, formatter)
         self.format_commands(ctx, formatter)
 
     def resultcallback(self, replace=False):
@@ -1244,7 +1271,8 @@ class MultiCommand(Command):
             echo(ctx.get_help(), color=ctx.color)
             ctx.exit()
 
-        rest = Command.parse_args(self, ctx, args)
+        rest = super().parse_args(ctx, args)
+
         if self.chain:
             ctx.protected_args = rest
             ctx.args = []
@@ -1265,7 +1293,7 @@ class MultiCommand(Command):
                 # invoked with None for regular groups, or an empty list
                 # for chained groups.
                 with ctx:
-                    Command.invoke(self, ctx)
+                    super().invoke(ctx)
                     return _process_result([] if self.chain else None)
             ctx.fail("Missing command.")
 
@@ -1283,7 +1311,7 @@ class MultiCommand(Command):
             with ctx:
                 cmd_name, cmd, args = self.resolve_command(ctx, args)
                 ctx.invoked_subcommand = cmd_name
-                Command.invoke(self, ctx)
+                super().invoke(ctx)
                 sub_ctx = cmd.make_context(cmd_name, args, parent=ctx)
                 with sub_ctx:
                     return _process_result(sub_ctx.command.invoke(sub_ctx))
@@ -1295,7 +1323,7 @@ class MultiCommand(Command):
         # but nothing else.
         with ctx:
             ctx.invoked_subcommand = "*" if args else None
-            Command.invoke(self, ctx)
+            super().invoke(ctx)
 
             # Otherwise we make every single context and invoke them in a
             # chain.  In that case the return value to the result processor
@@ -1365,8 +1393,27 @@ class Group(MultiCommand):
     :param commands: a dictionary of commands.
     """
 
+    #: If set, this is used by the group's :meth:`command` decorator
+    #: as the default :class:`Command` class. This is useful to make all
+    #: subcommands use a custom command class.
+    #:
+    #: .. versionadded:: 8.0
+    command_class = None
+
+    #: If set, this is used by the group's :meth:`group` decorator
+    #: as the default :class:`Group` class. This is useful to make all
+    #: subgroups use a custom group class.
+    #:
+    #: If set to the special value :class:`type` (literally
+    #: ``group_class = type``), this group's class will be used as the
+    #: default class. This makes a custom group class continue to make
+    #: custom groups.
+    #:
+    #: .. versionadded:: 8.0
+    group_class = None
+
     def __init__(self, name=None, commands=None, **attrs):
-        MultiCommand.__init__(self, name, **attrs)
+        super().__init__(name, **attrs)
         #: the registered subcommands by their exported names.
         self.commands = commands or {}
 
@@ -1382,12 +1429,21 @@ class Group(MultiCommand):
 
     def command(self, *args, **kwargs):
         """A shortcut decorator for declaring and attaching a command to
-        the group.  This takes the same arguments as :func:`command` but
-        immediately registers the created command with this instance by
-        calling into :meth:`add_command`.
+        the group. This takes the same arguments as :func:`command` and
+        immediately registers the created command with this group by
+        calling :meth:`add_command`.
+
+        To customize the command class used, set the
+        :attr:`command_class` attribute.
+
+        .. versionchanged:: 8.0
+            Added the :attr:`command_class` attribute.
         """
         from .decorators import command
 
+        if self.command_class is not None and "cls" not in kwargs:
+            kwargs["cls"] = self.command_class
+
         def decorator(f):
             cmd = command(*args, **kwargs)(f)
             self.add_command(cmd)
@@ -1397,12 +1453,24 @@ class Group(MultiCommand):
 
     def group(self, *args, **kwargs):
         """A shortcut decorator for declaring and attaching a group to
-        the group.  This takes the same arguments as :func:`group` but
-        immediately registers the created command with this instance by
-        calling into :meth:`add_command`.
+        the group. This takes the same arguments as :func:`group` and
+        immediately registers the created group with this group by
+        calling :meth:`add_command`.
+
+        To customize the group class used, set the :attr:`group_class`
+        attribute.
+
+        .. versionchanged:: 8.0
+            Added the :attr:`group_class` attribute.
         """
         from .decorators import group
 
+        if self.group_class is not None and "cls" not in kwargs:
+            if self.group_class is type:
+                kwargs["cls"] = type(self)
+            else:
+                kwargs["cls"] = self.group_class
+
         def decorator(f):
             cmd = group(*args, **kwargs)(f)
             self.add_command(cmd)
@@ -1425,7 +1493,7 @@ class CommandCollection(MultiCommand):
     """
 
     def __init__(self, name=None, sources=None, **attrs):
-        MultiCommand.__init__(self, name, **attrs)
+        super().__init__(name, **attrs)
         #: The list of registered multi commands.
         self.sources = sources or []
 
@@ -1763,7 +1831,7 @@ class Option(Parameter):
         **attrs,
     ):
         default_is_missing = attrs.get("default", _missing) is _missing
-        Parameter.__init__(self, param_decls, type=type, **attrs)
+        super().__init__(param_decls, type=type, **attrs)
 
         if prompt is True:
             prompt_text = self.name.replace("_", " ").capitalize()
@@ -1980,7 +2048,8 @@ class Option(Parameter):
                 if param.name == self.name and param.default:
                     return param.flag_value
             return None
-        return Parameter.get_default(self, ctx)
+
+        return super().get_default(ctx)
 
     def prompt_for_value(self, ctx):
         """This is an alternative flow that can be activated in the full
@@ -2007,7 +2076,8 @@ class Option(Parameter):
         )
 
     def resolve_envvar_value(self, ctx):
-        rv = Parameter.resolve_envvar_value(self, ctx)
+        rv = super().resolve_envvar_value(ctx)
+
         if rv is not None:
             return rv
         if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None:
@@ -2028,7 +2098,8 @@ class Option(Parameter):
     def full_process_value(self, ctx, value):
         if value is None and self.prompt is not None and not ctx.resilient_parsing:
             return self.prompt_for_value(ctx)
-        return Parameter.full_process_value(self, ctx, value)
+
+        return super().full_process_value(ctx, value)
 
 
 class Argument(Parameter):
@@ -2047,7 +2118,9 @@ class Argument(Parameter):
                 required = False
             else:
                 required = attrs.get("nargs", 1) > 0
-        Parameter.__init__(self, param_decls, required=required, **attrs)
+
+        super().__init__(param_decls, required=required, **attrs)
+
         if self.default is not None and self.nargs < 0:
             raise TypeError(
                 "nargs=-1 in combination with a default value is not supported."
diff --git a/src/click/exceptions.py b/src/click/exceptions.py
index 25b02bb..9623cd8 100644
--- a/src/click/exceptions.py
+++ b/src/click/exceptions.py
@@ -43,7 +43,7 @@ class UsageError(ClickException):
     exit_code = 2
 
     def __init__(self, message, ctx=None):
-        ClickException.__init__(self, message)
+        super().__init__(message)
         self.ctx = ctx
         self.cmd = self.ctx.command if self.ctx else None
 
@@ -82,7 +82,7 @@ class BadParameter(UsageError):
     """
 
     def __init__(self, message, ctx=None, param=None, param_hint=None):
-        UsageError.__init__(self, message, ctx)
+        super().__init__(message, ctx)
         self.param = param
         self.param_hint = param_hint
 
@@ -113,7 +113,7 @@ class MissingParameter(BadParameter):
     def __init__(
         self, message=None, ctx=None, param=None, param_hint=None, param_type=None
     ):
-        BadParameter.__init__(self, message, ctx, param, param_hint)
+        super().__init__(message, ctx, param, param_hint)
         self.param_type = param_type
 
     def format_message(self):
@@ -159,7 +159,8 @@ class NoSuchOption(UsageError):
     def __init__(self, option_name, message=None, possibilities=None, ctx=None):
         if message is None:
             message = f"no such option: {option_name}"
-        UsageError.__init__(self, message, ctx)
+
+        super().__init__(message, ctx)
         self.option_name = option_name
         self.possibilities = possibilities
 
@@ -185,7 +186,7 @@ class BadOptionUsage(UsageError):
     """
 
     def __init__(self, option_name, message, ctx=None):
-        UsageError.__init__(self, message, ctx)
+        super().__init__(message, ctx)
         self.option_name = option_name
 
 
@@ -197,9 +198,6 @@ class BadArgumentUsage(UsageError):
     .. versionadded:: 6.0
     """
 
-    def __init__(self, message, ctx=None):
-        UsageError.__init__(self, message, ctx)
-
 
 class FileError(ClickException):
     """Raised if a file cannot be opened."""
@@ -208,7 +206,8 @@ class FileError(ClickException):
         ui_filename = filename_to_ui(filename)
         if hint is None:
             hint = "unknown error"
-        ClickException.__init__(self, hint)
+
+        super().__init__(hint)
         self.ui_filename = ui_filename
         self.filename = filename
 
diff --git a/src/click/types.py b/src/click/types.py
index d50786a..910cd9d 100644
--- a/src/click/types.py
+++ b/src/click/types.py
@@ -278,7 +278,8 @@ class IntRange(IntParamType):
         self.clamp = clamp
 
     def convert(self, value, param, ctx):
-        rv = IntParamType.convert(self, value, param, ctx)
+        rv = super().convert(value, param, ctx)
+
         if self.clamp:
             if self.min is not None and rv < self.min:
                 return self.min
@@ -344,7 +345,8 @@ class FloatRange(FloatParamType):
         self.clamp = clamp
 
     def convert(self, value, param, ctx):
-        rv = FloatParamType.convert(self, value, param, ctx)
+        rv = super().convert(value, param, ctx)
+
         if self.clamp:
             if self.min is not None and rv < self.min:
                 return self.min


Test patch:
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 2467c73..c8da4ce 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -133,7 +133,7 @@ def test_base_command(runner):
 
     class OptParseCommand(click.BaseCommand):
         def __init__(self, name, parser, callback):
-            click.BaseCommand.__init__(self, name)
+            super().__init__(name)
             self.parser = parser
             self.callback = callback
 
diff --git a/tests/test_custom_classes.py b/tests/test_custom_classes.py
new file mode 100644
index 0000000..314842b
--- /dev/null
+++ b/tests/test_custom_classes.py
@@ -0,0 +1,112 @@
+import click
+
+
+def test_command_context_class():
+    """A command with a custom ``context_class`` should produce a
+    context using that type.
+    """
+
+    class CustomContext(click.Context):
+        pass
+
+    class CustomCommand(click.Command):
+        context_class = CustomContext
+
+    command = CustomCommand("test")
+    context = command.make_context("test", [])
+    assert isinstance(context, CustomContext)
+
+
+def test_context_invoke_type(runner):
+    """A command invoked from a custom context should have a new
+    context with the same type.
+    """
+
+    class CustomContext(click.Context):
+        pass
+
+    class CustomCommand(click.Command):
+        context_class = CustomContext
+
+    @click.command()
+    @click.argument("first_id", type=int)
+    @click.pass_context
+    def second(ctx, first_id):
+        assert isinstance(ctx, CustomContext)
+        assert id(ctx) != first_id
+
+    @click.command(cls=CustomCommand)
+    @click.pass_context
+    def first(ctx):
+        assert isinstance(ctx, CustomContext)
+        ctx.invoke(second, first_id=id(ctx))
+
+    assert not runner.invoke(first).exception
+
+
+def test_context_formatter_class():
+    """A context with a custom ``formatter_class`` should format help
+    using that type.
+    """
+
+    class CustomFormatter(click.HelpFormatter):
+        def write_heading(self, heading):
+            heading = click.style(heading, fg="yellow")
+            return super().write_heading(heading)
+
+    class CustomContext(click.Context):
+        formatter_class = CustomFormatter
+
+    context = CustomContext(
+        click.Command("test", params=[click.Option(["--value"])]), color=True
+    )
+    assert "\x1b[33mOptions\x1b[0m:" in context.get_help()
+
+
+def test_group_command_class(runner):
+    """A group with a custom ``command_class`` should create subcommands
+    of that type by default.
+    """
+
+    class CustomCommand(click.Command):
+        pass
+
+    class CustomGroup(click.Group):
+        command_class = CustomCommand
+
+    group = CustomGroup()
+    subcommand = group.command()(lambda: None)
+    assert type(subcommand) is CustomCommand
+    subcommand = group.command(cls=click.Command)(lambda: None)
+    assert type(subcommand) is click.Command
+
+
+def test_group_group_class(runner):
+    """A group with a custom ``group_class`` should create subgroups
+    of that type by default.
+    """
+
+    class CustomSubGroup(click.Group):
+        pass
+
+    class CustomGroup(click.Group):
+        group_class = CustomSubGroup
+
+    group = CustomGroup()
+    subgroup = group.group()(lambda: None)
+    assert type(subgroup) is CustomSubGroup
+    subgroup = group.command(cls=click.Group)(lambda: None)
+    assert type(subgroup) is click.Group
+
+
+def test_group_group_class_self(runner):
+    """A group with ``group_class = type`` should create subgroups of
+    the same type as itself.
+    """
+
+    class CustomGroup(click.Group):
+        group_class = type
+
+    group = CustomGroup()
+    subgroup = group.group()(lambda: None)
+    assert type(subgroup) is CustomGroup