From f9721e5abe8eff38f9be854e8dc139a094806ef5 Mon Sep 17 00:00:00 2001 From: Torsten Heinig Date: Tue, 23 Jun 2026 11:14:26 +0200 Subject: [PATCH 1/2] fix(ndcli): add tree view for list containers Add a Unicode tree rendering mode to `list containers` with: - `-T/--tree` to switch output to a visual tree - `-p/--pattern` to filter tree output by regex - `-c/--case-sensitive` for exact matching - `--no-color` to disable ANSI highlighting Keep the existing flat output path unchanged when tree mode is not requested. When filtering is enabled, retain matching nodes, their ancestors, and their child subtrees so the rendered tree preserves context. Co-authored-by: GitHub Copilot (GPT-5.4 mini) Signed-off-by: Torsten Heinig --- ndcli/dimcli/__init__.py | 80 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/ndcli/dimcli/__init__.py b/ndcli/dimcli/__init__.py index 56c735f..db8877f 100644 --- a/ndcli/dimcli/__init__.py +++ b/ndcli/dimcli/__init__.py @@ -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', From 3b88340f900de216fef5b36d0fb4dce8388af130 Mon Sep 17 00:00:00 2001 From: Torsten Heinig Date: Tue, 30 Jun 2026 17:33:22 +0200 Subject: [PATCH 2/2] test(dim-testsuite): tree view for list containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add dim-testsuite/t/container-list-tree.t, the integration test required by CONTRIBUTING.md for the feature shipped in f9721e5 (fix(ndcli): add tree view for list containers). The test covers all four new flags of `ndcli list containers`: - --tree / -T: Unicode box-drawing rendering (└──, ├──, │ ) verified for single-container (└── only) and multi-container layouts (├── + │ prefix for non-last; └── + spaces for last). - --pattern / -p: regex filter retains matching nodes with all their children and their ancestor path; unmatched subtrees are pruned (10.x block is dropped when pattern is "11"). - --case-sensitive / -c: confirmed that lowercase "container" does not match "(Container)" and that WARNING is emitted with no output. - --no-color: flag accepted without error; ANSI codes are already suppressed for non-tty stdout, output is identical to -T alone. Also removes a stray trailing space on a blank line in ndcli/dimcli/__init__.py left over from the feature commit. Co-authored-by: Claude Sonnet 4.6 (1M context) Signed-off-by: Torsten Heinig --- dim-testsuite/t/container-list-tree.t | 47 +++++++++++++++++++++++++++ ndcli/dimcli/__init__.py | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 dim-testsuite/t/container-list-tree.t 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 db8877f..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,