Repository: ansible/ansible
Pull request: ansible-inventory, add --limit option
fixes #77580
##### ISSUE TYPE
<!--- Pick one below and delete the rest -->
- Feature Pull Request

##### COMPONENT NAME
<!--- Write the short name of the module, plugin, task or feature below -->
ansible-inventory

Problem statement:
filter the output of ansible-inventory --list
### Summary

`ansible-inventory --graph` can be specified with a group though, the output is not a JSON format so that we cannot parse the output easily.

It would be helpful for us if the `ansible-inventory --list` could accept the group name.


### Issue Type

Feature Idea

### Component Name

inventory

### Additional Information

For example, I created an inventory file.

```
[groupA]
host1
host2
host3

[groupA:children]
groupA1

[groupB]
host11
host12

[groupC]
host21
host22
host23
host24

[groupC:children]
groupA1

[groupA1]
host101
host102
host103
host104
host105
```

Then I can get filtered output with `ansible-inventory --graph`.  It can catch all hosts in the child group as well.

```
$ ansible-inventory -i inventory --graph groupA
@groupA:
  |--@groupA1:
  |  |--host101
  |  |--host102
  |  |--host103
  |  |--host104
  |  |--host105
  |--host1
  |--host2
  |--host3
```

However `ansible-inventory --list` performs to output only for the whole hosts in the inventory.  We need the filtered output as the `--graph` in a JSON format.


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct

Hints from issue discussion:
Files identified in the description:
* `lib/ansible/inventory`

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
Since the `--list` output is either JSON or YAML which is easily parable (intended for machine consumption) why would you need to restrict to a group? In the case of `--graph` this is done as it was a 'human only' format and for big inventories it can get unworldly. 

needs_info
Actually it is not easy for me to parse and filter the output even if the output is JSON.  How can I get the host list as the same way as `--graph` in my example?  I suppose I have to catch the `children` entry and parse it again recursively.  I can create a code but the customer needs to get the list without writing a code like `--graph`.
We can look at enabling `limit` support (either by the positional arg, currently explicitly ignored, or with `--limit`)

Commit message: i swear there as an ignore_errors there already!!?!?!?

Patch to review:
diff --git a/changelogs/fragments/ainv_limit.yml b/changelogs/fragments/ainv_limit.yml
new file mode 100644
index 00000000000000..feb27fcfaf1d9a
--- /dev/null
+++ b/changelogs/fragments/ainv_limit.yml
@@ -0,0 +1,2 @@
+minor_changes:
+  - ansible-inventory now supports the limit command line options.
diff --git a/lib/ansible/cli/inventory.py b/lib/ansible/cli/inventory.py
index e8ed75e463657c..af83c89350f647 100755
--- a/lib/ansible/cli/inventory.py
+++ b/lib/ansible/cli/inventory.py
@@ -72,7 +72,6 @@ def init_parser(self):
         opt_help.add_runtask_options(self.parser)
 
         # remove unused default options
-        self.parser.add_argument('-l', '--limit', help=argparse.SUPPRESS, action=opt_help.UnrecognizedArgument, nargs='?')
         self.parser.add_argument('--list-hosts', help=argparse.SUPPRESS, action=opt_help.UnrecognizedArgument)
 
         self.parser.add_argument('args', metavar='host|group', nargs='?')
@@ -268,15 +267,17 @@ def _graph_name(name, depth=0):
             name = "  |" * (depth) + "--%s" % name
         return name
 
-    def _graph_group(self, group, depth=0):
+    def _graph_group(self, group, valid_hosts, depth=0):
 
         result = [self._graph_name('@%s:' % group.name, depth)]
         depth = depth + 1
         for kid in group.child_groups:
-            result.extend(self._graph_group(kid, depth))
+            result.extend(self._graph_group(kid, valid_hosts, depth))
 
         if group.name != 'all':
             for host in group.hosts:
+                if valid_hosts is not None and host not in valid_hosts:
+                    continue
                 result.append(self._graph_name(host.name, depth))
                 if context.CLIARGS['show_vars']:
                     result.extend(self._show_vars(self._get_host_variables(host), depth + 1))
@@ -288,21 +289,31 @@ def _graph_group(self, group, depth=0):
 
     def inventory_graph(self):
 
+        valid_hosts = None
+        if context.CLIARGS['subset']:
+            valid_hosts = CLI.get_host_list(self.inventory, context.CLIARGS['subset'])
+
         start_at = self._get_group(context.CLIARGS['pattern'])
         if start_at:
-            return '\n'.join(self._graph_group(start_at))
+            return '\n'.join(self._graph_group(start_at, valid_hosts))
         else:
             raise AnsibleOptionsError("Pattern must be valid group name when using --graph")
 
     def json_inventory(self, top):
 
         seen = set()
+        valid_hosts = None
+        if context.CLIARGS['subset']:
+            valid_hosts = CLI.get_host_list(self.inventory, context.CLIARGS['subset'])
 
         def format_group(group):
             results = {}
             results[group.name] = {}
             if group.name != 'all':
