Skip to content

Commit

Permalink
Improve the hab dump feature for forests
Browse files Browse the repository at this point in the history
  • Loading branch information
MHendricks committed Apr 11, 2023
1 parent 4bcb015 commit b040acb
Show file tree
Hide file tree
Showing 4 changed files with 240 additions and 60 deletions.
64 changes: 49 additions & 15 deletions hab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
39 changes: 31 additions & 8 deletions hab/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions hab/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
175 changes: 138 additions & 37 deletions tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"]

0 comments on commit b040acb

Please sign in to comment.