diff --git a/hab/__init__.py b/hab/__init__.py index 94db64d..07f7b39 100644 --- a/hab/__init__.py +++ b/hab/__init__.py @@ -182,24 +182,58 @@ def distros(self): return self._distros @classmethod - def dump_forest(cls, forest, style=None): - """Convert a forest dictionary to a readable string""" + def dump_forest( + cls, + forest, + attr="uri", + fmt="{pre}{attr}", + style=None, + indent=" ", + truncate=None, + ): + """Convert a forest dictionary to a readable string. - def sort_foreset(items): - """Ensures consistent sorting of the forest leaf nodes""" - return sorted(items, key=lambda item: item.name) + Args: + forest: A dictionary of hab.parser objects to dump. Common values + are `resolver.configs`, or `resolver.distros`. + attr (str, optional): The name of the attribute to display for each node. + fmt (str, optional): str.format string to control the display of + each node in the forest. Accepts (pre, attr) keys. + style (anytree.render.AbstractStyle, optional): Controls how anytree + renders the branch information. If not set, defaults to a custom + style that intents all children(recursively) to the same depth. + indent (str, optional): The string to use for indentation. Used only + if style is None. + truncate (int, optional): The maximum number of results to show for + a given tree level. If there are more child nodes than twice this + value, only include the first and last number of these results + with a "..." placeholder in between. Disable by passing None. + """ + def sort_forest(items): + """Ensures consistent sorting of the forest leaf nodes""" + ret = utils.natural_sort(items, key=lambda item: item.name) + if truncate and len(ret) > truncate * 2: + # Create a anytree node that can be rendered as "..." + placeholder = HabBase._placeholder(forest, None) + placeholder.name = "..." + # replace the excess middle nodes with the placeholder object + ret = ret[:truncate] + [placeholder] + ret[-truncate:] + return ret + + limit_pre = False if style is None: - style = anytree.render.AsciiStyle() - ret = [] - for tree_name in sorted(forest): - ret.append(tree_name) - tree = str( - anytree.RenderTree(forest[tree_name], style, childiter=sort_foreset) - ) - for line in tree.split("\n"): - ret.append(" {}".format(line)) - return "\n".join(ret) + style = anytree.render.AbstractStyle(indent, indent, indent) + limit_pre = True + + for tree_name in utils.natural_sort(forest): + for row in anytree.RenderTree( + forest[tree_name], style=style, childiter=sort_forest + ): + pre = row.pre + if limit_pre: + pre = row.pre[: len(indent)] + yield fmt.format(pre=pre, attr=getattr(row.node, attr)) def find_distro(self, requirement): """Returns the DistroVersion matching the requirement or None""" diff --git a/hab/cli.py b/hab/cli.py index 343cde7..0d47e97 100644 --- a/hab/cli.py +++ b/hab/cli.py @@ -222,8 +222,10 @@ def env(settings, uri, unfreeze, launch): "-t", "--type", "report_type", - type=click.Choice(["config", "c", "forest", "f", "site", "s"]), - default="config", + type=click.Choice( + ["nice", "site", "s", "uris", "u", "versions", "v", "forest", "f"] + ), + default="nice", help="Type of report.", ) @click.option( @@ -257,12 +259,33 @@ def dump( ): """Resolves and prints the requested setup.""" logger.info("Context: {}".format(uri)) - if report_type in ("forest", "f"): - click.echo(" Configs ".center(50, "-")) - click.echo(settings.resolver.dump_forest(settings.resolver.configs)) - click.echo(" Distros ".center(50, "-")) - click.echo(settings.resolver.dump_forest(settings.resolver.distros)) - elif report_type in ("site", "s"): + # Convert report_tupe short names to long names for ease of processing + report_map = {"u": "uris", "v": "versions", "f": "forest", "s": "site"} + report_type = report_map.get(report_type, report_type) + + if report_type in ("uris", "versions", "forest"): + # Allow the user to disable truncation of versions with verbosity flag + truncate = None if verbosity else 3 + + def echo_line(line): + if line.strip() == line: + click.echo(f'{Fore.GREEN}{line}{Fore.RESET}') + else: + click.echo(line) + + if report_type in ("uris", "forest"): + click.echo(f'{Fore.YELLOW}{" URIs ".center(50, "-")}{Fore.RESET}') + for line in settings.resolver.dump_forest(settings.resolver.configs): + echo_line(line) + if report_type in ("versions", "forest"): + click.echo(f'{Fore.YELLOW}{" Versions ".center(50, "-")}{Fore.RESET}') + for line in settings.resolver.dump_forest( + settings.resolver.distros, + attr="name", + truncate=truncate, + ): + echo_line(line) + elif report_type == "site": click.echo(settings.resolver.site.dump(verbosity=verbosity)) else: if unfreeze: diff --git a/hab/utils.py b/hab/utils.py index 62222c5..5832621 100644 --- a/hab/utils.py +++ b/hab/utils.py @@ -250,6 +250,28 @@ def load_json_file(filename): return data +def natural_sort(ls, key=None): + """Sort a list in a more natural way by treating contiguous integers as a + single number instead of processing each number individually. This function + understands that 10 is larger than 1. It also ignores case. + + Source: http://blog.codinghorror.com/sorting-for-humans-natural-sort-order + """ + + def convert(text): + return int(text) if text.isdigit() else text.lower() + + if key is None: + + def key(text): + return text + + def alphanum_key(a_key): + return [convert(c) for c in re.split(r'([0-9]+)', key(a_key))] + + return sorted(ls, key=alphanum_key) + + class NotSet(object): """The data for this property is not currently set.""" diff --git a/tests/test_resolver.py b/tests/test_resolver.py index fe0d998..c86f4eb 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -102,43 +102,123 @@ def test_closest_config(resolver, path, result, reason): def test_dump_forest(resolver): """Test the dump_forest method on resolver""" - result = resolver.dump_forest(resolver.configs) - check = "\n".join( - ( - "app", - " hab.parsers.placeholder.Placeholder('app')", - " |-- hab.parsers.config.Config('app/aliased')", - " | +-- hab.parsers.config.Config('app/aliased/mod')", - " +-- hab.parsers.placeholder.Placeholder('app/houdini')", - " |-- hab.parsers.config.Config('app/houdini/a')", - " +-- hab.parsers.config.Config('app/houdini/b')", - "default", - " hab.parsers.config.Config('default')", - " |-- hab.parsers.config.Config('default/Sc1')", - " +-- hab.parsers.config.Config('default/Sc11')", - "not_set", - " hab.parsers.config.Config('not_set')", - " |-- hab.parsers.config.Config('not_set/child')", - " |-- hab.parsers.config.Config('not_set/distros')", - " |-- hab.parsers.config.Config('not_set/empty_lists')", - " |-- hab.parsers.config.Config('not_set/env1')", - " |-- hab.parsers.config.Config('not_set/env_path_hab_uri')", - " |-- hab.parsers.config.Config('not_set/env_path_set')", - " |-- hab.parsers.config.Config('not_set/env_path_unset')", - " |-- hab.parsers.config.Config('not_set/no_distros')", - " |-- hab.parsers.config.Config('not_set/no_env')", - " +-- hab.parsers.config.Config('not_set/os')", - "place-holder", - " hab.parsers.placeholder.Placeholder('place-holder')", - " |-- hab.parsers.config.Config('place-holder/child')", - " +-- hab.parsers.config.Config('place-holder/inherits')", - "project_a", - " hab.parsers.config.Config('project_a')", - " +-- hab.parsers.config.Config('project_a/Sc001')", - " |-- hab.parsers.config.Config('project_a/Sc001/Animation')", - " +-- hab.parsers.config.Config('project_a/Sc001/Rigging')", - ) - ) + # Test dumping of configs using uri attr. + result = list(resolver.dump_forest(resolver.configs)) + check = [ + "app", + " app/aliased", + " app/aliased/mod", + " app/houdini", + " app/houdini/a", + " app/houdini/b", + "default", + " default/Sc1", + " default/Sc11", + "not_set", + " not_set/child", + " not_set/distros", + " not_set/empty_lists", + " not_set/env1", + " not_set/env_path_hab_uri", + " not_set/env_path_set", + " not_set/env_path_unset", + " not_set/no_distros", + " not_set/no_env", + " not_set/os", + "place-holder", + " place-holder/child", + " place-holder/inherits", + "project_a", + " project_a/Sc001", + " project_a/Sc001/Animation", + " project_a/Sc001/Rigging", + ] + assert result == check + + # Test dumping distros using name attr + result = list(resolver.dump_forest(resolver.distros, attr="name")) + check = [ + "aliased", + " aliased==2.0", + "aliased_mod", + " aliased_mod==1.0", + "all_settings", + " all_settings==0.1.0.dev1", + "houdini18.5", + " houdini18.5==18.5.351", + "houdini19.5", + " houdini19.5==19.5.493", + "maya2020", + " maya2020==2020.0", + " maya2020==2020.1", + "the_dcc", + " the_dcc==1.0", + " the_dcc==1.1", + " the_dcc==1.2", + "the_dcc_plugin_a", + " the_dcc_plugin_a==0.9", + " the_dcc_plugin_a==1.0", + " the_dcc_plugin_a==1.1", + "the_dcc_plugin_b", + " the_dcc_plugin_b==0.9", + " the_dcc_plugin_b==1.0", + " the_dcc_plugin_b==1.1", + "the_dcc_plugin_c", + " the_dcc_plugin_c==0.9", + " the_dcc_plugin_c==1.0", + " the_dcc_plugin_c==1.1", + "the_dcc_plugin_d", + " the_dcc_plugin_d==0.9", + " the_dcc_plugin_d==1.0", + " the_dcc_plugin_d==1.1", + "the_dcc_plugin_e", + " the_dcc_plugin_e==0.9", + " the_dcc_plugin_e==1.0", + " the_dcc_plugin_e==1.1", + ] + assert result == check + + # Test truncate feature by dumping distros + result = list(resolver.dump_forest(resolver.distros, attr="name", truncate=1)) + check = [ + "aliased", + " aliased==2.0", + "aliased_mod", + " aliased_mod==1.0", + "all_settings", + " all_settings==0.1.0.dev1", + "houdini18.5", + " houdini18.5==18.5.351", + "houdini19.5", + " houdini19.5==19.5.493", + "maya2020", + " maya2020==2020.0", + " maya2020==2020.1", + "the_dcc", + " the_dcc==1.0", + " ...", + " the_dcc==1.2", + "the_dcc_plugin_a", + " the_dcc_plugin_a==0.9", + " ...", + " the_dcc_plugin_a==1.1", + "the_dcc_plugin_b", + " the_dcc_plugin_b==0.9", + " ...", + " the_dcc_plugin_b==1.1", + "the_dcc_plugin_c", + " the_dcc_plugin_c==0.9", + " ...", + " the_dcc_plugin_c==1.1", + "the_dcc_plugin_d", + " the_dcc_plugin_d==0.9", + " ...", + " the_dcc_plugin_d==1.1", + "the_dcc_plugin_e", + " the_dcc_plugin_e==0.9", + " ...", + " the_dcc_plugin_e==1.1", + ] assert result == check @@ -365,3 +445,24 @@ def test_path_expansion(resolver, value, check): # Check that collapse_paths also works as expected assert utils.Platform.collapse_paths('test_string') == str('test_string') + + +def test_natrual_sort(): + items = ["test10", "test1", "Test3", "test2"] + # Double check that our test doesn't sort naturally by default + assert sorted(items) == ["Test3", "test1", "test10", "test2"] + + # Test that natural sort ignores case and groups numbers correctly + result = utils.natural_sort(items) + assert result == ["test1", "test2", "Test3", "test10"] + + # Test natural sorting using a custom sort key + class Node: + def __init__(self, name): + super().__init__() + self.name = name + + nodes = [Node(item) for item in items] + result = utils.natural_sort(nodes, key=lambda i: i.name) + check = [n.name for n in result] + assert check == ["test1", "test2", "Test3", "test10"]