diff --git a/dim-testsuite/t/container-list-tree.t b/dim-testsuite/t/container-list-tree.t new file mode 100644 index 0000000..d4c6a5e --- /dev/null +++ b/dim-testsuite/t/container-list-tree.t @@ -0,0 +1,47 @@ +# test tree view for list containers (-T/--tree, -p/--pattern, -c/--case-sensitive, --no-color) +$ ndcli create container 10.0.0.0/8 +INFO - Creating container 10.0.0.0/8 in layer3domain default + +# basic tree with long flag; single container uses └── connector +$ ndcli list containers layer3domain default --tree +layer3domain: default +└── 10.0.0.0/8 (Container) + └── 10.0.0.0/8 (Available) + +$ ndcli create layer3domain one type vrf rd 0:1 +$ ndcli create container 10.0.0.0/8 layer3domain one +INFO - Creating container 10.0.0.0/8 in layer3domain one +$ ndcli create container 11.0.0.0/8 layer3domain one +INFO - Creating container 11.0.0.0/8 in layer3domain one + +# short flag -T; multiple containers: non-last uses ├── and │ prefix, last uses └── +$ ndcli list containers layer3domain one -T +layer3domain: one +├── 10.0.0.0/8 (Container) +│ └── 10.0.0.0/8 (Available) +└── 11.0.0.0/8 (Container) + └── 11.0.0.0/8 (Available) + +# pattern filter implies tree mode; only the matching subtree is shown +$ ndcli list containers layer3domain one -p 11 +layer3domain: one +└── 11.0.0.0/8 (Container) + └── 11.0.0.0/8 (Available) + +# pattern is case-insensitive by default; both containers carry "(Container)" in their text +$ ndcli list containers layer3domain one -p container +layer3domain: one +├── 10.0.0.0/8 (Container) +│ └── 10.0.0.0/8 (Available) +└── 11.0.0.0/8 (Container) + └── 11.0.0.0/8 (Available) + +# case-sensitive flag: lowercase "container" does not match "(Container)" +$ ndcli list containers layer3domain one -p container -c +WARNING - No matches for pattern 'container' in layer3domain one + +# --no-color is accepted without error; no visual difference for non-tty output +$ ndcli list containers layer3domain default -T --no-color +layer3domain: default +└── 10.0.0.0/8 (Container) + └── 10.0.0.0/8 (Available) diff --git a/ndcli/dimcli/__init__.py b/ndcli/dimcli/__init__.py index 56c735f..45a0354 100644 --- a/ndcli/dimcli/__init__.py +++ b/ndcli/dimcli/__init__.py @@ -1343,7 +1343,7 @@ def modify_pool_add_subnet(self, args): created. The zone profile used for creating the reverse zones is taken from the reverse_dns_profile attribute of an ancestor ipblock. ''' - + options = OptionDict(include_messages=True) options.set_attributes(args.attributes) options.set_if(gateway=args.gw, @@ -1646,17 +1646,68 @@ def list_vlans(self, args): @cmd.register('list containers', layer3domain_group, Argument('container', nargs='?'), + Option('T', 'tree', + help='render as visual tree with Unicode box-drawing characters'), + Option('p', 'pattern', action='store', + help='regex to filter tree output (implies -T; shows matching nodes with full ancestor path and all children)'), + Option('c', 'case-sensitive', dest='case_sensitive', + help='case-sensitive pattern matching (default: case-insensitive)'), + Option(None, 'no-color', dest='no_color', + help='disable ANSI highlighting of matched terms'), help='list the children of a container') def list_containers(self, args): + use_tree = args.tree or bool(args.get('pattern')) + pattern_str = args.get('pattern') + case_sensitive = args.get('case_sensitive') + no_color = args.get('no_color') + + HIGHLIGHT_ON = '\033[1;33m' + HIGHLIGHT_OFF = '\033[0m' + + def node_text(node): + attributes = ['(%s)' % node['status']] + if 'pool' in node: + attributes.append('pool:%s' % node['pool']) + attributes.extend(['%s:%s' % (k, v) for k, v in sorted(node.get('attributes', {}).items())]) + return '%s %s' % (node['ip'], ' '.join(attributes)) + def print_tree(tree, level: int): for node in tree: - attributes = ['(%s)' % node['status']] - if 'pool' in node: - attributes.append('pool:%s' % node['pool']) - attributes.extend(['%s:%s' % (k, v) for k, v in sorted(node.get('attributes', {}).items())]) - print(' ' * level + '%s %s' % (node['ip'], ' '.join(attributes))) + print(' ' * level + node_text(node)) if 'children' in node: print_tree(node['children'], level + 1) + + def filter_tree(tree, regex): + """Return pruned tree: matching nodes (with all their children) and their ancestors.""" + result = [] + for node in tree: + if regex.search(node_text(node)): + result.append(node) # keep node with all original children + else: + filtered_children = filter_tree(node.get('children', []), regex) + if filtered_children: + new_node = dict(node) + new_node['children'] = filtered_children + result.append(new_node) + return result + + def render_unicode(tree, prefix='', regex=None, use_color=False): + lines = [] + for i, node in enumerate(tree): + is_last = (i == len(tree) - 1) + connector = '└── ' if is_last else '├── ' + text = node_text(node) + if use_color and regex: + text = regex.sub( + lambda m: HIGHLIGHT_ON + m.group() + HIGHLIGHT_OFF, text) + lines.append(prefix + connector + text) + children = node.get('children', []) + if children: + child_prefix = prefix + (' ' if is_last else '│ ') + child_output = render_unicode(children, child_prefix, regex, use_color) + if child_output: + lines.append(child_output) + return '\n'.join(lines) layer3domains: List[str] = [] if args.layer3domain == "all" or get_layer3domain(args.layer3domain) is None: layer3domains = [l['name'] for l in self.client.layer3domain_list()] @@ -1684,14 +1735,31 @@ def print_tree(tree, level: int): elif len(dim_errors) > 0: for dimerror in dim_errors: logging.debug(dimerror) + regex = None + if pattern_str: + flags = 0 if case_sensitive else re.IGNORECASE + try: + regex = re.compile(pattern_str, flags) + except re.error as ex: + raise DimError('Invalid regex: %s' % ex) + use_color = regex is not None and not no_color and sys.stdout.isatty() idx = 0 for layer3domain, result in results.items(): if idx > 0: print() _print_messages(result) if result['containers']: + containers = result['containers'] + if use_tree and regex: + containers = filter_tree(containers, regex) + if not containers: + logging.warning('No matches for pattern %r in layer3domain %s', pattern_str, layer3domain) + continue print('layer3domain: ' + layer3domain) - print_tree(result['containers'], 0) + if use_tree: + print(render_unicode(containers, regex=regex, use_color=use_color)) + else: + print_tree(containers, 0) idx += 1 @cmd.register('list ips',