Repository: pallets/click

Issue:
Export JSON Data of Click Structure
For external tools it would be nice if one could export the entire data of a click command into a JSON format.  For instance that way one could generate externally hosted documentation pages for commands that use better formatting than what `--help` generates.



Discussion:
If we were to do this in Click core, as opposed to a separate project, I'd imagine we'd add a `to_info_dict()` method to `BaseCommand`, `Parameter`, and `ParamType`, overriding as necessary. Is there something specific that's not easy to introspect currently, though? And how detailed should the dict be? Should it include every parameter? Computed attributes like `auto_envvar_prefix`?

A separate project could take advantage of other libraries like Marshmallow to give the serialized data more structure as well. I guess I'm not opposed to it, and implementing it here in some form doesn't prevent another project from extending it or offering a different method.
@chrisngyn and I will be working on this!
For example, `group.to_info_dict()` would put the group name, help, etc. in, then call `command.to_info_dict()` for each command returned by `list_commands()`, and put that list under a "commands" key. So any given object would build itself plus anything under it, and a subclass could override what happens.

I think there are some other open issues about being able to traverse the structure without triggering prompts and validation, since that functionality exists but isn't perfect. As a first pass, don't worry about these, note them down and they can be addressed separately.

Resolving patch:
diff --git a/src/click/core.py b/src/click/core.py
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -413,6 +413,27 @@ def __init__(
         self._source_by_paramname = {}
         self._exit_stack = ExitStack()
 
+    def to_info_dict(self):
+        """Gather information that could be useful for a tool generating
+        user-facing documentation. This traverses the entire CLI
+        structure.
+
+        .. code-block:: python
+
+            with Context(cli) as ctx:
+                info = ctx.to_info_dict()
+
+        .. versionadded:: 8.0
+        """
+        return {
+            "command": self.command.to_info_dict(self),
+            "info_name": self.info_name,
+            "allow_extra_args": self.allow_extra_args,
+            "allow_interspersed_args": self.allow_interspersed_args,
+            "ignore_unknown_options": self.ignore_unknown_options,
+            "auto_envvar_prefix": self.auto_envvar_prefix,
+        }
+
     def __enter__(self):
         self._depth += 1
         push_context(self)
@@ -632,6 +653,14 @@ def get_help(self):
         """
         return self.command.get_help(self)
 
+    def _make_sub_context(self, command):
+        """Create a new context of the same type as this context, but
+        for a new command.
+
+        :meta private:
+        """
+        return type(self)(command, info_name=command.name, parent=self)
+
     def invoke(*args, **kwargs):  # noqa: B902
         """Invokes a command callback in exactly the way it expects.  There
         are two ways to invoke this method:
@@ -663,8 +692,7 @@ def invoke(*args, **kwargs):  # noqa: B902
                     "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)
+            ctx = self._make_sub_context(other_cmd)
 
             for param in other_cmd.params:
                 if param.name not in kwargs and param.expose_value:
@@ -766,6 +794,20 @@ def __init__(self, name, context_settings=None):
         #: an optional dictionary with defaults passed to the context.
         self.context_settings = context_settings
 
+    def to_info_dict(self, ctx):
+        """Gather information that could be useful for a tool generating
+        user-facing documentation. This traverses the entire structure
+        below this command.
+
+        Use :meth:`click.Context.to_info_dict` to traverse the entire
+        CLI structure.
+
+        :param ctx: A :class:`Context` representing this command.
+
+        .. versionadded:: 8.0
+        """
+        return {"name": self.name}
+
     def __repr__(self):
         return f"<{self.__class__.__name__} {self.name}>"
 
@@ -1000,6 +1042,18 @@ def __init__(
         self.hidden = hidden
         self.deprecated = deprecated
 
+    def to_info_dict(self, ctx):
+        info_dict = super().to_info_dict(ctx)
+        info_dict.update(
+            params=[param.to_info_dict() for param in self.get_params(ctx)],
+            help=self.help,
+            epilog=self.epilog,
+            short_help=self.short_help,
+            hidden=self.hidden,
+            deprecated=self.deprecated,
+        )
+        return info_dict
+
     def __repr__(self):
         return f"<{self.__class__.__name__} {self.name}>"
 
@@ -1232,6 +1286,20 @@ def __init__(
                         " optional arguments."
                     )
 
+    def to_info_dict(self, ctx):
+        info_dict = super().to_info_dict(ctx)
+        commands = {}
+
+        for name in self.list_commands(ctx):
+            command = self.get_command(ctx, name)
+            sub_ctx = ctx._make_sub_context(command)
+
+            with sub_ctx.scope(cleanup=False):
+                commands[name] = command.to_info_dict(sub_ctx)
+
+        info_dict.update(commands=commands, chain=self.chain)
+        return info_dict
+
     def collect_usage_pieces(self, ctx):
         rv = super().collect_usage_pieces(ctx)
         rv.append(self.subcommand_metavar)
@@ -1661,6 +1729,28 @@ def __init__(
         self.envvar = envvar
         self.autocompletion = autocompletion
 
+    def to_info_dict(self):
+        """Gather information that could be useful for a tool generating
+        user-facing documentation.
+
+        Use :meth:`click.Context.to_info_dict` to traverse the entire
+        CLI structure.
+
+        .. versionadded:: 8.0
+        """
+        return {
+            "name": self.name,
+            "param_type_name": self.param_type_name,
+            "opts": self.opts,
+            "secondary_opts": self.secondary_opts,
+            "type": self.type.to_info_dict(),
+            "required": self.required,
+            "nargs": self.nargs,
+            "multiple": self.multiple,
+            "default": self.default,
+            "envvar": self.envvar,
+        }
+
     def __repr__(self):
         return f"<{self.__class__.__name__} {self.name}>"
 
@@ -1955,6 +2045,18 @@ def __init__(
                         "Options cannot be count and flags at the same time."
                     )
 
+    def to_info_dict(self):
+        info_dict = super().to_info_dict()
+        info_dict.update(
+            help=self.help,
+            prompt=self.prompt,
+            is_flag=self.is_flag,
+            flag_value=self.flag_value,
+            count=self.count,
+            hidden=self.hidden,
+        )
+        return info_dict
+
     def _parse_decls(self, decls, expose_value):
         opts = []
         secondary_opts = []
diff --git a/src/click/types.py b/src/click/types.py
--- a/src/click/types.py
+++ b/src/click/types.py
@@ -43,6 +43,20 @@ class ParamType:
     #: Windows).
     envvar_list_splitter = None
 
+    def to_info_dict(self):
+        """Gather information that could be useful for a tool generating
+        user-facing documentation.
+
+        Use :meth:`click.Context.to_info_dict` to traverse the entire
+        CLI structure.
+
+        .. versionadded:: 8.0
+        """
+        # The class name without the "ParamType" suffix.
+        param_type = type(self).__name__.partition("ParamType")[0]
+        param_type = param_type.partition("ParameterType")[0]
+        return {"param_type": param_type, "name": self.name}
+
     def __call__(self, value, param=None, ctx=None):
         if value is not None:
             return self.convert(value, param, ctx)
@@ -107,6 +121,11 @@ def __init__(self, func):
         self.name = func.__name__
         self.func = func
 
+    def to_info_dict(self):
+        info_dict = super().to_info_dict()
+        info_dict["func"] = self.func
+        return info_dict
+
     def convert(self, value, param, ctx):
         try:
             return self.func(value)
@@ -176,6 +195,12 @@ def __init__(self, choices, case_sensitive=True):
         self.choices = choices
         self.case_sensitive = case_sensitive
 
+    def to_info_dict(self):
+        info_dict = super().to_info_dict()
+        info_dict["choices"] = self.choices
+        info_dict["case_sensitive"] = self.case_sensitive
+        return info_dict
+
     def get_metavar(self, param):
         choices_str = "|".join(self.choices)
 
@@ -251,6 +276,11 @@ class DateTime(ParamType):
     def __init__(self, formats=None):
         self.formats = formats or ["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"]
 
+    def to_info_dict(self):
+        info_dict = super().to_info_dict()
+        info_dict["formats"] = self.formats
+        return info_dict
+
     def get_metavar(self, param):
         return f"[{'|'.join(self.formats)}]"
 
@@ -293,6 +323,17 @@ def __init__(self, min=None, max=None, min_open=False, max_open=False, clamp=Fal
         self.max_open = max_open
         self.clamp = clamp
 
+    def to_info_dict(self):
+        info_dict = super().to_info_dict()
+        info_dict.update(
+            min=self.min,
+            max=self.max,
+            min_open=self.min_open,
+            max_open=self.max_open,
+            clamp=self.clamp,
+        )
+        return info_dict
+
     def convert(self, value, param, ctx):
         import operator
 
@@ -492,6 +533,11 @@ def __init__(
         self.lazy = lazy
         self.atomic = atomic
 
+    def to_info_dict(self):
+        info_dict = super().to_info_dict()
+        info_dict.update(mode=self.mode, encoding=self.encoding)
+        return info_dict
+
     def resolve_lazy_flag(self, value):
         if self.lazy is not None:
             return self.lazy
@@ -601,6 +647,18 @@ def __init__(
             self.name = "path"
             self.path_type = "Path"
 
+    def to_info_dict(self):
+        info_dict = super().to_info_dict()
+        info_dict.update(
+            exists=self.exists,
+            file_okay=self.file_okay,
+            dir_okay=self.dir_okay,
+            writable=self.writable,
+            readable=self.readable,
+            allow_dash=self.allow_dash,
+        )
+        return info_dict
+
     def coerce_path_result(self, rv):
         if self.type is not None and not isinstance(rv, self.type):
             if self.type is str:
@@ -674,6 +732,11 @@ class Tuple(CompositeParamType):
     def __init__(self, types):
         self.types = [convert_type(ty) for ty in types]
 
+    def to_info_dict(self):
+        info_dict = super().to_info_dict()
+        info_dict["types"] = [t.to_info_dict() for t in self.types]
+        return info_dict
+
     @property
     def name(self):
         return f"<{' '.join(ty.name for ty in self.types)}>"


Test patch:
diff --git a/tests/test_info_dict.py b/tests/test_info_dict.py
new file mode 100644
--- /dev/null
+++ b/tests/test_info_dict.py
@@ -0,0 +1,268 @@
+import pytest
+
+import click.types
+
+# Common (obj, expect) pairs used to construct multiple tests.
+STRING_PARAM_TYPE = (click.STRING, {"param_type": "String", "name": "text"})
+INT_PARAM_TYPE = (click.INT, {"param_type": "Int", "name": "integer"})
+BOOL_PARAM_TYPE = (click.BOOL, {"param_type": "Bool", "name": "boolean"})
+HELP_OPTION = (
+    None,
+    {
+        "name": "help",
+        "param_type_name": "option",
+        "opts": ["--help"],
+        "secondary_opts": [],
+        "type": BOOL_PARAM_TYPE[1],
+        "required": False,
+        "nargs": 1,
+        "multiple": False,
+        "default": False,
+        "envvar": None,
+        "help": "Show this message and exit.",
+        "prompt": None,
+        "is_flag": True,
+        "flag_value": True,
+        "count": False,
+        "hidden": False,
+    },
+)
+NAME_ARGUMENT = (
+    click.Argument(["name"]),
+    {
+        "name": "name",
+        "param_type_name": "argument",
+        "opts": ["name"],
+        "secondary_opts": [],
+        "type": STRING_PARAM_TYPE[1],
+        "required": True,
+        "nargs": 1,
+        "multiple": False,
+        "default": None,
+        "envvar": None,
+    },
+)
+NUMBER_OPTION = (
+    click.Option(["-c", "--count", "number"], default=1),
+    {
+        "name": "number",
+        "param_type_name": "option",
+        "opts": ["-c", "--count"],
+        "secondary_opts": [],
+        "type": INT_PARAM_TYPE[1],
+        "required": False,
+        "nargs": 1,
+        "multiple": False,
+        "default": 1,
+        "envvar": None,
+        "help": None,
+        "prompt": None,
+        "is_flag": False,
+        "flag_value": False,
+        "count": False,
+        "hidden": False,
+    },
+)
+HELLO_COMMAND = (
+    click.Command("hello", params=[NUMBER_OPTION[0]]),
+    {
+        "name": "hello",
+        "params": [NUMBER_OPTION[1], HELP_OPTION[1]],
+        "help": None,
+        "epilog": None,
+        "short_help": None,
+        "hidden": False,
+        "deprecated": False,
+    },
+)
+HELLO_GROUP = (
+    click.Group("cli", [HELLO_COMMAND[0]]),
+    {
+        "name": "cli",
+        "params": [HELP_OPTION[1]],
+        "help": None,
+        "epilog": None,
+        "short_help": None,
+        "hidden": False,
+        "deprecated": False,
+        "commands": {"hello": HELLO_COMMAND[1]},
+        "chain": False,
+    },
+)
+
+
+@pytest.mark.parametrize(
+    ("obj", "expect"),
+    [
+        pytest.param(
+            click.types.FuncParamType(range),
+            {"param_type": "Func", "name": "range", "func": range},
+            id="Func ParamType",
+        ),
+        pytest.param(
+            click.UNPROCESSED,
+            {"param_type": "Unprocessed", "name": "text"},
+            id="UNPROCESSED ParamType",
+        ),
+        pytest.param(*STRING_PARAM_TYPE, id="STRING ParamType"),
+        pytest.param(
+            click.Choice(["a", "b"]),
+            {
+                "param_type": "Choice",
+                "name": "choice",
+                "choices": ["a", "b"],
+                "case_sensitive": True,
+            },
+            id="Choice ParamType",
+        ),
+        pytest.param(
+            click.DateTime(["%Y-%m-%d"]),
+            {"param_type": "DateTime", "name": "datetime", "formats": ["%Y-%m-%d"]},
+            id="DateTime ParamType",
+        ),
+        pytest.param(*INT_PARAM_TYPE, id="INT ParamType"),
+        pytest.param(
+            click.IntRange(0, 10, clamp=True),
+            {
+                "param_type": "IntRange",
+                "name": "integer range",
+                "min": 0,
+                "max": 10,
+                "min_open": False,
+                "max_open": False,
+                "clamp": True,
+            },
+            id="IntRange ParamType",
+        ),
+        pytest.param(
+            click.FLOAT, {"param_type": "Float", "name": "float"}, id="FLOAT ParamType"
+        ),
+        pytest.param(
+            click.FloatRange(-0.5, 0.5),
+            {
+                "param_type": "FloatRange",
+                "name": "float range",
+                "min": -0.5,
+                "max": 0.5,
+                "min_open": False,
+                "max_open": False,
+                "clamp": False,
+            },
+            id="FloatRange ParamType",
+        ),
+        pytest.param(*BOOL_PARAM_TYPE, id="Bool ParamType"),
+        pytest.param(
+            click.UUID, {"param_type": "UUID", "name": "uuid"}, id="UUID ParamType"
+        ),
+        pytest.param(
+            click.File(),
+            {"param_type": "File", "name": "filename", "mode": "r", "encoding": None},
+            id="File ParamType",
+        ),
+        pytest.param(
+            click.Path(),
+            {
+                "param_type": "Path",
+                "name": "path",
+                "exists": False,
+                "file_okay": True,
+                "dir_okay": True,
+                "writable": False,
+                "readable": True,
+                "allow_dash": False,
+            },
+            id="Path ParamType",
+        ),
+        pytest.param(
+            click.Tuple((click.STRING, click.INT)),
+            {
+                "param_type": "Tuple",
+                "name": "<text integer>",
+                "types": [STRING_PARAM_TYPE[1], INT_PARAM_TYPE[1]],
+            },
+            id="Tuple ParamType",
+        ),
+        pytest.param(*NUMBER_OPTION, id="Option"),
+        pytest.param(
+            click.Option(["--cache/--no-cache", "-c/-u"]),
+            {
+                "name": "cache",
+                "param_type_name": "option",
+                "opts": ["--cache", "-c"],
+                "secondary_opts": ["--no-cache", "-u"],
+                "type": BOOL_PARAM_TYPE[1],
+                "required": False,
+                "nargs": 1,
+                "multiple": False,
+                "default": False,
+                "envvar": None,
+                "help": None,
+                "prompt": None,
+                "is_flag": True,
+                "flag_value": True,
+                "count": False,
+                "hidden": False,
+            },
+            id="Flag Option",
+        ),
+        pytest.param(*NAME_ARGUMENT, id="Argument"),
+    ],
+)
+def test_parameter(obj, expect):
+    out = obj.to_info_dict()
+    assert out == expect
+
+
+@pytest.mark.parametrize(
+    ("obj", "expect"),
+    [
+        pytest.param(*HELLO_COMMAND, id="Command"),
+        pytest.param(*HELLO_GROUP, id="Group"),
+        pytest.param(
+            click.Group(
+                "base",
+                [click.Command("test", params=[NAME_ARGUMENT[0]]), HELLO_GROUP[0]],
+            ),
+            {
+                "name": "base",
+                "params": [HELP_OPTION[1]],
+                "help": None,
+                "epilog": None,
+                "short_help": None,
+                "hidden": False,
+                "deprecated": False,
+                "commands": {
+                    "cli": HELLO_GROUP[1],
+                    "test": {
+                        "name": "test",
+                        "params": [NAME_ARGUMENT[1], HELP_OPTION[1]],
+                        "help": None,
+                        "epilog": None,
+                        "short_help": None,
+                        "hidden": False,
+                        "deprecated": False,
+                    },
+                },
+                "chain": False,
+            },
+            id="Nested Group",
+        ),
+    ],
+)
+def test_command(obj, expect):
+    ctx = click.Context(obj)
+    out = obj.to_info_dict(ctx)
+    assert out == expect
+
+
+def test_context():
+    ctx = click.Context(HELLO_COMMAND[0])
+    out = ctx.to_info_dict()
+    assert out == {
+        "command": HELLO_COMMAND[1],
+        "info_name": None,
+        "allow_extra_args": False,
+        "allow_interspersed_args": True,
+        "ignore_unknown_options": False,
+        "auto_envvar_prefix": None,
+    }