Skip to content

Commit d2342c8

Browse files
authored
Simplify overriding selective evaluation policy settings for modules (ipython#14943)
### References - closes ipython#14940 - fixes ipython#14916 ### Code changes - allow to pass module-level allow list for attribute evaluation - allows to pass allow-lists using dotted paths rather than tuples; this is especially helpful to avoid a common mistake of passing `("my")` where `("my", )` would have been expected before - fixes issue with invalid prefix trimming which came up again when writing tests for this PR ### User-facing changes It is now possible to enable eager attribute evaluation in completer on an entire package with: ```python c.Completer.policy_overrides = { "allowed_getattr_external": { "my_trusted_library" } } ```
2 parents eeb177f + 52aa832 commit d2342c8

File tree

3 files changed

+118
-21
lines changed

3 files changed

+118
-21
lines changed

IPython/core/completer.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,6 @@
222222
from IPython.utils.PyColorize import theme_table
223223
from IPython.utils.decorators import sphinx_options
224224
from IPython.utils.dir2 import dir2, get_real_method
225-
from IPython.utils.docs import GENERATING_DOCUMENTATION
226225
from IPython.utils.path import ensure_dir_exists
227226
from IPython.utils.process import arg_split
228227
from traitlets import (
@@ -1313,14 +1312,19 @@ def _evaluate_expr(self, expr):
13131312
),
13141313
)
13151314
done = True
1316-
except Exception as e:
1317-
if self.debug:
1318-
print("Evaluation exception", e)
1315+
except (SyntaxError, TypeError):
1316+
# TypeError can show up with something like `+ d`
1317+
# where `d` is a dictionary.
1318+
13191319
# trim the expression to remove any invalid prefix
13201320
# e.g. user starts `(d[`, so we get `expr = '(d'`,
13211321
# where parenthesis is not closed.
13221322
# TODO: make this faster by reusing parts of the computation?
13231323
expr = self._trim_expr(expr)
1324+
except Exception as e:
1325+
if self.debug:
1326+
print("Evaluation exception", e)
1327+
done = True
13241328
return obj
13251329

13261330
@property
@@ -1331,6 +1335,7 @@ def _auto_import(self):
13311335
self._auto_import_func = import_item(self.auto_import_method)
13321336
return self._auto_import_func
13331337

1338+
13341339
def get__all__entries(obj):
13351340
"""returns the strings in the __all__ attribute"""
13361341
try:

