From c26f79f94c2a8664e0a75ecef608417225310337 Mon Sep 17 00:00:00 2001 From: Adam Miller Date: Mon, 20 May 2024 19:54:40 -0400 Subject: [PATCH] add initial version --- .gitignore | 4 + README.md | 120 ++++++++++++++++ click_tree/__init__.py | 306 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 + setup.py | 30 ++++ 5 files changed, 463 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 click_tree/__init__.py create mode 100644 pyproject.toml create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e5785c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +__pycache__ +click_tree.egg-info +dist \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8d9f2c --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Click Tree + +`click_tree` is a utility library for the [click](https://click.palletsprojects.com) +CLI framework which generates a tree of any click CLI: + +```bash +smn --tree home +home - home automation (hass-cli) +|-- all - all rooms +| +-- lights - toggle all lights +|-- kitchen - kitchen +| +-- lights +| |-- set (*) - set brightness level (0-100) +| |-- level - get brightness level (0-100) +| |-- toggle - toggle state +| |-- state - get state +| |-- on - turn on +| +-- off - turn off +|-- living - living room +| |-- lights +| | |-- set (*) - set brightness level (0-100) +| | |-- level - get brightness level (0-100) +| | |-- toggle - toggle state +| | |-- state - get state +| | |-- on - turn on +| | +-- off - turn off +| |-- fan +| | |-- toggle (*) - toggle state +| | |-- state - get state +| | |-- on - turn on +| | +-- off - turn off +| |-- humidifier +| | |-- toggle (*) - toggle state +| | |-- state - get state +| | |-- on - turn on +| | +-- off - turn off +| |-- temp - temperature (f) +| +-- humid - relative humidity (%) +``` + +This library uses [anytree](https://anytree.readthedocs.io/en/stable/index.html) +for constructing and rendering the tree. + +# Usage + +## Parameter + +The click tree can be implemented as a click Parameter type, ClickTreeParam, +which should be added to the root of the CLI: + +```python +from typing import Optional + +import click +from click_tree import ClickTreeParam + +@click.group(name="smn") +@click.option( + "--tree", + is_flag=True, + type=ClickTreeParam(scoped=True), + help="Enable tree display.", +) +def cli(tree: Optional[bool]) -> None: + pass +``` + +Passing the `--tree` option will then cause the CLI to display the tree and exit. +With the `scoped=True` option, the tree will only display from the user invoked +command onwards. For example, `smn --tree`, would render the entire tree, while +`smn --tree home` would only render all subcommands and groups from `home` onwards. + +## Manual + +Manual implementation is possible with the `get_tree` command, which takes a +"root" click object, either a Command or a Group, as well as optionally a set +of `argv` to use for scoping the generated tree: + +```python +from typing import Optional +from sys import argv + +import click +from click_tree import get_tree + +@click.group(name="smn") +def cli() -> None: + pass + +@cli.command(name="tree") +@click.pass_context +def cli_tree(ctx: click.Context) -> None: + tree = get_tree(ctx.find_root().command, argv) + + click.secho(tree.render()) +``` + +# Configuration + +ClickTreeParam has several options for customizing behavior during tree render: + +``` +scoped: bool. If True, sys.argv will be used to "scope" the rendered + tree down to the current invocation, only showing the tree from + the provided command onward. +ignore_names: Optional[Iterable[str]]. List of command/group names to + filter out during tree display. +style: Optional[AbstractStyle]. Override the anytree style used to render + the tree. Defaults to AsciiStyle. Style reference: + https://anytree.readthedocs.io/en/stable/api/anytree.render.html +``` + +For manual implementation, the `ignore_names` and `style` parameters can be +provided to `tree.render()`. + +# Credits + +The original inspiration for this tree implementation was based off of the +[click-command-tree](https://github.com/whwright/click-command-tree) library +created by Harrison Wright. diff --git a/click_tree/__init__.py b/click_tree/__init__.py new file mode 100644 index 0000000..5f00dcd --- /dev/null +++ b/click_tree/__init__.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +from __future__ import annotations +from typing import Any, Optional, Tuple, Union, Iterable +from sys import argv + +from anytree import ( + AbstractStyle, + AsciiStyle, + ChildResolverError, + NodeMixin, + RenderTree, + Resolver, +) +import click + + +class ClickTreeParam(click.ParamType): + """Click Tree Parameter. + + This pseudo-parameter will generate and render a tree of the click Command + it is decorated with. + + Example Usage: + @click.command(name="foobar) + @click.option( + "--tree", + is_flag=True, + type=ClickTreeParam(scoped=True, ignore_names=["smn-run"]), + help="Enable tree display.", + ) + def cli(tree: Optional[bool]) -> None: + ... + + If `--tree` is provided, the command will then render the tree and exit. + Otherwise, it will continue execution as normal. + + Args: + scoped: bool. If True, sys.argv will be used to "scope" the rendered + tree down to the current invocation, only showing the tree from + the provided command onward. + ignore_names: Optional[Iterable[str]]. List of command/group names to + filter out during tree display. + style: Optional[AbstractStyle]. Override the anytree style used to render + the tree. Defaults to AsciiStyle. Style reference: + https://anytree.readthedocs.io/en/stable/api/anytree.render.html + """ + + name = "click_tree" + + def __init__( + self, + scoped: bool = False, + ignore_names: Optional[Iterable[str]] = None, + style: Optional[AbstractStyle] = None, + ) -> None: + self.scoped = scoped + self.ignore_names = ignore_names + self.style = style + + def convert( + self, + # Original click.ParamType.convert signature, not changing. + # pyre-ignore[2]: Parameter `value` must have a type other than `Any`. + value: Any, + param: Optional[click.Parameter], + ctx: Optional[click.Context], + ) -> None: + if not isinstance(value, bool): + click.secho( + "click.Option should be defined with is_flag=True when using ClickTreeParam" + ) + raise click.exceptions.Exit(1) + elif not ctx: + click.secho("No click.Context found, cannot resolve root command") + raise click.exceptions.Exit(1) + elif value: + # Tree parameter specified by user, render. + tree = get_tree(ctx.find_root().command, argv if self.scoped else None) + click.secho(tree.render(style=self.style, ignore_names=self.ignore_names)) + raise click.exceptions.Exit(0) + else: + # Not rendering tree, return. + return None + + +class ClickNode(NodeMixin): + """A single Node in the Click command tree. + + Args: + name: str. Name of the command. + description: Optional[str]. Description of the command, if any. + default_command: bool. Whether or not this command is a "default" command. + This only applies to Groups using click-contrib/click-default-group. + Defaults to False. + parent: Optional[ClickNode]. Parent ClickNode. + children: Optional[Tuple[ClickNode, ...]]. Child ClickNode(s). + """ + + def __init__( + self, + name: str, + description: Optional[str] = None, + default_command: bool = False, + parent: Optional[ClickNode] = None, + children: Optional[Tuple[ClickNode, ...]] = None, + ) -> None: + super().__init__() + + self.name = name + self.description = description + self.default_command = default_command + + self.parent = parent + if children: + self.children: Tuple[ClickNode, ...] = children + + @classmethod + def from_click_obj( + cls, + click_obj: Union[click.Command, click.Group], + name: str, + default_command: bool = False, + parent: Optional[ClickNode] = None, + ) -> ClickNode: + """Generate a ClickNode tree from a given Click Command or Group. + + This will retrieve all relevant metadata about a given click object and + create a ClickNode for it. If click_obj is a Group, it will recursively + add all Commands/Groups under it as well. + + Args: + click_obj: Union[click.Command, click.Group]. Click object to generate + ClickNode tree for. + command_name: Optional[str]. Optional name override, if not provided the + `name` property of the provided click_obj will be used. + parent: Optional[ClickNode]. Parent ClickNode of this Node, supplied + recursively during command enumeration. + + Returns: + click_node: ClickNode. Node representing the full tree of the provided + click object. + """ + + node = ClickNode( + name=name, + default_command=default_command, + parent=parent, + ) + + if isinstance(click_obj, click.Group): + description = click_obj.short_help + + # Attempt to retrieve default_cmd_name, defaulting to None + # if this is just a normal click.Group and not a DefaultGroup. + default_cmd_name = getattr(click_obj, "default_cmd_name", None) + + # Add all the subcommands of this group as children recursively. + node.children = tuple( + cls.from_click_obj(command, name, default_cmd_name == name, node) + for name, command in click_obj.commands.items() + ) + else: + description = click_obj.help + + if description: + # Retrieve first non-empty line of the description. + description = next((line for line in description.split("\n") if line), "") + + if len(description) > 80: + description = f"{description[:77]}..." + else: + description = description[:80] + + node.description = description + + return node + + def render( + self, + style: Optional[AbstractStyle] = None, + ignore_names: Optional[Iterable[str]] = None, + ) -> str: + """Render and return the ClickNode tree. + + Args: + style: Optional[AbstractStyle]. Anytree "style" to use for rendering + tree. Defaults to AsciiStyle(). + ignore_names: Optional[Iterable[str]]. Collection of command names + that will be skipped if found during tree rendering. + + Returns: + rendered_tree: str. Rendered ClickNode tree. + """ + + if not style: + style = AsciiStyle() + + if not ignore_names: + ignore_names = tuple() + + render_str = "" + for row in RenderTree(self, style): + node = row.node + + if node.name in ignore_names: + continue + + # Add a suffix which shows truncated help if there is help defined. + help_str = f" - {node.description}" if node.description else "" + + default_str = " (*)" if node.default_command else "" + + # Default, with help + # toggle (*) - toggle state + # Not default, with help + # state - get state + # Not default, no help + # on + render_str += f"{row.pre}{node.name}{default_str}{help_str}\n" + + return render_str + + +def _get_command_path( + root: Union[click.Command, click.Group], argv: Iterable[str] +) -> str: + """Resolve the tree "path" for a given command invocation. + + Given a sys.argv like: + ['/the/path/to/my/script', + '--tree', + 'living', + 'lights'] + + This will attempt to resolve each argument against the supplied root, adding + it's name to the path if found, and updating the root if the resolved click + object is also a group. (In this example /root_command/living/lights). + + Args: + root: Union[click.Command, click.Group]. Root click object to resolve + path with. + argv: Iterable[str]. Arguments, usually from sys.argv, representing the + current click invocation. + + Returns: + command_path: str. Resolved command path. + """ + + path = f"/{root.name}" + if not isinstance(root, click.Group): + # The root is a command, just return the path to the root node. + return path + + node = root + for arg in argv: + # Attempt to resolve the arg as a command in the current Group. + if resolved_obj := node.commands.get(arg, None): + # If it was resolved, add the name to the path. + path += f"/{resolved_obj.name}" + + if isinstance(resolved_obj, click.Group): + # If this is also a group, update the current node to the + # resolved group, so that the next argument(s) can be resolved + # against it. + node = resolved_obj + + # Return the full path. + return path + + +def get_tree( + root: Union[click.Command, click.Group], argv: Optional[Iterable[str]] = None +) -> ClickNode: + """Generate and return a ClickNode tree for a given click root object and argv. + + If argv are provided, it will also "scope" the tree down to the invoked + command, only rendering it from that point on. + + Args: + root: Union[click.Command, click.Group]. Root click object to generate + tree from. + argv: Iterable[str]. Arguments, usually from sys.argv, representing the + current click invocation, used for scoping. + + Returns: + click_tree: Generated ClickNode tree from the root object, optionally + scoped for the current invocation. + """ + + name = root.name + if name is not None: + tree = ClickNode.from_click_obj(root, name) + else: + tree = ClickNode.from_click_obj(root, "") + + if not argv: + # No scoping, just return entire tree. + return tree + + command_path = _get_command_path(root, argv) + if command_path == "/": + # Root command, return the entire tree. + return tree + else: + # Resolve the scoped path. + return Resolver("name").get(tree, command_path) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fed528d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..62b57df --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +from setuptools import setup, find_packages + +setup( + name="click-tree", + version="0.0.1", + license="MIT", + description=" automatically generate a tree view of a click CLI", + author="Adam Miller", + author_email="miller@adammiller.io", + url="https://github.com/adammillerio/click-tree", + download_url="https://github.com/adammillerio/click-tree/archive/v0.0.1.tar.gz", + keywords=[], + classifiers=[ + "Development Status :: 5 - Production", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3 :: Only", + ], + packages=find_packages(), + include_package_data=True, + install_requires=[ + "anytree", + "click", + ], +)