Skip to content

Commit 989b902

Browse files
authored
fix(cli): exclude imports from group data (#1676)
1 parent fbbc7e7 commit 989b902

3 files changed

Lines changed: 71 additions & 2 deletions

File tree

src/pyinfra_cli/inventory.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import ast
12
import socket
23
from collections import defaultdict
34
from os import listdir, path
4-
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union
5+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, TypeVar, Union
56

67
from pyinfra import logger
78
from pyinfra.api.inventory import Inventory
@@ -55,6 +56,31 @@ def _is_inventory_group(key: str, value: Any):
5556
return True
5657

5758

59+
def _get_imported_names(filename: str) -> Set[str]:
60+
"""
61+
Return the set of names bound by ``import`` / ``from ... import`` statements
62+
in ``filename``. Used to keep those names out of the resulting group data dict
63+
(issue #1297) so that e.g. ``from pyinfra import inventory`` in a group data
64+
file does not end up as a piece of group data and break ``debug-inventory``.
65+
"""
66+
with open(filename, "r", encoding="utf-8") as f:
67+
tree = ast.parse(f.read(), filename=filename)
68+
69+
names: Set[str] = set()
70+
for node in tree.body:
71+
if isinstance(node, ast.Import):
72+
for alias in node.names:
73+
names.add(alias.asname or alias.name.split(".")[0])
74+
elif isinstance(node, ast.ImportFrom):
75+
for alias in node.names:
76+
if alias.name == "*":
77+
# Wildcard imports can't be resolved statically; users who
78+
# hit this case can alias names with leading underscores.
79+
continue
80+
names.add(alias.asname or alias.name)
81+
return names
82+
83+
5884
def _get_group_data(dirname_or_filename: str):
5985
group_data = {}
6086

@@ -76,7 +102,14 @@ def _get_group_data(dirname_or_filename: str):
76102

77103
# Read the files locals into a dict
78104
attrs = exec_file(file, return_locals=True)
79-
keys = attrs.get("__all__", attrs.keys())
105+
# If __all__ is explicitly set the user controls exports directly;
106+
# otherwise drop names introduced by import statements so modules,
107+
# classes, and other unserializable objects don't leak into the
108+
# group data (issue #1297).
109+
if "__all__" in attrs:
110+
keys = attrs["__all__"]
111+
else:
112+
keys = [key for key in attrs.keys() if key not in _get_imported_names(file)]
80113

81114
group_data[group_name] = {
82115
key: value
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# ruff: noqa: F401
2+
# Intentional unused imports: this fixture verifies that imports in a group
3+
# data file do not leak into inventory.group_data (issue #1297).
4+
import os
5+
from os.path import join as path_join
6+
7+
from pyinfra import inventory
8+
9+
exported_value = "this_should_be_included"
10+
11+
_underscore = os.name # ignored regardless (leading underscore filter)

tests/test_cli/test_cli_inventory.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,31 @@ def test_load_group_data(self):
4545
assert inventory.group_data["leftover_data"].get("still_parsed") == "never_used"
4646
assert inventory.group_data["leftover_data"].get("_global_arg") == "gets_parsed"
4747

48+
def test_imports_do_not_leak_into_group_data(self):
49+
"""
50+
Regression test for #1297: ``from pkg import name`` / ``import pkg`` in
51+
a group_data file must not expose ``name`` / ``pkg`` as group data,
52+
otherwise ``debug-inventory`` fails trying to JSON-encode a module.
53+
"""
54+
ctx_state.reset()
55+
ctx_inventory.reset()
56+
57+
hosts = ["somehost", "anotherhost", "someotherhost"]
58+
result = run_cli(
59+
"-y",
60+
",".join(hosts),
61+
f"--group-data={path.join('tests', 'test_cli', 'deploy', 'group_data')}",
62+
"exec",
63+
"uptime",
64+
)
65+
assert result.exit_code == 0, result.stdout
66+
67+
leaked = inventory.group_data["imports_leak"]
68+
assert "os" not in leaked
69+
assert "inventory" not in leaked
70+
assert "path_join" not in leaked
71+
assert leaked.get("exported_value") == "this_should_be_included"
72+
4873
def test_load_group_data_file(self):
4974
ctx_state.reset()
5075
ctx_inventory.reset()

0 commit comments

Comments
 (0)