IPython/core/guarded_eval.py

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from copy import copy
2-
from inspect import isclass, signature, Signature
2+
from inspect import isclass, signature, Signature, getmodule
33
from typing import (
44
Annotated,
55
AnyStr,
@@ -122,14 +122,24 @@ def can_call(self, func):
122122
def _get_external(module_name: str, access_path: Sequence[str]):
123123
"""Get value from external module given a dotted access path.
124124
125+
Only gets value if the module is already imported.
126+
125127
Raises:
126128
* `KeyError` if module is removed not found, and
127129
* `AttributeError` if access path does not match an exported object
128130
"""
129-
member_type = sys.modules[module_name]
130-
for attr in access_path:
131-
member_type = getattr(member_type, attr)
132-
return member_type
131+
try:
132+
member_type = sys.modules[module_name]
133+
# standard module
134+
for attr in access_path:
135+
member_type = getattr(member_type, attr)
136+
return member_type
137+
except (KeyError, AttributeError):
138+
# handle modules in namespace packages
139+
module_path = ".".join([module_name, *access_path])
140+
if module_path in sys.modules:
141+
return sys.modules[module_path]
142+
raise
133143

134144

135145
def _has_original_dunder_external(
@@ -139,18 +149,26 @@ def _has_original_dunder_external(
139149
method_name: str,
140150
):
141151
if module_name not in sys.modules:
142-
# LBYLB as it is faster
143-
return False
152+
full_module_path = ".".join([module_name, *access_path])
153+
if full_module_path not in sys.modules:
154+
# LBYLB as it is faster
155+
return False
144156
try:
145157
member_type = _get_external(module_name, access_path)
146158
value_type = type(value)
147159
if type(value) == member_type:
148160
return True
161+
if isinstance(member_type, ModuleType):
162+
value_module = getmodule(value_type)
163+
if not value_module or not value_module.__name__:
164+
return False
165+
if value_module.__name__.startswith(member_type.__name__):
166+
return True
149167
if method_name == "__getattribute__":
150168
# we have to short-circuit here due to an unresolved issue in
151169
# `isinstance` implementation: https://bugs.python.org/issue32683
152170
return False
153-
if isinstance(value, member_type):
171+
if not isinstance(member_type, ModuleType) and isinstance(value, member_type):
154172
method = getattr(value_type, method_name, None)
155173
member_method = getattr(member_type, method_name, None)
156174
if member_method == method:
@@ -185,35 +203,47 @@ def _has_original_dunder(
185203
return False
186204

187205

206+
def _coerce_path_to_tuples(
207+
allow_list: set[tuple[str, ...] | str],
208+
) -> set[tuple[str, ...]]:
209+
"""Replace dotted paths on the provided allow-list with tuples."""
210+
return {
211+
path if isinstance(path, tuple) else tuple(path.split("."))
212+
for path in allow_list
213+
}
214+
215+
188216
@undoc
189217
@dataclass
190218
class SelectivePolicy(EvaluationPolicy):
191219
allowed_getitem: set[InstancesHaveGetItem] = field(default_factory=set)
192-
allowed_getitem_external: set[tuple[str, ...]] = field(default_factory=set)
220+
allowed_getitem_external: set[tuple[str, ...] | str] = field(default_factory=set)
193221

194222
allowed_getattr: set[MayHaveGetattr] = field(default_factory=set)
195-
allowed_getattr_external: set[tuple[str, ...]] = field(default_factory=set)
223+
allowed_getattr_external: set[tuple[str, ...] | str] = field(default_factory=set)
196224

197225
allowed_operations: set = field(default_factory=set)
198-
allowed_operations_external: set[tuple[str, ...]] = field(default_factory=set)
226+
allowed_operations_external: set[tuple[str, ...] | str] = field(default_factory=set)
199227

200228
_operation_methods_cache: dict[str, set[Callable]] = field(
201229
default_factory=dict, init=False
202230
)
203231

204232
def can_get_attr(self, value, attr):
233+
allowed_getattr_external = _coerce_path_to_tuples(self.allowed_getattr_external)
234+
205235
has_original_attribute = _has_original_dunder(
206236
value,
207237
allowed_types=self.allowed_getattr,
208238
allowed_methods=self._getattribute_methods,
209-
allowed_external=self.allowed_getattr_external,
239+
allowed_external=allowed_getattr_external,
210240
method_name="__getattribute__",
211241
)
212242
has_original_attr = _has_original_dunder(
213243
value,
214244
allowed_types=self.allowed_getattr,
215245
allowed_methods=self._getattr_methods,
216-
allowed_external=self.allowed_getattr_external,
246+
allowed_external=allowed_getattr_external,
217247
method_name="__getattr__",
218248
)
219249

@@ -245,7 +275,7 @@ def can_get_attr(self, value, attr):
245275
return True # pragma: no cover
246276

247277
# Properties in subclasses of allowed types may be ok if not changed
248-
for module_name, *access_path in self.allowed_getattr_external:
278+
for module_name, *access_path in allowed_getattr_external:
249279
try:
250280
external_class = _get_external(module_name, access_path)
251281
external_class_attr_val = getattr(external_class, attr)
@@ -257,15 +287,19 @@ def can_get_attr(self, value, attr):
257287

258288
def can_get_item(self, value, item):
259289
"""Allow accessing `__getiitem__` of allow-listed instances unless it was not modified."""
290+
allowed_getitem_external = _coerce_path_to_tuples(self.allowed_getitem_external)
260291
return _has_original_dunder(
261292
value,
262293
allowed_types=self.allowed_getitem,
263294
allowed_methods=self._getitem_methods,
264-
allowed_external=self.allowed_getitem_external,
295+
allowed_external=allowed_getitem_external,
265296
method_name="__getitem__",
266297
)
267298

268299
def can_operate(self, dunders: tuple[str, ...], a, b=None):
300+
allowed_operations_external = _coerce_path_to_tuples(
301+
self.allowed_operations_external
302+
)
269303
objects = [a]
270304
if b is not None:
271305
objects.append(b)
@@ -275,7 +309,7 @@ def can_operate(self, dunders: tuple[str, ...], a, b=None):
275309
obj,
276310
allowed_types=self.allowed_operations,
277311
allowed_methods=self._operator_dunder_methods(dunder),
278-
allowed_external=self.allowed_operations_external,
312+
allowed_external=allowed_operations_external,
279313
method_name=dunder,
280314
)
281315
for dunder in dunders
@@ -586,7 +620,12 @@ def eval_node(node: Union[ast.AST, None], context: EvaluationContext):
586620
dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
587621
if dunders:
588622
if policy.can_operate(dunders, value):
589-
return getattr(value, dunders[0])()
623+
try:
624+
return getattr(value, dunders[0])()
625+
except AttributeError:
626+
raise TypeError(
627+
f"bad operand type for unary {node.op}: {type(value)}"
628+
)
590629
else:
591630
raise GuardRejection(
592631
f"Operation (`{dunders}`) for",

tests/test_completer.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99
import sys
1010
import textwrap
11+
import types
1112
import unittest
1213
import random
1314

@@ -1403,6 +1404,58 @@ def test_completion_no_autoimport(self):
14031404
_, matches = complete(line_buffer="math.")
14041405
self.assertNotIn(".pi", matches)
14051406

1407+
def test_completion_allow_custom_getattr_per_module(self):
1408+
factory_code = textwrap.dedent(
1409+
"""
1410+
class ListFactory:
1411+
def __getattr__(self, attr):
1412+
return []
1413+
"""
1414+
)
1415+
1416+
safe_lib = types.ModuleType("my.safe.lib")
1417+
sys.modules["my.safe.lib"] = safe_lib
1418+
exec(factory_code, safe_lib.__dict__)
1419+
1420+
unsafe_lib = types.ModuleType("my.unsafe.lib")
1421+
sys.modules["my.unsafe.lib"] = unsafe_lib
1422+
exec(factory_code, unsafe_lib.__dict__)
1423+
1424+
ip = get_ipython()
1425+
ip.user_ns["safe_list_factory"] = safe_lib.ListFactory()
1426+
ip.user_ns["unsafe_list_factory"] = unsafe_lib.ListFactory()
1427+
complete = ip.Completer.complete
1428+
with (
1429+
evaluation_policy("limited", allowed_getattr_external={"my.safe.lib"}),
1430+
jedi_status(False),
1431+
):
1432+
_, matches = complete(line_buffer="safe_list_factory.example.")
1433+
self.assertIn(".append", matches)
1434+
# this also checks against https://github.com/ipython/ipython/issues/14916
1435+
# because removing "un" would cause this test to incorrectly pass
1436+
_, matches = complete(line_buffer="unsafe_list_factory.example.")
1437+
self.assertNotIn(".append", matches)
1438+
1439+
sys.modules["my"] = types.ModuleType("my")
1440+
1441+
with (
1442+
evaluation_policy("limited", allowed_getattr_external={"my"}),
1443+
jedi_status(False),
1444+
):
1445+
_, matches = complete(line_buffer="safe_list_factory.example.")
1446+
self.assertIn(".append", matches)
1447+
_, matches = complete(line_buffer="unsafe_list_factory.example.")
1448+
self.assertIn(".append", matches)
1449+
1450+
with (
1451+
evaluation_policy("limited"),
1452+
jedi_status(False),
1453+
):
1454+
_, matches = complete(line_buffer="safe_list_factory.example.")
1455+
self.assertNotIn(".append", matches)
1456+
_, matches = complete(line_buffer="unsafe_list_factory.example.")
1457+
self.assertNotIn(".append", matches)
1458+
14061459
def test_dict_key_completion_bytes(self):
14071460
"""Test handling of bytes in dict key completion"""
14081461
ip = get_ipython()

0 commit comments

Comments
 (0)