test_guarded_eval.py
582 lines
| 14.8 KiB
| text/x-python
|
PythonLexer
krassowski
|
r27926 | from contextlib import contextmanager | ||
krassowski
|
r27906 | from typing import NamedTuple | ||
krassowski
|
r27921 | from functools import partial | ||
krassowski
|
r27913 | from IPython.core.guarded_eval import ( | ||
EvaluationContext, | ||||
GuardRejection, | ||||
guarded_eval, | ||||
krassowski
|
r27918 | _unbind_method, | ||
krassowski
|
r27913 | ) | ||
krassowski
|
r27906 | from IPython.testing import decorators as dec | ||
import pytest | ||||
krassowski
|
r27921 | def create_context(evaluation: str, **kwargs): | ||
return EvaluationContext(locals=kwargs, globals={}, evaluation=evaluation) | ||||
krassowski
|
r27906 | |||
krassowski
|
r27921 | forbidden = partial(create_context, "forbidden") | ||
minimal = partial(create_context, "minimal") | ||||
limited = partial(create_context, "limited") | ||||
unsafe = partial(create_context, "unsafe") | ||||
dangerous = partial(create_context, "dangerous") | ||||
LIMITED_OR_HIGHER = [limited, unsafe, dangerous] | ||||
MINIMAL_OR_HIGHER = [minimal, *LIMITED_OR_HIGHER] | ||||
krassowski
|
r27913 | |||
krassowski
|
r27906 | |||
krassowski
|
r27926 | @contextmanager | ||
def module_not_installed(module: str): | ||||
import sys | ||||
try: | ||||
to_restore = sys.modules[module] | ||||
del sys.modules[module] | ||||
except KeyError: | ||||
to_restore = None | ||||
try: | ||||
yield | ||||
finally: | ||||
sys.modules[module] = to_restore | ||||
krassowski
|
r27929 | def test_external_not_installed(): | ||
""" | ||||
Because attribute check requires checking if object is not of allowed | ||||
external type, this tests logic for absence of external module. | ||||
""" | ||||
class Custom: | ||||
def __init__(self): | ||||
self.test = 1 | ||||
def __getattr__(self, key): | ||||
return key | ||||
with module_not_installed("pandas"): | ||||
context = limited(x=Custom()) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("x.test", context) | ||||
@dec.skip_without("pandas") | ||||
def test_external_changed_api(monkeypatch): | ||||
"""Check that the execution rejects if external API changed paths""" | ||||
import pandas as pd | ||||
series = pd.Series([1], index=["a"]) | ||||
with monkeypatch.context() as m: | ||||
m.delattr(pd, "Series") | ||||
context = limited(data=series) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("data.iloc[0]", context) | ||||
krassowski
|
r27913 | @dec.skip_without("pandas") | ||
krassowski
|
r27906 | def test_pandas_series_iloc(): | ||
import pandas as pd | ||||
krassowski
|
r27913 | |||
series = pd.Series([1], index=["a"]) | ||||
krassowski
|
r27914 | context = limited(data=series) | ||
krassowski
|
r27913 | assert guarded_eval("data.iloc[0]", context) == 1 | ||
krassowski
|
r27906 | |||
krassowski
|
r27926 | def test_rejects_custom_properties(): | ||
class BadProperty: | ||||
@property | ||||
def iloc(self): | ||||
return [None] | ||||
series = BadProperty() | ||||
context = limited(data=series) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("data.iloc[0]", context) | ||||
@dec.skip_without("pandas") | ||||
def test_accepts_non_overriden_properties(): | ||||
import pandas as pd | ||||
class GoodProperty(pd.Series): | ||||
pass | ||||
series = GoodProperty([1], index=["a"]) | ||||
context = limited(data=series) | ||||
assert guarded_eval("data.iloc[0]", context) == 1 | ||||
krassowski
|
r27913 | @dec.skip_without("pandas") | ||
krassowski
|
r27906 | def test_pandas_series(): | ||
import pandas as pd | ||||
krassowski
|
r27913 | |||
krassowski
|
r27914 | context = limited(data=pd.Series([1], index=["a"])) | ||
krassowski
|
r27906 | assert guarded_eval('data["a"]', context) == 1 | ||
with pytest.raises(KeyError): | ||||
guarded_eval('data["c"]', context) | ||||
krassowski
|
r27913 | @dec.skip_without("pandas") | ||
krassowski
|
r27906 | def test_pandas_bad_series(): | ||
import pandas as pd | ||||
krassowski
|
r27913 | |||
krassowski
|
r27906 | class BadItemSeries(pd.Series): | ||
def __getitem__(self, key): | ||||
krassowski
|
r27913 | return "CUSTOM_ITEM" | ||
krassowski
|
r27906 | |||
class BadAttrSeries(pd.Series): | ||||
def __getattr__(self, key): | ||||
krassowski
|
r27913 | return "CUSTOM_ATTR" | ||
krassowski
|
r27906 | |||
krassowski
|
r27913 | bad_series = BadItemSeries([1], index=["a"]) | ||
krassowski
|
r27914 | context = limited(data=bad_series) | ||
krassowski
|
r27906 | |||
with pytest.raises(GuardRejection): | ||||
guarded_eval('data["a"]', context) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval('data["c"]', context) | ||||
# note: here result is a bit unexpected because | ||||
# pandas `__getattr__` calls `__getitem__`; | ||||
# FIXME - special case to handle it? | ||||
krassowski
|
r27913 | assert guarded_eval("data.a", context) == "CUSTOM_ITEM" | ||
krassowski
|
r27906 | |||
context = unsafe(data=bad_series) | ||||
krassowski
|
r27913 | assert guarded_eval('data["a"]', context) == "CUSTOM_ITEM" | ||
krassowski
|
r27906 | |||
krassowski
|
r27913 | bad_attr_series = BadAttrSeries([1], index=["a"]) | ||
krassowski
|
r27914 | context = limited(data=bad_attr_series) | ||
krassowski
|
r27906 | assert guarded_eval('data["a"]', context) == 1 | ||
with pytest.raises(GuardRejection): | ||||
krassowski
|
r27913 | guarded_eval("data.a", context) | ||
krassowski
|
r27906 | |||
krassowski
|
r27913 | @dec.skip_without("pandas") | ||
krassowski
|
r27906 | def test_pandas_dataframe_loc(): | ||
import pandas as pd | ||||
from pandas.testing import assert_series_equal | ||||
krassowski
|
r27913 | |||
data = pd.DataFrame([{"a": 1}]) | ||||
krassowski
|
r27914 | context = limited(data=data) | ||
krassowski
|
r27913 | assert_series_equal(guarded_eval('data.loc[:, "a"]', context), data["a"]) | ||
krassowski
|
r27906 | |||
def test_named_tuple(): | ||||
class GoodNamedTuple(NamedTuple): | ||||
a: str | ||||
pass | ||||
class BadNamedTuple(NamedTuple): | ||||
a: str | ||||
krassowski
|
r27913 | |||
krassowski
|
r27906 | def __getitem__(self, key): | ||
return None | ||||
krassowski
|
r27913 | good = GoodNamedTuple(a="x") | ||
bad = BadNamedTuple(a="x") | ||||
krassowski
|
r27906 | |||
krassowski
|
r27914 | context = limited(data=good) | ||
krassowski
|
r27913 | assert guarded_eval("data[0]", context) == "x" | ||
krassowski
|
r27906 | |||
krassowski
|
r27914 | context = limited(data=bad) | ||
krassowski
|
r27906 | with pytest.raises(GuardRejection): | ||
krassowski
|
r27913 | guarded_eval("data[0]", context) | ||
krassowski
|
r27906 | |||
def test_dict(): | ||||
krassowski
|
r27914 | context = limited(data={"a": 1, "b": {"x": 2}, ("x", "y"): 3}) | ||
krassowski
|
r27906 | assert guarded_eval('data["a"]', context) == 1 | ||
krassowski
|
r27913 | assert guarded_eval('data["b"]', context) == {"x": 2} | ||
krassowski
|
r27906 | assert guarded_eval('data["b"]["x"]', context) == 2 | ||
assert guarded_eval('data["x", "y"]', context) == 3 | ||||
krassowski
|
r27913 | assert guarded_eval("data.keys", context) | ||
krassowski
|
r27906 | |||
def test_set(): | ||||
krassowski
|
r27914 | context = limited(data={"a", "b"}) | ||
krassowski
|
r27913 | assert guarded_eval("data.difference", context) | ||
krassowski
|
r27906 | |||
def test_list(): | ||||
krassowski
|
r27914 | context = limited(data=[1, 2, 3]) | ||
krassowski
|
r27913 | assert guarded_eval("data[1]", context) == 2 | ||
assert guarded_eval("data.copy", context) | ||||
krassowski
|
r27906 | |||
def test_dict_literal(): | ||||
krassowski
|
r27914 | context = limited() | ||
krassowski
|
r27913 | assert guarded_eval("{}", context) == {} | ||
krassowski
|
r27906 | assert guarded_eval('{"a": 1}', context) == {"a": 1} | ||
def test_list_literal(): | ||||
krassowski
|
r27914 | context = limited() | ||
krassowski
|
r27913 | assert guarded_eval("[]", context) == [] | ||
krassowski
|
r27906 | assert guarded_eval('[1, "a"]', context) == [1, "a"] | ||
def test_set_literal(): | ||||
krassowski
|
r27914 | context = limited() | ||
krassowski
|
r27913 | assert guarded_eval("set()", context) == set() | ||
krassowski
|
r27906 | assert guarded_eval('{"a"}', context) == {"a"} | ||
krassowski
|
r27921 | def test_evaluates_if_expression(): | ||
krassowski
|
r27914 | context = limited() | ||
krassowski
|
r27913 | assert guarded_eval("2 if True else 3", context) == 2 | ||
assert guarded_eval("4 if False else 5", context) == 5 | ||||
krassowski
|
r27906 | |||
def test_object(): | ||||
obj = object() | ||||
krassowski
|
r27914 | context = limited(obj=obj) | ||
krassowski
|
r27913 | assert guarded_eval("obj.__dir__", context) == obj.__dir__ | ||
krassowski
|
r27906 | |||
@pytest.mark.parametrize( | ||||
"code,expected", | ||||
[ | ||||
krassowski
|
r27913 | ["int.numerator", int.numerator], | ||
["float.is_integer", float.is_integer], | ||||
["complex.real", complex.real], | ||||
], | ||||
krassowski
|
r27906 | ) | ||
def test_number_attributes(code, expected): | ||||
krassowski
|
r27914 | assert guarded_eval(code, limited()) == expected | ||
krassowski
|
r27906 | |||
def test_method_descriptor(): | ||||
krassowski
|
r27914 | context = limited() | ||
krassowski
|
r27913 | assert guarded_eval("list.copy.__name__", context) == "copy" | ||
krassowski
|
r27906 | |||
@pytest.mark.parametrize( | ||||
"data,good,bad,expected", | ||||
[ | ||||
krassowski
|
r27913 | [[1, 2, 3], "data.index(2)", "data.append(4)", 1], | ||
[{"a": 1}, "data.keys().isdisjoint({})", "data.update()", True], | ||||
], | ||||
krassowski
|
r27906 | ) | ||
krassowski
|
r27921 | def test_evaluates_calls(data, good, bad, expected): | ||
krassowski
|
r27914 | context = limited(data=data) | ||
krassowski
|
r27906 | assert guarded_eval(good, context) == expected | ||
with pytest.raises(GuardRejection): | ||||
guarded_eval(bad, context) | ||||
@pytest.mark.parametrize( | ||||
"code,expected", | ||||
[ | ||||
krassowski
|
r27913 | ["(1\n+\n1)", 2], | ||
["list(range(10))[-1:]", [9]], | ||||
["list(range(20))[3:-2:3]", [3, 6, 9, 12, 15]], | ||||
], | ||||
krassowski
|
r27906 | ) | ||
krassowski
|
r27921 | @pytest.mark.parametrize("context", LIMITED_OR_HIGHER) | ||
def test_evaluates_complex_cases(code, expected, context): | ||||
assert guarded_eval(code, context()) == expected | ||||
@pytest.mark.parametrize( | ||||
"code,expected", | ||||
[ | ||||
["1", 1], | ||||
["1.0", 1.0], | ||||
["0xdeedbeef", 0xDEEDBEEF], | ||||
["True", True], | ||||
["None", None], | ||||
["{}", {}], | ||||
["[]", []], | ||||
], | ||||
) | ||||
@pytest.mark.parametrize("context", MINIMAL_OR_HIGHER) | ||||
def test_evaluates_literals(code, expected, context): | ||||
assert guarded_eval(code, context()) == expected | ||||
krassowski
|
r27906 | |||
krassowski
|
r27920 | @pytest.mark.parametrize( | ||
"code,expected", | ||||
[ | ||||
["-5", -5], | ||||
["+5", +5], | ||||
["~5", -6], | ||||
], | ||||
) | ||||
krassowski
|
r27921 | @pytest.mark.parametrize("context", LIMITED_OR_HIGHER) | ||
def test_evaluates_unary_operations(code, expected, context): | ||||
assert guarded_eval(code, context()) == expected | ||||
krassowski
|
r27920 | |||
@pytest.mark.parametrize( | ||||
"code,expected", | ||||
[ | ||||
["1 + 1", 2], | ||||
["3 - 1", 2], | ||||
["2 * 3", 6], | ||||
["5 // 2", 2], | ||||
["5 / 2", 2.5], | ||||
["5**2", 25], | ||||
["2 >> 1", 1], | ||||
["2 << 1", 4], | ||||
["1 | 2", 3], | ||||
["1 & 1", 1], | ||||
["1 & 2", 0], | ||||
], | ||||
) | ||||
krassowski
|
r27921 | @pytest.mark.parametrize("context", LIMITED_OR_HIGHER) | ||
def test_evaluates_binary_operations(code, expected, context): | ||||
assert guarded_eval(code, context()) == expected | ||||
krassowski
|
r27920 | |||
@pytest.mark.parametrize( | ||||
"code,expected", | ||||
[ | ||||
["2 > 1", True], | ||||
["2 < 1", False], | ||||
["2 <= 1", False], | ||||
["2 <= 2", True], | ||||
["1 >= 2", False], | ||||
["2 >= 2", True], | ||||
["2 == 2", True], | ||||
["1 == 2", False], | ||||
["1 != 2", True], | ||||
["1 != 1", False], | ||||
["1 < 4 < 3", False], | ||||
["(1 < 4) < 3", True], | ||||
["4 > 3 > 2 > 1", True], | ||||
["4 > 3 > 2 > 9", False], | ||||
["1 < 2 < 3 < 4", True], | ||||
["9 < 2 < 3 < 4", False], | ||||
["1 < 2 > 1 > 0 > -1 < 1", True], | ||||
["1 in [1] in [[1]]", True], | ||||
["1 in [1] in [[2]]", False], | ||||
["1 in [1]", True], | ||||
["0 in [1]", False], | ||||
["1 not in [1]", False], | ||||
["0 not in [1]", True], | ||||
["True is True", True], | ||||
["False is False", True], | ||||
["True is False", False], | ||||
krassowski
|
r27921 | ["True is not True", False], | ||
["False is not True", True], | ||||
krassowski
|
r27920 | ], | ||
) | ||||
krassowski
|
r27921 | @pytest.mark.parametrize("context", LIMITED_OR_HIGHER) | ||
def test_evaluates_comparisons(code, expected, context): | ||||
assert guarded_eval(code, context()) == expected | ||||
def test_guards_comparisons(): | ||||
class GoodEq(int): | ||||
pass | ||||
class BadEq(int): | ||||
def __eq__(self, other): | ||||
assert False | ||||
context = limited(bad=BadEq(1), good=GoodEq(1)) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("bad == 1", context) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("bad != 1", context) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("1 == bad", context) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("1 != bad", context) | ||||
assert guarded_eval("good == 1", context) is True | ||||
assert guarded_eval("good != 1", context) is False | ||||
assert guarded_eval("1 == good", context) is True | ||||
assert guarded_eval("1 != good", context) is False | ||||
def test_guards_unary_operations(): | ||||
class GoodOp(int): | ||||
pass | ||||
class BadOpInv(int): | ||||
def __inv__(self, other): | ||||
assert False | ||||
class BadOpInverse(int): | ||||
def __inv__(self, other): | ||||
assert False | ||||
context = limited(good=GoodOp(1), bad1=BadOpInv(1), bad2=BadOpInverse(1)) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("~bad1", context) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("~bad2", context) | ||||
def test_guards_binary_operations(): | ||||
class GoodOp(int): | ||||
pass | ||||
krassowski
|
r27920 | |||
krassowski
|
r27921 | class BadOp(int): | ||
def __add__(self, other): | ||||
assert False | ||||
krassowski
|
r27920 | |||
krassowski
|
r27921 | context = limited(good=GoodOp(1), bad=BadOp(1)) | ||
with pytest.raises(GuardRejection): | ||||
guarded_eval("1 + bad", context) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("bad + 1", context) | ||||
assert guarded_eval("good + 1", context) == 2 | ||||
assert guarded_eval("1 + good", context) == 2 | ||||
def test_guards_attributes(): | ||||
class GoodAttr(float): | ||||
pass | ||||
class BadAttr1(float): | ||||
def __getattr__(self, key): | ||||
assert False | ||||
class BadAttr2(float): | ||||
def __getattribute__(self, key): | ||||
assert False | ||||
context = limited(good=GoodAttr(0.5), bad1=BadAttr1(0.5), bad2=BadAttr2(0.5)) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("bad1.as_integer_ratio", context) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("bad2.as_integer_ratio", context) | ||||
assert guarded_eval("good.as_integer_ratio()", context) == (1, 2) | ||||
@pytest.mark.parametrize("context", MINIMAL_OR_HIGHER) | ||||
def test_access_builtins(context): | ||||
assert guarded_eval("round", context()) == round | ||||
def test_access_builtins_fails(): | ||||
krassowski
|
r27915 | context = limited() | ||
krassowski
|
r27921 | with pytest.raises(NameError): | ||
guarded_eval("this_is_not_builtin", context) | ||||
def test_rejects_forbidden(): | ||||
context = forbidden() | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("1", context) | ||||
def test_guards_locals_and_globals(): | ||||
context = EvaluationContext( | ||||
locals={"local_a": "a"}, globals={"global_b": "b"}, evaluation="minimal" | ||||
) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("local_a", context) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("global_b", context) | ||||
def test_access_locals_and_globals(): | ||||
context = EvaluationContext( | ||||
locals={"local_a": "a"}, globals={"global_b": "b"}, evaluation="limited" | ||||
) | ||||
assert guarded_eval("local_a", context) == "a" | ||||
assert guarded_eval("global_b", context) == "b" | ||||
@pytest.mark.parametrize( | ||||
"code", | ||||
["def func(): pass", "class C: pass", "x = 1", "x += 1", "del x", "import ast"], | ||||
) | ||||
@pytest.mark.parametrize("context", [minimal(), limited(), unsafe()]) | ||||
def test_rejects_side_effect_syntax(code, context): | ||||
with pytest.raises(SyntaxError): | ||||
guarded_eval(code, context) | ||||
krassowski
|
r27915 | |||
krassowski
|
r27906 | def test_subscript(): | ||
context = EvaluationContext( | ||||
krassowski
|
r27918 | locals={}, globals={}, evaluation="limited", in_subscript=True | ||
krassowski
|
r27906 | ) | ||
empty_slice = slice(None, None, None) | ||||
krassowski
|
r27913 | assert guarded_eval("", context) == tuple() | ||
assert guarded_eval(":", context) == empty_slice | ||||
assert guarded_eval("1:2:3", context) == slice(1, 2, 3) | ||||
krassowski
|
r27906 | assert guarded_eval(':, "a"', context) == (empty_slice, "a") | ||
def test_unbind_method(): | ||||
class X(list): | ||||
def index(self, k): | ||||
krassowski
|
r27913 | return "CUSTOM" | ||
krassowski
|
r27906 | x = X() | ||
krassowski
|
r27918 | assert _unbind_method(x.index) is X.index | ||
assert _unbind_method([].index) is list.index | ||||
krassowski
|
r27929 | assert _unbind_method(list.index) is None | ||
krassowski
|
r27906 | |||
def test_assumption_instance_attr_do_not_matter(): | ||||
"""This is semi-specified in Python documentation. | ||||
However, since the specification says 'not guaranted | ||||
to work' rather than 'is forbidden to work', future | ||||
versions could invalidate this assumptions. This test | ||||
is meant to catch such a change if it ever comes true. | ||||
""" | ||||
krassowski
|
r27913 | |||
krassowski
|
r27906 | class T: | ||
def __getitem__(self, k): | ||||
krassowski
|
r27913 | return "a" | ||
krassowski
|
r27906 | def __getattr__(self, k): | ||
krassowski
|
r27913 | return "a" | ||
krassowski
|
r27926 | def f(self): | ||
return "b" | ||||
krassowski
|
r27906 | t = T() | ||
krassowski
|
r27926 | t.__getitem__ = f | ||
t.__getattr__ = f | ||||
krassowski
|
r27913 | assert t[1] == "a" | ||
assert t[1] == "a" | ||||
krassowski
|
r27906 | |||
def test_assumption_named_tuples_share_getitem(): | ||||
"""Check assumption on named tuples sharing __getitem__""" | ||||
from typing import NamedTuple | ||||
class A(NamedTuple): | ||||
pass | ||||
class B(NamedTuple): | ||||
pass | ||||
assert A.__getitem__ == B.__getitem__ | ||||
Carlos Cordoba
|
r28227 | |||
@dec.skip_without("numpy") | ||||
def test_module_access(): | ||||
import numpy | ||||
context = limited(numpy=numpy) | ||||
assert guarded_eval("numpy.linalg.norm", context) == numpy.linalg.norm | ||||
context = minimal(numpy=numpy) | ||||
with pytest.raises(GuardRejection): | ||||
guarded_eval("np.linalg.norm", context) | ||||