-                results[group.name]['hosts'] = [h.name for h in group.hosts]
+                if valid_hosts is not None:
+                    results[group.name]['hosts'] = [h.name for h in group.hosts if h in valid_hosts]
+                else:
+                    results[group.name]['hosts'] = [h.name for h in group.hosts]
             results[group.name]['children'] = []
             for subgroup in group.child_groups:
                 results[group.name]['children'].append(subgroup.name)
@@ -310,7 +321,8 @@ def format_group(group):
                     results.update(format_group(subgroup))
                     seen.add(subgroup.name)
             if context.CLIARGS['export']:
-                results[group.name]['vars'] = self._get_group_variables(group)
+                if valid_hosts is None or results[group.name]['hosts']:
+                    results[group.name]['vars'] = self._get_group_variables(group)
 
             self._remove_empty(results[group.name])
             if not results[group.name]:
@@ -333,6 +345,9 @@ def format_group(group):
     def yaml_inventory(self, top):
 
         seen = []
+        valid_hosts = None
+        if context.CLIARGS['subset']:
+            valid_hosts = CLI.get_host_list(self.inventory, context.CLIARGS['subset'])
 
         def format_group(group):
             results = {}
@@ -350,6 +365,8 @@ def format_group(group):
             results[group.name]['hosts'] = {}
             if group.name != 'all':
                 for h in group.hosts:
+                    if valid_hosts is not None and h not in valid_hosts:
+                        continue
                     myvars = {}
                     if h.name not in seen:  # avoid defining host vars more than once
                         seen.append(h.name)
@@ -357,9 +374,10 @@ def format_group(group):
                     results[group.name]['hosts'][h.name] = myvars
 
             if context.CLIARGS['export']:
-                gvars = self._get_group_variables(group)
-                if gvars:
-                    results[group.name]['vars'] = gvars
+                if valid_hosts is None or results[group.name]['hosts']:
+                    gvars = self._get_group_variables(group)
+                    if gvars:
+                        results[group.name]['vars'] = gvars
 
             self._remove_empty(results[group.name])
 
@@ -370,6 +388,9 @@ def format_group(group):
     def toml_inventory(self, top):
         seen = set()
         has_ungrouped = bool(next(g.hosts for g in top.child_groups if g.name == 'ungrouped'))
+        valid_hosts = None
+        if context.CLIARGS['subset']:
+            valid_hosts = CLI.get_host_list(self.inventory, context.CLIARGS['subset'])
 
         def format_group(group):
             results = {}
@@ -385,6 +406,8 @@ def format_group(group):
 
             if group.name != 'all':
                 for host in group.hosts:
+                    if valid_hosts is not None and host not in valid_hosts:
+                        continue
                     if host.name not in seen:
                         seen.add(host.name)
                         host_vars = self._get_host_variables(host=host)
@@ -396,7 +419,8 @@ def format_group(group):
                         results[group.name]['hosts'] = {host.name: host_vars}
 
             if context.CLIARGS['export']:
-                results[group.name]['vars'] = self._get_group_variables(group)
+                if valid_hosts is None or results[group.name]['hosts']:
+                    results[group.name]['vars'] = self._get_group_variables(group)
 
             self._remove_empty(results[group.name])
             if not results[group.name]:
diff --git a/test/integration/targets/ansible-inventory/tasks/main.yml b/test/integration/targets/ansible-inventory/tasks/main.yml
index 84ac2c3cf5b20f..633b7cbf5d3404 100644
--- a/test/integration/targets/ansible-inventory/tasks/main.yml
+++ b/test/integration/targets/ansible-inventory/tasks/main.yml
@@ -145,3 +145,14 @@
   loop_control:
     loop_var: toml_package
   when: toml_package is not contains 'tomllib' or (toml_package is contains 'tomllib' and ansible_facts.python.version_info >= [3, 11])
+
+- name: check that limit removes host
+  command: ansible-inventory -i '{{ role_path }}/files/valid_sample.yml'  --limit '!something' --list
+  register: limited
+  ignore_errors: true
+
+- name: ensure empty host list
+  assert:
+    that:
+      - "'ERROR' in limited.stderr or 'ERROR' in lmited.stdout"
+      - "'--limit' in limited.stderr or '--limit' in lmited.stdout"


Code review comments:
diff_hunk: @@ -145,3 +145,14 @@
   loop_control:
     loop_var: toml_package
   when: toml_package is not contains 'tomllib' or (toml_package is contains 'tomllib' and ansible_facts.python.version_info >= [3, 11])
+
+- name: check that limit removes host
+  command: ansible-inventory -i '{{ role_path }}/files/valid_sample.yml'  --limit '!something' --list
+  register: limited
+  ignore_errors: true
+
+- name: ensure empty host list
+  assert:
+    that:
+      - "'ERROR' in limited.stderr or 'ERROR' in lmited.stdout"
+      - "'--limit' in limited.stderr or '--limit' in lmited.stdout"
path: test/integration/targets/ansible-inventory/tasks/main.yml
text: @user1:
```suggestion
      - "'ERROR' in limited.stderr or 'ERROR' in limited.stdout"
      - "'--limit' in limited.stderr or '--limit' in limited.stdout"
```

@user2:
nice catch!