diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fd41c6..48a27a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Codecov +name: CI on: [push] jobs: lint: @@ -18,7 +18,14 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python: ['2.7', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12-dev', 'pypy-3.8'] + python: + - '3.9' + - '3.10' + - '3.11' + - '3.12' + - '3.13' + - 'pypy-3.9' + - 'pypy-3.10' env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python }} @@ -29,13 +36,16 @@ jobs: uses: actions/setup-python@master with: python-version: ${{ matrix.python }} + allow-prereleases: true + - name: Generate Report run: | pip install coverage coverage run -m test_simpleeval coverage xml - name: Upload to codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4.0.1 with: files: coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} verbose: true diff --git a/LICENCE b/LICENCE index a71b854..0502c20 100644 --- a/LICENCE +++ b/LICENCE @@ -1,4 +1,4 @@ -simpleeval - Copyright (c) 2013-2017 Daniel Fairhead +simpleeval - Copyright (c) 2013-2024 Daniel Fairhead (MIT Licence) diff --git a/Makefile b/Makefile index 7f6e9f3..bcc6ba1 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ test: - python test_simpleeval.py + python -Werror test_simpleeval.py autotest: find . -name \*.py -not -path .\/.v\* | entr make test @@ -22,11 +22,10 @@ coverage: coverage run test_simpleeval.py lint: - black --check --diff simpleeval.py test_simpleeval.py - isort --check-only --diff simpleeval.py test_simpleeval.py - pylint simpleeval.py test_simpleeval.py + ruff check simpleeval.py test_simpleeval.py + ruff format --check simpleeval.py test_simpleeval.py mypy simpleeval.py test_simpleeval.py format: - black simpleeval.py test_simpleeval.py - isort simpleeval.py test_simpleeval.py + ruff check --fix-only simpleeval.py test_simpleeval.py + ruff format simpleeval.py test_simpleeval.py diff --git a/README.rst b/README.rst index 6a98f50..3bced32 100644 --- a/README.rst +++ b/README.rst @@ -1,23 +1,28 @@ simpleeval (Simple Eval) ======================== -.. image:: https://github.com/danthedeckie/simpleeval/actions/workflows/ci.yml/badge.svg?branch=gh-actions-build +.. |build-status| image:: https://github.com/danthedeckie/simpleeval/actions/workflows/ci.yml/badge.svg?branch=gh-actions-build :target: https://github.com/danthedeckie/simpleeval/actions/ :alt: Build Status -.. image:: https://codecov.io/gh/danthedeckie/simpleeval/branch/master/graph/badge.svg?token=isRnN1yrca +.. |code-coverage| image:: https://codecov.io/gh/danthedeckie/simpleeval/branch/master/graph/badge.svg?token=isRnN1yrca :target: https://codecov.io/gh/danthedeckie/simpleeval - :alt: Code Coverage + :alt: Code Coverage Status -.. image:: https://badge.fury.io/py/simpleeval.svg +.. |pypi-version| image:: https://badge.fury.io/py/simpleeval.svg :target: https://badge.fury.io/py/simpleeval :alt: PyPI Version -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black +.. |python-versions| image:: https://img.shields.io/badge/python-3.9_%7C_3.10_%7C_3.11_%7C_3.12_%7C_3.13_%7C_PyPy3.9_%7C_PyPy3.10-blue + :alt: Static Badge -.. image:: https://img.shields.io/badge/linting-pylint-yellowgreen - :target: https://github.com/PyCQA/pylint +.. |pypi-monthly-downloads| image:: https://img.shields.io/pypi/dm/SimpleEval + :alt: PyPI - Downloads + +.. |formatting-with-ruff| image:: https://img.shields.io/badge/-ruff-black?logo=lightning&logoColor=%2300ff00&link=https%3A%2F%2Fdocs.astral.sh%2Fruff%2F + :alt: Static Badge + +|build-status| |code-coverage| |pypi-version| |python-versions| |pypi-monthly-downloads| |formatting-with-ruff| A single file library for easily adding evaluatable expressions into @@ -157,7 +162,7 @@ The ``^`` operator is often mistaken for a exponent operator, not the bitwise operation that it is in python, so if you want ``3 ^ 2`` to equal ``9``, you can replace the operator like this: -.. code-block:: python +.. code-block:: pycon >>> import ast >>> from simpleeval import safe_power @@ -200,7 +205,7 @@ If Expressions You can use python style ``if x then y else z`` type expressions: -.. code-block:: python +.. code-block:: pycon >>> simple_eval("'equal' if x == y else 'not equal'", names={"x": 1, "y": 2}) @@ -208,7 +213,7 @@ You can use python style ``if x then y else z`` type expressions: which, of course, can be nested: -.. code-block:: python +.. code-block:: pycon >>> simple_eval("'a' if 1 == 2 else 'b' if 2 == 3 else 'c'") 'c' @@ -219,7 +224,7 @@ Functions You can define functions which you'd like the expresssions to have access to: -.. code-block:: python +.. code-block:: pycon >>> simple_eval("double(21)", functions={"double": lambda x:x*2}) 42 @@ -227,7 +232,7 @@ You can define functions which you'd like the expresssions to have access to: You can define "real" functions to pass in rather than lambdas, of course too, and even re-name them so that expressions can be shorter -.. code-block:: python +.. code-block:: pycon >>> def double(x): return x * 2 @@ -246,13 +251,13 @@ are provided in the ``DEFAULT_FUNCTIONS`` dict: +----------------+--------------------------------------------------+ | ``float(x)`` | Convert ``x`` to a ``float``. | +----------------+--------------------------------------------------+ -| ``str(x)`` | Convert ``x`` to a ``str`` (``unicode`` in py2) | +| ``str(x)`` | Convert ``x`` to a ``str`` | +----------------+--------------------------------------------------+ If you want to provide a list of functions, but want to keep these as well, then you can do a normal python ``.copy()`` & ``.update``: -.. code-block:: python +.. code-block:: pycon >>> my_functions = simpleeval.DEFAULT_FUNCTIONS.copy() >>> my_functions.update( @@ -267,7 +272,7 @@ Names Sometimes it's useful to have variables available, which in python terminology are called 'names'. -.. code-block:: python +.. code-block:: pycon >>> simple_eval("a + b", names={"a": 11, "b": 100}) 111 @@ -275,7 +280,7 @@ are called 'names'. You can also hand the handling of names over to a function, if you prefer: -.. code-block:: python +.. code-block:: pycon >>> def name_handler(node): return ord(node.id[0].lower(a))-96 @@ -284,9 +289,34 @@ You can also hand the handling of names over to a function, if you prefer: 3 That was a bit of a silly example, but you could use this for pulling values -from a database or file, say, or doing some kind of caching system. +from a database or file, looking up spreadsheet cells, say, or doing some kind of caching system. -The two default names that are provided are ``True`` and ``False``. So if you want to provide your own names, but want ``True`` and ``False`` to keep working, either provide them yourself, or ``.copy()`` and ``.update`` the ``DEFAULT_NAMES``. (See functions example above). +In general, when it attempts to find a variable by name, if it cannot find one, +then it will look in the ``functions`` for a function of that name. If you want your name handler +function to return an "I can't find that name!", then it should raise a ``simpleeval.NameNotDefined`` +exception. Eg: + +.. code-block:: pycon + + >>> def name_handler(node): + ... if node.id[0] == 'a': + ... return 21 + ... raise NameNotDefined(node.id[0], "Not found") + ... + ... simple_eval('a + a', names=name_handler, functions={"b": 100}) + + 42 + + >>> simple_eval('a + b', names=name_handler, functions={'b': 100}) + 121 + +(Note: in that example, putting a number directly into the ``functions`` dict was done just to +show the fall-back to functions. Normally only put actual callables in there.) + + +The two default names that are provided are ``True`` and ``False``. So if you want to provide +your own names, but want ``True`` and ``False`` to keep working, either provide them yourself, +or ``.copy()`` and ``.update`` the ``DEFAULT_NAMES``. (See functions example above). Creating an Evaluator Class --------------------------- @@ -296,7 +326,7 @@ evaluations, you can create a SimpleEval object, and pass it expressions each time (which should be a bit quicker, and certainly more convenient for some use cases): -.. code-block:: python +.. code-block:: pycon >>> s = SimpleEval() @@ -375,7 +405,7 @@ comprehensions. Since the primary intention of this library is short expressions - an extra 'sweetener' is enabled by default. You can access a dict (or similar's) keys using the .attr syntax: -.. code-block:: python +.. code-block:: pycon >>> simple_eval("foo.bar", names={"foo": {"bar": 42}}) 42 @@ -405,8 +435,7 @@ and then use ``EvalNoMethods`` instead of the ``SimpleEval`` class. Other... -------- -The library supports python 3 - but should be mostly compatible (and tested before 0.9.11) -with python 2.7 as well. +The library supports Python 3.9 and higher. Object attributes that start with ``_`` or ``func_`` are disallowed by default. If you really need that (BE CAREFUL!), then modify the module global diff --git a/pyproject.toml b/pyproject.toml index 330c483..c0eb5eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,9 @@ build-backend = "setuptools.build_meta" line-length = 99 target-version = ['py310'] +[tool.ruff] +line-length = 99 + [tool.isort] combine_as_imports = true float_to_top = true diff --git a/requirements/dev.txt b/requirements/dev.txt index 20b576a..60e68af 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,4 +1,2 @@ -black==23.1.0 -isort==5.10.1 -pylint==2.12.2 -mypy==0.931 +ruff==0.6.9 +mypy==1.11.2 diff --git a/setup.cfg b/setup.cfg index ed290d4..52eba7b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,15 +1,16 @@ [metadata] name = simpleeval -version = 0.9.13 +version = 1.0.0 author = Daniel Fairhead author_email = danthedeckie@gmail.com description = A simple, safe single expression evaluator library. keywords = eval, simple, expression, parse, ast url = https://github.com/danthedeckie/simpleeval +license = MIT long_description = file: README.rst long_description_content_type = text/x-rst classifiers = - Development Status :: 4 - Beta + Development Status :: 5 - Production/Stable Intended Audience :: Developers License :: OSI Approved :: MIT License Topic :: Software Development :: Libraries :: Python Modules @@ -17,9 +18,7 @@ classifiers = [options] py_modules = simpleeval - -[bdist_wheel] -universal=1 +python_requires = >=3.9 [pycodestyle] max_line_length = 99 diff --git a/simpleeval.py b/simpleeval.py index df75d6a..9b0de5b 100644 --- a/simpleeval.py +++ b/simpleeval.py @@ -1,5 +1,5 @@ """ -SimpleEval - (C) 2013-2023 Daniel Fairhead +SimpleEval - (C) 2013-2024 Daniel Fairhead ------------------------------------- An short, easy to use, safe and reasonably extensible expression evaluator. @@ -58,6 +58,11 @@ - daxamin (Dax Amin) Better error for attempting to eval empty string - smurfix (Matthias Urlichs) Allow clearing functions / operators / etc completely - koenigsley (Mikhail Yeremeyev) documentation typos correction. +- kurtmckee (Kurt McKee) Infrastructure updates +- edgarrmondragon (Edgar Ramírez-Mondragón) Address Python 3.12+ deprecation warnings +- cedk (Cédric Krier) Allow running tests with Werror +- decorator-factory More security fixes +- lkruitwagen (Lucas Kruitwagen) Adding support for dict comprehensions ------------------------------------- Basic Usage: @@ -103,9 +108,6 @@ import warnings from random import random -PYTHON3 = sys.version_info[0] == 3 -PYTHON35 = PYTHON3 and sys.version_info > (3, 5) - ######################################## # Module wide 'globals' @@ -115,7 +117,16 @@ MAX_SHIFT = 10000 # highest << or >> (lshift / rshift) MAX_SHIFT_BASE = int(sys.float_info.max) # highest on left side of << or >> DISALLOW_PREFIXES = ["_", "func_"] -DISALLOW_METHODS = ["format", "format_map", "mro"] +DISALLOW_METHODS = [ + "format", + "format_map", + "mro", + "tb_frame", + "gi_frame", + "ag_frame", + "cr_frame", + "exec", +] # Disallow functions: # This, strictly speaking, is not necessary. These /should/ never be accessable anyway, @@ -124,7 +135,7 @@ # their functionality is required, then please wrap them up in a safe container. And think # very hard about it first. And don't say I didn't warn you. # builtins is a dict in python >3.6 but a module before -DISALLOW_FUNCTIONS = {type, isinstance, eval, getattr, setattr, repr, compile, open} +DISALLOW_FUNCTIONS = {type, isinstance, eval, getattr, setattr, repr, compile, open, exec} if hasattr(__builtins__, "help") or ( hasattr(__builtins__, "__contains__") and "help" in __builtins__ # type: ignore ): @@ -132,11 +143,6 @@ DISALLOW_FUNCTIONS.add(help) -if PYTHON3: - # exec is not a function in Python2... - exec("DISALLOW_FUNCTIONS.add(exec)") # pylint: disable=exec-used - - ######################################## # Exceptions: @@ -317,8 +323,7 @@ def safe_lshift(a, b): # pylint: disable=invalid-name "randint": random_int, "int": int, "float": float, - # pylint: disable=undefined-variable - "str": str if PYTHON3 else unicode, # type: ignore + "str": str, } DEFAULT_NAMES = {"True": True, "False": False, "None": None} @@ -360,8 +365,6 @@ def __init__(self, operators=None, functions=None, names=None): ast.Assign: self._eval_assign, ast.AugAssign: self._eval_aug_assign, ast.Import: self._eval_import, - ast.Num: self._eval_num, - ast.Str: self._eval_str, ast.Name: self._eval_name, ast.UnaryOp: self._eval_unaryop, ast.BinOp: self._eval_binop, @@ -374,22 +377,23 @@ def __init__(self, operators=None, functions=None, names=None): ast.Attribute: self._eval_attribute, ast.Index: self._eval_index, ast.Slice: self._eval_slice, + ast.JoinedStr: self._eval_joinedstr, + ast.FormattedValue: self._eval_formattedvalue, + ast.Constant: self._eval_constant, } - # py3k stuff: - if hasattr(ast, "NameConstant"): - self.nodes[ast.NameConstant] = self._eval_constant + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + # py3.12 deprecated ast.Num, ast.Str, ast.NameConstant + # https://docs.python.org/3.12/whatsnew/3.12.html#deprecated + if Num := getattr(ast, "Num"): + self.nodes[Num] = self._eval_num - # py3.6, f-strings - if hasattr(ast, "JoinedStr"): - self.nodes[ast.JoinedStr] = self._eval_joinedstr # f-string - self.nodes[ - ast.FormattedValue - ] = self._eval_formattedvalue # formatted value in f-string + if Str := getattr(ast, "Str"): + self.nodes[Str] = self._eval_str - # py3.8 uses ast.Constant instead of ast.Num, ast.Str, ast.NameConstant - if hasattr(ast, "Constant"): - self.nodes[ast.Constant] = self._eval_constant + if NameConstant := getattr(ast, "NameConstant"): + self.nodes[NameConstant] = self._eval_constant # Defaults: @@ -467,8 +471,9 @@ def _eval_num(node): def _eval_str(node): if len(node.s) > MAX_STRING_LENGTH: raise IterableTooLong( - "String Literal in statement is too long!" - " ({0}, when {1} is max)".format(len(node.s), MAX_STRING_LENGTH) + "String Literal in statement is too long! ({0}, when {1} is max)".format( + len(node.s), MAX_STRING_LENGTH + ) ) return node.s @@ -476,8 +481,9 @@ def _eval_str(node): def _eval_constant(node): if hasattr(node.value, "__len__") and len(node.value) > MAX_STRING_LENGTH: raise IterableTooLong( - "Literal in statement is too long!" - " ({0}, when {1} is max)".format(len(node.value), MAX_STRING_LENGTH) + "Literal in statement is too long! ({0}, when {1} is max)".format( + len(node.value), MAX_STRING_LENGTH + ) ) return node.value @@ -548,24 +554,30 @@ def _eval_name(self, node): try: # This happens at least for slicing # This is a safe thing to do because it is impossible - # that there is a true exression assigning to none + # that there is a true expression assigning to none # (the compiler rejects it, so you can't even # pass that to ast.parse) - if hasattr(self.names, "__getitem__"): - return self.names[node.id] - if callable(self.names): + return self.names[node.id] + + except (TypeError, KeyError): + pass + + if callable(self.names): + try: return self.names(node) + except NameNotDefined: + pass + elif not hasattr(self.names, "__getitem__"): raise InvalidExpression( 'Trying to use name (variable) "{0}"' ' when no "names" defined for' " evaluator".format(node.id) ) - except KeyError: - if node.id in self.functions: - return self.functions[node.id] + if node.id in self.functions: + return self.functions[node.id] - raise NameNotDefined(node.id, self.expr) + raise NameNotDefined(node.id, self.expr) def _eval_subscript(self, node): container = self._eval(node.value) @@ -656,6 +668,7 @@ def __init__(self, operators=None, functions=None, names=None): ast.Set: self._eval_set, ast.ListComp: self._eval_comprehension, ast.GeneratorExp: self._eval_comprehension, + ast.DictComp: self._eval_comprehension, } ) @@ -668,7 +681,7 @@ def _eval_dict(self, node): result = {} for key, value in zip(node.keys, node.values): - if PYTHON35 and key is None: + if key is None: # "{**x}" gets parsed as a key-value pair of (None, Name(x)) result.update(self._eval(value)) else: @@ -680,7 +693,7 @@ def _eval_list(self, node): result = [] for item in node.elts: - if PYTHON3 and isinstance(item, ast.Starred): + if isinstance(item, ast.Starred): result.extend(self._eval(item.value)) else: result.append(self._eval(item)) @@ -694,7 +707,10 @@ def _eval_set(self, node): return set(self._eval(x) for x in node.elts) def _eval_comprehension(self, node): - to_return = [] + if isinstance(node, ast.DictComp): + to_return = {} + else: + to_return = [] extra_names = {} @@ -733,7 +749,10 @@ def do_generator(gi=0): if len(node.generators) > gi + 1: do_generator(gi + 1) else: - to_return.append(self._eval(node.elt)) + if isinstance(to_return, dict): + to_return[self._eval(node.key)] = self._eval(node.value) + elif isinstance(to_return, list): + to_return.append(self._eval(node.elt)) try: do_generator() diff --git a/test_simpleeval.py b/test_simpleeval.py index bc83d50..53a0a72 100644 --- a/test_simpleeval.py +++ b/test_simpleeval.py @@ -1,12 +1,13 @@ # pylint: disable=too-many-public-methods, missing-docstring, eval-used, too-many-lines, no-self-use, disallowed-name, unspecified-encoding """ - Unit tests for simpleeval. - -------------------------- +Unit tests for simpleeval. +-------------------------- - Most of this stuff is pretty basic. +Most of this stuff is pretty basic. """ + import ast import gc import operator @@ -209,6 +210,7 @@ class TestEvaluator(DRYTest): def test_only_evalutate_first_statement(self): # it only evaluates the first statement: with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") self.t("11; x = 21; x + x", 11) self.assertIsInstance(ws[0].message, simpleeval.MultipleExpressions) @@ -429,9 +431,8 @@ def test_large_shifts(self): def test_encode_bignums(self): # thanks gk - if hasattr(1, "from_bytes"): # python3 only - with self.assertRaises(simpleeval.IterableTooLong): - self.t('(1).from_bytes(("123123123123123123123123").encode()*999999, "big")', 0) + with self.assertRaises(simpleeval.IterableTooLong): + self.t('(1).from_bytes(("123123123123123123123123").encode()*999999, "big")', 0) def test_string_length(self): with self.assertRaises(simpleeval.IterableTooLong): @@ -612,7 +613,6 @@ def test_dict_contains(self): self.t('{"a": 24}.get("b", 11)', 11) self.t('"a" in {"a": 24}', True) - @unittest.skipIf(not simpleeval.PYTHON35, "feature not supported") def test_dict_star_expression(self): self.s.names["x"] = {"a": 1, "b": 2} self.t('{"a": 0, **x, "c": 3}', {"a": 1, "b": 2, "c": 3}) @@ -621,7 +621,6 @@ def test_dict_star_expression(self): self.s.names["y"] = {"x": 1, "y": 2} self.t('{"a": 0, **x, **y, "c": 3}', {"a": 1, "b": 2, "c": 3, "x": 1, "y": 2}) - @unittest.skipIf(not simpleeval.PYTHON35, "feature not supported") def test_dict_invalid_star_expression(self): self.s.names["x"] = {"a": 1, "b": 2} self.s.names["y"] = {"x": 1, "y": 2} @@ -660,12 +659,10 @@ def test_list_contains(self): self.t('"b" in ["a","b"]', True) - @unittest.skipIf(not simpleeval.PYTHON3, "feature not supported") def test_list_star_expression(self): self.s.names["x"] = [1, 2, 3] self.t('["a", *x, "b"]', ["a", 1, 2, 3, "b"]) - @unittest.skipIf(not simpleeval.PYTHON3, "feature not supported") def test_list_invalid_star_expression(self): self.s.names["x"] = [1, 2, 3] self.s.names["y"] = 42 @@ -732,6 +729,24 @@ def test_unpack(self): def test_nested_unpack(self): self.t("[a+b+c for a, (b, c) in ((1,(1,1)),(3,(2,2)))]", [3, 7]) + def test_dictcomp_basic(self): + self.t("{a:a + 1 for a in [1,2,3]}", {1: 2, 2: 3, 3: 4}) + + def test_dictcomp_with_self_reference(self): + self.t("{a:a + a for a in [1,2,3]}", {1: 2, 2: 4, 3: 6}) + + def test_dictcomp_with_if(self): + self.t("{a:a for a in [1,2,3,4,5] if a <= 3}", {1: 1, 2: 2, 3: 3}) + + def test_dictcomp_with_multiple_if(self): + self.t("{a:a for a in [1,2,3,4,5] if a <= 3 and a > 1 }", {2: 2, 3: 3}) + + def test_dictcomp_unpack(self): + self.t("{a:a+b for a,b in ((1,2),(3,4))}", {1: 3, 3: 7}) + + def test_dictcomp_nested_unpack(self): + self.t("{a:a+b+c for a, (b, c) in ((1,(1,1)),(3,(2,2)))}", {1: 3, 3: 7}) + def test_other_places(self): self.s.functions = {"sum": sum} self.t("sum([a+1 for a in [1,2,3,4,5]])", 20) @@ -795,6 +810,7 @@ def test_none(self): # or if you attempt to assign an unknown name to another with self.assertRaises(NameNotDefined): with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") self.t("s += a", 21) self.assertIsInstance(ws[0].message, simpleeval.AssignmentAttempted) @@ -820,6 +836,7 @@ def test_dict(self): # however, you can't assign to those names: with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") self.t("a = 200", 200) self.assertIsInstance(ws[0].message, simpleeval.AssignmentAttempted) @@ -830,6 +847,7 @@ def test_dict(self): self.s.names["b"] = [0] with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") self.t("b[0] = 11", 11) self.assertIsInstance(ws[0].message, simpleeval.AssignmentAttempted) @@ -853,6 +871,7 @@ def test_dict(self): # you still can't assign though: with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") self.t("c['b'] = 99", 99) self.assertIsInstance(ws[0].message, simpleeval.AssignmentAttempted) @@ -863,6 +882,7 @@ def test_dict(self): self.s.names["c"]["c"] = {"c": 11} with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") self.t("c['c']['c'] = 21", 21) self.assertIsInstance(ws[0].message, simpleeval.AssignmentAttempted) @@ -878,6 +898,7 @@ def test_dict_attr_access(self): self.t("a.b.c*2", 84) with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") self.t("a.b.c = 11", 11) self.assertIsInstance(ws[0].message, simpleeval.AssignmentAttempted) @@ -885,6 +906,7 @@ def test_dict_attr_access(self): # TODO: Wat? with warnings.catch_warnings(record=True) as ws: + warnings.simplefilter("always") self.t("a.d = 11", 11) with self.assertRaises(KeyError): @@ -965,6 +987,36 @@ def name_handler(node): self.t("a", 1) self.t("a + b", 3) + def test_name_handler_name_not_found(self): + def name_handler(node): + if node.id[0] == "a": + return 21 + raise NameNotDefined(node.id[0], "not found") + + self.s.names = name_handler + self.s.functions = {"b": lambda: 100} + self.t("a + a", 42) + + self.t("b()", 100) + + with self.assertRaises(NameNotDefined): + self.t("c", None) + + def test_name_handler_raises_error(self): + # What happens if our name-handler raises a different kind of error? + # we want it to ripple up all the way... + + def name_handler(_node): + return {}["test"] + + self.s.names = name_handler + + # This should never be accessed: + self.s.functions = {"c": 42} + + with self.assertRaises(KeyError): + self.t("c", None) + class TestWhitespace(DRYTest): """test that incorrect whitespace (preceding/trailing) doesn't matter.""" @@ -1200,10 +1252,7 @@ def foo(y): class TestDisallowedFunctions(DRYTest): def test_functions_are_disallowed_at_init(self): - DISALLOWED = [type, isinstance, eval, getattr, setattr, help, repr, compile, open] - if simpleeval.PYTHON3: - # pylint: disable=exec-used - exec("DISALLOWED.append(exec)") # exec is not a function in Python2... + DISALLOWED = [type, isinstance, eval, getattr, setattr, help, repr, compile, open, exec] for f in simpleeval.DISALLOW_FUNCTIONS: assert f in DISALLOWED @@ -1213,11 +1262,7 @@ def test_functions_are_disallowed_at_init(self): SimpleEval(functions={"foo": x}) def test_functions_are_disallowed_in_expressions(self): - DISALLOWED = [type, isinstance, eval, getattr, setattr, help, repr, compile, open] - - if simpleeval.PYTHON3: - # pylint: disable=exec-used - exec("DISALLOWED.append(exec)") # exec is not a function in Python2... + DISALLOWED = [type, isinstance, eval, getattr, setattr, help, repr, compile, open, exec] for f in simpleeval.DISALLOW_FUNCTIONS: assert f in DISALLOWED @@ -1233,8 +1278,21 @@ def test_functions_are_disallowed_in_expressions(self): simpleeval.DEFAULT_FUNCTIONS = DF.copy() + def test_breakout_via_generator(self): + # Thanks decorator-factory + class Foo: + def bar(self): + yield "Hello, world!" + + # Test the genertor does work - also adds the `yield` to codecov... + assert list(Foo().bar()) == ["Hello, world!"] + + evil = "foo.bar().gi_frame.f_globals['__builtins__'].exec('raise RuntimeError(\"Oh no\")')" + + with self.assertRaises(FeatureNotAvailable): + simple_eval(evil, names={"foo": Foo()}) + -@unittest.skipIf(simpleeval.PYTHON3 is not True, "Python2 fails - but it's not supported anyway.") @unittest.skipIf(platform.python_implementation() == "PyPy", "GC set_debug not available in PyPy") class TestReferenceCleanup(DRYTest): """Test cleanup without cyclic references"""