├── .github └── workflows │ └── pytest.yml ├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── make_release.sh ├── pure_eval ├── __init__.py ├── core.py ├── my_getattr_static.py ├── py.typed └── utils.py ├── pyproject.toml ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── test_core.py ├── test_getattr_static.py └── test_utils.py └── tox.ini /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: [3.7, 3.8, 3.9, '3.10', 3.11, 3.12, 3.13-dev] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | pip install --upgrade pip 25 | pip install --upgrade coveralls .[tests] 26 | - name: Test 27 | env: 28 | PURE_EVAL_SLOW_TESTS: 1 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | COVERALLS_FLAG_NAME: "test-${{ matrix.python-version }}-${{ matrix.os }}" 31 | COVERALLS_PARALLEL: true 32 | run: | 33 | coverage run --source pure_eval -m pytest 34 | coverage report -m 35 | coveralls --service=github 36 | 37 | coveralls: 38 | name: Coveralls Finished 39 | needs: test 40 | runs-on: ubuntu-latest 41 | container: python:3-slim 42 | steps: 43 | - name: Finished 44 | run: | 45 | pip3 install --upgrade coveralls 46 | coveralls --service=github --finish 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pure_eval/version.py 2 | 3 | # Created by .ignore support plugin (hsz.mobi) 4 | ### Python template 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # celery beat schedule file 98 | celerybeat-schedule 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | 130 | # vscode settings 131 | .vscode/ 132 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alex Hall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include pure_eval/py.typed 3 | include README.md 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `pure_eval` 2 | 3 | [![Build Status](https://travis-ci.org/alexmojaki/pure_eval.svg?branch=master)](https://travis-ci.org/alexmojaki/pure_eval) [![Coverage Status](https://coveralls.io/repos/github/alexmojaki/pure_eval/badge.svg?branch=master)](https://coveralls.io/github/alexmojaki/pure_eval?branch=master) [![Supports Python versions 3.7+](https://img.shields.io/pypi/pyversions/pure_eval.svg)](https://pypi.python.org/pypi/pure_eval) 4 | 5 | This is a Python package that lets you safely evaluate certain AST nodes without triggering arbitrary code that may have unwanted side effects. 6 | 7 | It can be installed from PyPI: 8 | 9 | pip install pure_eval 10 | 11 | To demonstrate usage, suppose we have an object defined as follows: 12 | 13 | ```python 14 | class Rectangle: 15 | def __init__(self, width, height): 16 | self.width = width 17 | self.height = height 18 | 19 | @property 20 | def area(self): 21 | print("Calculating area...") 22 | return self.width * self.height 23 | 24 | 25 | rect = Rectangle(3, 5) 26 | ``` 27 | 28 | Given the `rect` object, we want to evaluate whatever expressions we can in this source code: 29 | 30 | ```python 31 | source = "(rect.width, rect.height, rect.area)" 32 | ``` 33 | 34 | This library works with the AST, so let's parse the source code and peek inside: 35 | 36 | ```python 37 | import ast 38 | 39 | tree = ast.parse(source) 40 | the_tuple = tree.body[0].value 41 | for node in the_tuple.elts: 42 | print(ast.dump(node)) 43 | ``` 44 | 45 | Output: 46 | 47 | ```python 48 | Attribute(value=Name(id='rect', ctx=Load()), attr='width', ctx=Load()) 49 | Attribute(value=Name(id='rect', ctx=Load()), attr='height', ctx=Load()) 50 | Attribute(value=Name(id='rect', ctx=Load()), attr='area', ctx=Load()) 51 | ``` 52 | 53 | Now to actually use the library. First construct an Evaluator: 54 | 55 | ```python 56 | from pure_eval import Evaluator 57 | 58 | evaluator = Evaluator({"rect": rect}) 59 | ``` 60 | 61 | The argument to `Evaluator` should be a mapping from variable names to their values. Or if you have access to the stack frame where `rect` is defined, you can instead use: 62 | 63 | ```python 64 | evaluator = Evaluator.from_frame(frame) 65 | ``` 66 | 67 | Now to evaluate some nodes, using `evaluator[node]`: 68 | 69 | ```python 70 | print("rect.width:", evaluator[the_tuple.elts[0]]) 71 | print("rect:", evaluator[the_tuple.elts[0].value]) 72 | ``` 73 | 74 | Output: 75 | 76 | ``` 77 | rect.width: 3 78 | rect: <__main__.Rectangle object at 0x105b0dd30> 79 | ``` 80 | 81 | OK, but you could have done the same thing with `eval`. The useful part is that it will refuse to evaluate the property `rect.area` because that would trigger unknown code. If we try, it'll raise a `CannotEval` exception. 82 | 83 | ```python 84 | from pure_eval import CannotEval 85 | 86 | try: 87 | print("rect.area:", evaluator[the_tuple.elts[2]]) # fails 88 | except CannotEval as e: 89 | print(e) # prints CannotEval 90 | ``` 91 | 92 | To find all the expressions that can be evaluated in a tree: 93 | 94 | ```python 95 | for node, value in evaluator.find_expressions(tree): 96 | print(ast.dump(node), value) 97 | ``` 98 | 99 | Output: 100 | 101 | ```python 102 | Attribute(value=Name(id='rect', ctx=Load()), attr='width', ctx=Load()) 3 103 | Attribute(value=Name(id='rect', ctx=Load()), attr='height', ctx=Load()) 5 104 | Name(id='rect', ctx=Load()) <__main__.Rectangle object at 0x105568d30> 105 | Name(id='rect', ctx=Load()) <__main__.Rectangle object at 0x105568d30> 106 | Name(id='rect', ctx=Load()) <__main__.Rectangle object at 0x105568d30> 107 | ``` 108 | 109 | Note that this includes `rect` three times, once for each appearance in the source code. Since all these nodes are equivalent, we can group them together: 110 | 111 | ```python 112 | from pure_eval import group_expressions 113 | 114 | for nodes, values in group_expressions(evaluator.find_expressions(tree)): 115 | print(len(nodes), "nodes with value:", values) 116 | ``` 117 | 118 | Output: 119 | 120 | ``` 121 | 1 nodes with value: 3 122 | 1 nodes with value: 5 123 | 3 nodes with value: <__main__.Rectangle object at 0x10d374d30> 124 | ``` 125 | 126 | If we want to list all the expressions in a tree, we may want to filter out certain expressions whose values are obvious. For example, suppose we have a function `foo`: 127 | 128 | ```python 129 | def foo(): 130 | pass 131 | ``` 132 | 133 | If we refer to `foo` by its name as usual, then that's not interesting: 134 | 135 | ```python 136 | from pure_eval import is_expression_interesting 137 | 138 | node = ast.parse('foo').body[0].value 139 | print(ast.dump(node)) 140 | print(is_expression_interesting(node, foo)) 141 | ``` 142 | 143 | Output: 144 | 145 | ```python 146 | Name(id='foo', ctx=Load()) 147 | False 148 | ``` 149 | 150 | But if we refer to it by a different name, then it's interesting: 151 | 152 | ```python 153 | node = ast.parse('bar').body[0].value 154 | print(ast.dump(node)) 155 | print(is_expression_interesting(node, foo)) 156 | ``` 157 | 158 | Output: 159 | 160 | ```python 161 | Name(id='bar', ctx=Load()) 162 | True 163 | ``` 164 | 165 | In general `is_expression_interesting` returns False for the following values: 166 | - Literals (e.g. `123`, `'abc'`, `[1, 2, 3]`, `{'a': (), 'b': ([1, 2], [3])}`) 167 | - Variables or attributes whose name is equal to the value's `__name__`, such as `foo` above or `self.foo` if it was a method. 168 | - Builtins (e.g. `len`) referred to by their usual name. 169 | 170 | To make things easier, you can combine finding expressions, grouping them, and filtering out the obvious ones with: 171 | 172 | ```python 173 | evaluator.interesting_expressions_grouped(root) 174 | ``` 175 | 176 | To get the source code of an AST node, I recommend [asttokens](https://github.com/gristlabs/asttokens). 177 | 178 | Here's a complete example that brings it all together: 179 | 180 | ```python 181 | from asttokens import ASTTokens 182 | from pure_eval import Evaluator 183 | 184 | source = """ 185 | x = 1 186 | d = {x: 2} 187 | y = d[x] 188 | """ 189 | 190 | names = {} 191 | exec(source, names) 192 | atok = ASTTokens(source, parse=True) 193 | for nodes, value in Evaluator(names).interesting_expressions_grouped(atok.tree): 194 | print(atok.get_text(nodes[0]), "=", value) 195 | ``` 196 | 197 | Output: 198 | 199 | ```python 200 | x = 1 201 | d = {1: 2} 202 | y = 2 203 | d[x] = 2 204 | ``` 205 | -------------------------------------------------------------------------------- /make_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux -o pipefail 4 | 5 | if [ ! -z "$(git status --porcelain)" ]; then 6 | set +x 7 | echo You have uncommitted changes which would mess up the git tag 8 | exit 1 9 | fi 10 | 11 | if [ -z "${1+x}" ]; then 12 | set +x 13 | echo Provide a version argument 14 | echo "${0} .." 15 | exit 1 16 | fi 17 | 18 | if [[ ! ${1} =~ ^([0-9]+)(\.[0-9]+)?(\.[0-9]+)?$ ]]; then 19 | echo "Not a valid release tag." 20 | exit 1 21 | fi 22 | 23 | export TAG="v${1}" 24 | git tag -f "${TAG}" 25 | git push origin HEAD "${TAG}" 26 | rm -rf ./build ./dist 27 | python -m build --sdist --wheel . 28 | twine upload ./dist/*.whl dist/*.tar.gz 29 | -------------------------------------------------------------------------------- /pure_eval/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Evaluator, CannotEval, group_expressions, is_expression_interesting 2 | from .my_getattr_static import getattr_static 3 | 4 | try: 5 | from .version import __version__ 6 | except ImportError: 7 | # version.py is auto-generated with the git tag when building 8 | __version__ = "???" 9 | 10 | __all__ = [ 11 | "Evaluator", 12 | "CannotEval", 13 | "group_expressions", 14 | "is_expression_interesting", 15 | "getattr_static", 16 | "__version__", 17 | ] 18 | -------------------------------------------------------------------------------- /pure_eval/core.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import builtins 3 | import operator 4 | from collections import ChainMap, OrderedDict, deque 5 | from contextlib import suppress 6 | from types import FrameType 7 | from typing import Any, Tuple, Iterable, List, Mapping, Dict, Union, Set 8 | 9 | from pure_eval.my_getattr_static import getattr_static 10 | from pure_eval.utils import ( 11 | CannotEval, 12 | has_ast_name, 13 | copy_ast_without_context, 14 | is_standard_types, 15 | of_standard_types, 16 | is_any, 17 | of_type, 18 | ensure_dict, 19 | ) 20 | 21 | 22 | class Evaluator: 23 | def __init__(self, names: Mapping[str, Any]): 24 | """ 25 | Construct a new evaluator with the given variable names. 26 | This is a low level API, typically you will use `Evaluator.from_frame(frame)`. 27 | 28 | :param names: a mapping from variable names to their values. 29 | """ 30 | 31 | self.names = names 32 | self._cache = {} # type: Dict[ast.expr, Any] 33 | 34 | @classmethod 35 | def from_frame(cls, frame: FrameType) -> 'Evaluator': 36 | """ 37 | Construct an Evaluator that can look up variables from the given frame. 38 | 39 | :param frame: a frame object, e.g. from a traceback or `inspect.currentframe().f_back`. 40 | """ 41 | 42 | return cls(ChainMap( 43 | ensure_dict(frame.f_locals), 44 | ensure_dict(frame.f_globals), 45 | ensure_dict(frame.f_builtins), 46 | )) 47 | 48 | def __getitem__(self, node: ast.expr) -> Any: 49 | """ 50 | Find the value of the given node. 51 | If it cannot be evaluated safely, this raises `CannotEval`. 52 | The result is cached either way. 53 | 54 | :param node: an AST expression to evaluate 55 | :return: the value of the node 56 | """ 57 | 58 | if not isinstance(node, ast.expr): 59 | raise TypeError("node should be an ast.expr, not {!r}".format(type(node).__name__)) 60 | 61 | with suppress(KeyError): 62 | result = self._cache[node] 63 | if result is CannotEval: 64 | raise CannotEval 65 | else: 66 | return result 67 | 68 | try: 69 | self._cache[node] = result = self._handle(node) 70 | return result 71 | except CannotEval: 72 | self._cache[node] = CannotEval 73 | raise 74 | 75 | def _handle(self, node: ast.expr) -> Any: 76 | """ 77 | This is where the evaluation happens. 78 | Users should use `__getitem__`, i.e. `evaluator[node]`, 79 | as it provides caching. 80 | 81 | :param node: an AST expression to evaluate 82 | :return: the value of the node 83 | """ 84 | 85 | with suppress(Exception): 86 | return ast.literal_eval(node) 87 | 88 | if isinstance(node, ast.Name): 89 | try: 90 | return self.names[node.id] 91 | except KeyError: 92 | raise CannotEval 93 | elif isinstance(node, ast.Attribute): 94 | value = self[node.value] 95 | attr = node.attr 96 | return getattr_static(value, attr) 97 | elif isinstance(node, ast.Subscript): 98 | return self._handle_subscript(node) 99 | elif isinstance(node, (ast.List, ast.Tuple, ast.Set, ast.Dict)): 100 | return self._handle_container(node) 101 | elif isinstance(node, ast.UnaryOp): 102 | return self._handle_unary(node) 103 | elif isinstance(node, ast.BinOp): 104 | return self._handle_binop(node) 105 | elif isinstance(node, ast.BoolOp): 106 | return self._handle_boolop(node) 107 | elif isinstance(node, ast.Compare): 108 | return self._handle_compare(node) 109 | elif isinstance(node, ast.Call): 110 | return self._handle_call(node) 111 | raise CannotEval 112 | 113 | def _handle_call(self, node): 114 | if node.keywords: 115 | raise CannotEval 116 | func = self[node.func] 117 | args = [self[arg] for arg in node.args] 118 | 119 | if ( 120 | is_any( 121 | func, 122 | slice, 123 | int, 124 | range, 125 | round, 126 | complex, 127 | list, 128 | tuple, 129 | abs, 130 | hex, 131 | bin, 132 | oct, 133 | bool, 134 | ord, 135 | float, 136 | len, 137 | chr, 138 | ) 139 | or len(args) == 0 140 | and is_any(func, set, dict, str, frozenset, bytes, bytearray, object) 141 | or len(args) >= 2 142 | and is_any(func, str, divmod, bytes, bytearray, pow) 143 | ): 144 | args = [ 145 | of_standard_types(arg, check_dict_values=False, deep=False) 146 | for arg in args 147 | ] 148 | try: 149 | return func(*args) 150 | except Exception as e: 151 | raise CannotEval from e 152 | 153 | if len(args) == 1: 154 | arg = args[0] 155 | if is_any(func, id, type): 156 | try: 157 | return func(arg) 158 | except Exception as e: 159 | raise CannotEval from e 160 | if is_any(func, all, any, sum): 161 | of_type(arg, tuple, frozenset, list, set, dict, OrderedDict, deque) 162 | for x in arg: 163 | of_standard_types(x, check_dict_values=False, deep=False) 164 | try: 165 | return func(arg) 166 | except Exception as e: 167 | raise CannotEval from e 168 | 169 | if is_any( 170 | func, sorted, min, max, hash, set, dict, ascii, str, repr, frozenset 171 | ): 172 | of_standard_types(arg, check_dict_values=True, deep=True) 173 | try: 174 | return func(arg) 175 | except Exception as e: 176 | raise CannotEval from e 177 | raise CannotEval 178 | 179 | def _handle_compare(self, node): 180 | left = self[node.left] 181 | result = True 182 | 183 | for op, right in zip(node.ops, node.comparators): 184 | right = self[right] 185 | 186 | op_type = type(op) 187 | op_func = { 188 | ast.Eq: operator.eq, 189 | ast.NotEq: operator.ne, 190 | ast.Lt: operator.lt, 191 | ast.LtE: operator.le, 192 | ast.Gt: operator.gt, 193 | ast.GtE: operator.ge, 194 | ast.Is: operator.is_, 195 | ast.IsNot: operator.is_not, 196 | ast.In: (lambda a, b: a in b), 197 | ast.NotIn: (lambda a, b: a not in b), 198 | }[op_type] 199 | 200 | if op_type not in (ast.Is, ast.IsNot): 201 | of_standard_types(left, check_dict_values=False, deep=True) 202 | of_standard_types(right, check_dict_values=False, deep=True) 203 | 204 | try: 205 | result = op_func(left, right) 206 | except Exception as e: 207 | raise CannotEval from e 208 | if not result: 209 | return result 210 | left = right 211 | 212 | return result 213 | 214 | def _handle_boolop(self, node): 215 | left = of_standard_types( 216 | self[node.values[0]], check_dict_values=False, deep=False 217 | ) 218 | 219 | for right in node.values[1:]: 220 | # We need short circuiting so that the whole operation can be evaluated 221 | # even if the right operand can't 222 | if isinstance(node.op, ast.Or): 223 | left = left or of_standard_types( 224 | self[right], check_dict_values=False, deep=False 225 | ) 226 | else: 227 | assert isinstance(node.op, ast.And) 228 | left = left and of_standard_types( 229 | self[right], check_dict_values=False, deep=False 230 | ) 231 | return left 232 | 233 | def _handle_binop(self, node): 234 | op_type = type(node.op) 235 | op = { 236 | ast.Add: operator.add, 237 | ast.Sub: operator.sub, 238 | ast.Mult: operator.mul, 239 | ast.Div: operator.truediv, 240 | ast.FloorDiv: operator.floordiv, 241 | ast.Mod: operator.mod, 242 | ast.Pow: operator.pow, 243 | ast.LShift: operator.lshift, 244 | ast.RShift: operator.rshift, 245 | ast.BitOr: operator.or_, 246 | ast.BitXor: operator.xor, 247 | ast.BitAnd: operator.and_, 248 | }.get(op_type) 249 | if not op: 250 | raise CannotEval 251 | left = self[node.left] 252 | hash_type = is_any(type(left), set, frozenset, dict, OrderedDict) 253 | left = of_standard_types(left, check_dict_values=False, deep=hash_type) 254 | formatting = type(left) in (str, bytes) and op_type == ast.Mod 255 | 256 | right = of_standard_types( 257 | self[node.right], 258 | check_dict_values=formatting, 259 | deep=formatting or hash_type, 260 | ) 261 | try: 262 | return op(left, right) 263 | except Exception as e: 264 | raise CannotEval from e 265 | 266 | def _handle_unary(self, node: ast.UnaryOp): 267 | value = of_standard_types( 268 | self[node.operand], check_dict_values=False, deep=False 269 | ) 270 | op_type = type(node.op) 271 | op = { 272 | ast.USub: operator.neg, 273 | ast.UAdd: operator.pos, 274 | ast.Not: operator.not_, 275 | ast.Invert: operator.invert, 276 | }[op_type] 277 | try: 278 | return op(value) 279 | except Exception as e: 280 | raise CannotEval from e 281 | 282 | def _handle_subscript(self, node): 283 | value = self[node.value] 284 | of_standard_types( 285 | value, check_dict_values=False, deep=is_any(type(value), dict, OrderedDict) 286 | ) 287 | index = node.slice 288 | if isinstance(index, ast.Slice): 289 | index = slice( 290 | *[ 291 | None if p is None else self[p] 292 | for p in [index.lower, index.upper, index.step] 293 | ] 294 | ) 295 | elif isinstance(index, ast.ExtSlice): 296 | raise CannotEval 297 | else: 298 | if isinstance(index, ast.Index): 299 | index = index.value 300 | index = self[index] 301 | of_standard_types(index, check_dict_values=False, deep=True) 302 | 303 | try: 304 | return value[index] 305 | except Exception: 306 | raise CannotEval 307 | 308 | def _handle_container( 309 | self, 310 | node: Union[ast.List, ast.Tuple, ast.Set, ast.Dict] 311 | ) -> Union[List, Tuple, Set, Dict]: 312 | """Handle container nodes, including List, Set, Tuple and Dict""" 313 | if isinstance(node, ast.Dict): 314 | elts = node.keys 315 | if None in elts: # ** unpacking inside {}, not yet supported 316 | raise CannotEval 317 | else: 318 | elts = node.elts 319 | elts = [self[elt] for elt in elts] 320 | if isinstance(node, ast.List): 321 | return elts 322 | if isinstance(node, ast.Tuple): 323 | return tuple(elts) 324 | 325 | # Set and Dict 326 | if not all( 327 | is_standard_types(elt, check_dict_values=False, deep=True) for elt in elts 328 | ): 329 | raise CannotEval 330 | 331 | if isinstance(node, ast.Set): 332 | try: 333 | return set(elts) 334 | except TypeError: 335 | raise CannotEval 336 | 337 | assert isinstance(node, ast.Dict) 338 | 339 | pairs = [(elt, self[val]) for elt, val in zip(elts, node.values)] 340 | try: 341 | return dict(pairs) 342 | except TypeError: 343 | raise CannotEval 344 | 345 | def find_expressions(self, root: ast.AST) -> Iterable[Tuple[ast.expr, Any]]: 346 | """ 347 | Find all expressions in the given tree that can be safely evaluated. 348 | This is a low level API, typically you will use `interesting_expressions_grouped`. 349 | 350 | :param root: any AST node 351 | :return: generator of pairs (tuples) of expression nodes and their corresponding values. 352 | """ 353 | 354 | for node in ast.walk(root): 355 | if not isinstance(node, ast.expr): 356 | continue 357 | 358 | try: 359 | value = self[node] 360 | except CannotEval: 361 | continue 362 | 363 | yield node, value 364 | 365 | def interesting_expressions_grouped(self, root: ast.AST) -> List[Tuple[List[ast.expr], Any]]: 366 | """ 367 | Find all interesting expressions in the given tree that can be safely evaluated, 368 | grouping equivalent nodes together. 369 | 370 | For more control and details, see: 371 | - Evaluator.find_expressions 372 | - is_expression_interesting 373 | - group_expressions 374 | 375 | :param root: any AST node 376 | :return: A list of pairs (tuples) containing: 377 | - A list of equivalent AST expressions 378 | - The value of the first expression node 379 | (which should be the same for all nodes, unless threads are involved) 380 | """ 381 | 382 | return group_expressions( 383 | pair 384 | for pair in self.find_expressions(root) 385 | if is_expression_interesting(*pair) 386 | ) 387 | 388 | 389 | def is_expression_interesting(node: ast.expr, value: Any) -> bool: 390 | """ 391 | Determines if an expression is potentially interesting, at least in my opinion. 392 | Returns False for the following expressions whose value is generally obvious: 393 | - Literals (e.g. 123, 'abc', [1, 2, 3], {'a': (), 'b': ([1, 2], [3])}) 394 | - Variables or attributes whose name is equal to the value's __name__. 395 | For example, a function `def foo(): ...` is not interesting when referred to 396 | as `foo` as it usually would, but `bar` can be interesting if `bar is foo`. 397 | Similarly the method `self.foo` is not interesting. 398 | - Builtins (e.g. `len`) referred to by their usual name. 399 | 400 | This is a low level API, typically you will use `interesting_expressions_grouped`. 401 | 402 | :param node: an AST expression 403 | :param value: the value of the node 404 | :return: a boolean: True if the expression is interesting, False otherwise 405 | """ 406 | 407 | with suppress(ValueError): 408 | ast.literal_eval(node) 409 | return False 410 | 411 | # TODO exclude inner modules, e.g. numpy.random.__name__ == 'numpy.random' != 'random' 412 | # TODO exclude common module abbreviations, e.g. numpy as np, pandas as pd 413 | if has_ast_name(value, node): 414 | return False 415 | 416 | if ( 417 | isinstance(node, ast.Name) 418 | and getattr(builtins, node.id, object()) is value 419 | ): 420 | return False 421 | 422 | return True 423 | 424 | 425 | def group_expressions(expressions: Iterable[Tuple[ast.expr, Any]]) -> List[Tuple[List[ast.expr], Any]]: 426 | """ 427 | Organise expression nodes and their values such that equivalent nodes are together. 428 | Two nodes are considered equivalent if they have the same structure, 429 | ignoring context (Load, Store, or Delete) and location (lineno, col_offset). 430 | For example, this will group together the same variable name mentioned multiple times in an expression. 431 | 432 | This will not check the values of the nodes. Equivalent nodes should have the same values, 433 | unless threads are involved. 434 | 435 | This is a low level API, typically you will use `interesting_expressions_grouped`. 436 | 437 | :param expressions: pairs of AST expressions and their values, as obtained from 438 | `Evaluator.find_expressions`, or `(node, evaluator[node])`. 439 | :return: A list of pairs (tuples) containing: 440 | - A list of equivalent AST expressions 441 | - The value of the first expression node 442 | (which should be the same for all nodes, unless threads are involved) 443 | """ 444 | 445 | result = {} 446 | for node, value in expressions: 447 | dump = ast.dump(copy_ast_without_context(node)) 448 | result.setdefault(dump, ([], value))[0].append(node) 449 | return list(result.values()) 450 | -------------------------------------------------------------------------------- /pure_eval/my_getattr_static.py: -------------------------------------------------------------------------------- 1 | import types 2 | 3 | from pure_eval.utils import of_type, CannotEval 4 | 5 | _sentinel = object() 6 | 7 | 8 | def _static_getmro(klass): 9 | return type.__dict__['__mro__'].__get__(klass) 10 | 11 | 12 | def _check_instance(obj, attr): 13 | instance_dict = {} 14 | try: 15 | instance_dict = object.__getattribute__(obj, "__dict__") 16 | except AttributeError: 17 | pass 18 | return dict.get(instance_dict, attr, _sentinel) 19 | 20 | 21 | def _check_class(klass, attr): 22 | for entry in _static_getmro(klass): 23 | if _shadowed_dict(type(entry)) is _sentinel: 24 | try: 25 | return entry.__dict__[attr] 26 | except KeyError: 27 | pass 28 | else: 29 | break 30 | return _sentinel 31 | 32 | 33 | def _is_type(obj): 34 | try: 35 | _static_getmro(obj) 36 | except TypeError: 37 | return False 38 | return True 39 | 40 | 41 | def _shadowed_dict(klass): 42 | dict_attr = type.__dict__["__dict__"] 43 | for entry in _static_getmro(klass): 44 | try: 45 | class_dict = dict_attr.__get__(entry)["__dict__"] 46 | except KeyError: 47 | pass 48 | else: 49 | if not (type(class_dict) is types.GetSetDescriptorType and 50 | class_dict.__name__ == "__dict__" and 51 | class_dict.__objclass__ is entry): 52 | return class_dict 53 | return _sentinel 54 | 55 | 56 | def getattr_static(obj, attr): 57 | """Retrieve attributes without triggering dynamic lookup via the 58 | descriptor protocol, __getattr__ or __getattribute__. 59 | 60 | Note: this function may not be able to retrieve all attributes 61 | that getattr can fetch (like dynamically created attributes) 62 | and may find attributes that getattr can't (like descriptors 63 | that raise AttributeError). It can also return descriptor objects 64 | instead of instance members in some cases. See the 65 | documentation for details. 66 | """ 67 | instance_result = _sentinel 68 | if not _is_type(obj): 69 | klass = type(obj) 70 | dict_attr = _shadowed_dict(klass) 71 | if (dict_attr is _sentinel or 72 | type(dict_attr) is types.MemberDescriptorType): 73 | instance_result = _check_instance(obj, attr) 74 | else: 75 | raise CannotEval 76 | else: 77 | klass = obj 78 | 79 | klass_result = _check_class(klass, attr) 80 | 81 | if instance_result is not _sentinel and klass_result is not _sentinel: 82 | if _check_class(type(klass_result), "__get__") is not _sentinel and ( 83 | _check_class(type(klass_result), "__set__") is not _sentinel 84 | or _check_class(type(klass_result), "__delete__") is not _sentinel 85 | ): 86 | return _resolve_descriptor(klass_result, obj, klass) 87 | 88 | if instance_result is not _sentinel: 89 | return instance_result 90 | if klass_result is not _sentinel: 91 | get = _check_class(type(klass_result), '__get__') 92 | if get is _sentinel: 93 | return klass_result 94 | else: 95 | if obj is klass: 96 | instance = None 97 | else: 98 | instance = obj 99 | return _resolve_descriptor(klass_result, instance, klass) 100 | 101 | if obj is klass: 102 | # for types we check the metaclass too 103 | for entry in _static_getmro(type(klass)): 104 | if _shadowed_dict(type(entry)) is _sentinel: 105 | try: 106 | result = entry.__dict__[attr] 107 | get = _check_class(type(result), '__get__') 108 | if get is not _sentinel: 109 | raise CannotEval 110 | return result 111 | except KeyError: 112 | pass 113 | raise CannotEval 114 | 115 | 116 | class _foo: 117 | __slots__ = ['foo'] 118 | method = lambda: 0 119 | 120 | 121 | slot_descriptor = _foo.foo 122 | wrapper_descriptor = str.__dict__['__add__'] 123 | method_descriptor = str.__dict__['startswith'] 124 | user_method_descriptor = _foo.__dict__['method'] 125 | 126 | safe_descriptors_raw = [ 127 | slot_descriptor, 128 | wrapper_descriptor, 129 | method_descriptor, 130 | user_method_descriptor, 131 | ] 132 | 133 | safe_descriptor_types = list(map(type, safe_descriptors_raw)) 134 | 135 | 136 | def _resolve_descriptor(d, instance, owner): 137 | try: 138 | return type(of_type(d, *safe_descriptor_types)).__get__(d, instance, owner) 139 | except AttributeError as e: 140 | raise CannotEval from e 141 | -------------------------------------------------------------------------------- /pure_eval/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. The pure_eval package uses inline types. 2 | -------------------------------------------------------------------------------- /pure_eval/utils.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict, deque 2 | from datetime import date, time, datetime 3 | from decimal import Decimal 4 | from fractions import Fraction 5 | import ast 6 | import enum 7 | import typing 8 | 9 | 10 | class CannotEval(Exception): 11 | def __repr__(self): 12 | return self.__class__.__name__ 13 | 14 | __str__ = __repr__ 15 | 16 | 17 | def is_any(x, *args): 18 | return any( 19 | x is arg 20 | for arg in args 21 | ) 22 | 23 | 24 | def of_type(x, *types): 25 | if is_any(type(x), *types): 26 | return x 27 | else: 28 | raise CannotEval 29 | 30 | 31 | def of_standard_types(x, *, check_dict_values: bool, deep: bool): 32 | if is_standard_types(x, check_dict_values=check_dict_values, deep=deep): 33 | return x 34 | else: 35 | raise CannotEval 36 | 37 | 38 | def is_standard_types(x, *, check_dict_values: bool, deep: bool): 39 | try: 40 | return _is_standard_types_deep(x, check_dict_values, deep)[0] 41 | except RecursionError: 42 | return False 43 | 44 | 45 | def _is_standard_types_deep(x, check_dict_values: bool, deep: bool): 46 | typ = type(x) 47 | if is_any( 48 | typ, 49 | str, 50 | int, 51 | bool, 52 | float, 53 | bytes, 54 | complex, 55 | date, 56 | time, 57 | datetime, 58 | Fraction, 59 | Decimal, 60 | type(None), 61 | object, 62 | ): 63 | return True, 0 64 | 65 | if is_any(typ, tuple, frozenset, list, set, dict, OrderedDict, deque, slice): 66 | if typ in [slice]: 67 | length = 0 68 | else: 69 | length = len(x) 70 | assert isinstance(deep, bool) 71 | if not deep: 72 | return True, length 73 | 74 | if check_dict_values and typ in (dict, OrderedDict): 75 | items = (v for pair in x.items() for v in pair) 76 | elif typ is slice: 77 | items = [x.start, x.stop, x.step] 78 | else: 79 | items = x 80 | for item in items: 81 | if length > 100000: 82 | return False, length 83 | is_standard, item_length = _is_standard_types_deep( 84 | item, check_dict_values, deep 85 | ) 86 | if not is_standard: 87 | return False, length 88 | length += item_length 89 | return True, length 90 | 91 | return False, 0 92 | 93 | 94 | class _E(enum.Enum): 95 | pass 96 | 97 | 98 | class _C: 99 | def foo(self): pass # pragma: nocover 100 | 101 | def bar(self): pass # pragma: nocover 102 | 103 | @classmethod 104 | def cm(cls): pass # pragma: nocover 105 | 106 | @staticmethod 107 | def sm(): pass # pragma: nocover 108 | 109 | 110 | safe_name_samples = { 111 | "len": len, 112 | "append": list.append, 113 | "__add__": list.__add__, 114 | "insert": [].insert, 115 | "__mul__": [].__mul__, 116 | "fromkeys": dict.__dict__['fromkeys'], 117 | "is_any": is_any, 118 | "__repr__": CannotEval.__repr__, 119 | "foo": _C().foo, 120 | "bar": _C.bar, 121 | "cm": _C.cm, 122 | "sm": _C.sm, 123 | "ast": ast, 124 | "CannotEval": CannotEval, 125 | "_E": _E, 126 | } 127 | 128 | typing_annotation_samples = { 129 | name: getattr(typing, name) 130 | for name in "List Dict Tuple Set Callable Mapping".split() 131 | } 132 | 133 | safe_name_types = tuple({ 134 | type(f) 135 | for f in safe_name_samples.values() 136 | }) 137 | 138 | 139 | typing_annotation_types = tuple({ 140 | type(f) 141 | for f in typing_annotation_samples.values() 142 | }) 143 | 144 | 145 | def eq_checking_types(a, b): 146 | return type(a) is type(b) and a == b 147 | 148 | 149 | def ast_name(node): 150 | if isinstance(node, ast.Name): 151 | return node.id 152 | elif isinstance(node, ast.Attribute): 153 | return node.attr 154 | else: 155 | return None 156 | 157 | 158 | def safe_name(value): 159 | typ = type(value) 160 | if is_any(typ, *safe_name_types): 161 | return value.__name__ 162 | elif value is typing.Optional: 163 | return "Optional" 164 | elif value is typing.Union: 165 | return "Union" 166 | elif is_any(typ, *typing_annotation_types): 167 | return getattr(value, "__name__", None) or getattr(value, "_name", None) 168 | else: 169 | return None 170 | 171 | 172 | def has_ast_name(value, node): 173 | value_name = safe_name(value) 174 | if type(value_name) is not str: 175 | return False 176 | return eq_checking_types(ast_name(node), value_name) 177 | 178 | 179 | def copy_ast_without_context(x): 180 | if isinstance(x, ast.AST): 181 | kwargs = { 182 | field: copy_ast_without_context(getattr(x, field)) 183 | for field in x._fields 184 | if field != 'ctx' 185 | if hasattr(x, field) 186 | } 187 | a = type(x)(**kwargs) 188 | if hasattr(a, 'ctx'): 189 | # Python 3.13.0b2+ defaults to Load when we don't pass ctx 190 | # https://github.com/python/cpython/pull/118871 191 | del a.ctx 192 | return a 193 | elif isinstance(x, list): 194 | return list(map(copy_ast_without_context, x)) 195 | else: 196 | return x 197 | 198 | 199 | def ensure_dict(x): 200 | """ 201 | Handles invalid non-dict inputs 202 | """ 203 | try: 204 | return dict(x) 205 | except Exception: 206 | return {} 207 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=41", "wheel", "setuptools_scm[toml]>=3.4.3"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | write_to = "pure_eval/version.py" 7 | write_to_template = "__version__ = '{version}'" 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pure_eval 3 | url = http://github.com/alexmojaki/pure_eval 4 | author = Alex Hall 5 | author_email = alex.mojaki@gmail.com 6 | license = MIT 7 | description = Safely evaluate AST nodes without side effects 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | classifiers = 11 | Intended Audience :: Developers 12 | Programming Language :: Python :: 3.7 13 | Programming Language :: Python :: 3.8 14 | Programming Language :: Python :: 3.9 15 | Programming Language :: Python :: 3.10 16 | Programming Language :: Python :: 3.11 17 | Programming Language :: Python :: 3.12 18 | Programming Language :: Python :: 3.13 19 | License :: OSI Approved :: MIT License 20 | Operating System :: OS Independent 21 | 22 | [options] 23 | packages = pure_eval 24 | install_requires = 25 | include_package_data = True 26 | tests_require = pytest 27 | setup_requires = setuptools>=44; setuptools_scm[toml]>=3.4.3 28 | 29 | [options.extras_require] 30 | tests = pytest 31 | 32 | [options.package_data] 33 | pure_eval = py.typed 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexmojaki/pure_eval/b02b4046e809c04c920fad4ddc8e3f838c59af16/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import inspect 3 | import sys 4 | import typing 5 | 6 | import itertools 7 | import pytest 8 | 9 | from pure_eval import Evaluator, CannotEval 10 | from pure_eval.core import is_expression_interesting, group_expressions 11 | 12 | 13 | def check_eval(source, *expected_values, total=True): 14 | frame = inspect.currentframe().f_back 15 | evaluator = Evaluator.from_frame(frame) 16 | root = ast.parse(source) 17 | values = [] 18 | for node, value in evaluator.find_expressions(root): 19 | expr = ast.Expression(body=node) 20 | ast.copy_location(expr, node) 21 | code = compile(expr, "", "eval") 22 | expected = eval(code, frame.f_globals, frame.f_locals) 23 | assert value == expected 24 | values.append(value) 25 | if total: 26 | assert value in expected_values 27 | 28 | for expected in expected_values: 29 | assert expected in values 30 | 31 | 32 | def test_eval_names(): 33 | x = 3 34 | check_eval( 35 | "(x, check_eval, len), nonexistent", 36 | x, check_eval, len, (x, check_eval, len) 37 | ) 38 | 39 | 40 | def test_eval_literals(): 41 | check_eval( 42 | "(1, 'a', [{}])", 43 | (1, 'a', [{}]), 44 | 1, 'a', [{}], 45 | {}, 46 | ) 47 | 48 | 49 | def test_eval_attrs(): 50 | class Foo: 51 | bar = 9 52 | 53 | @property 54 | def prop(self): 55 | return 0 56 | 57 | def method(self): 58 | pass 59 | 60 | foo = Foo() 61 | foo.spam = 44 62 | 63 | check_eval( 64 | "foo.bar + foo.spam + Foo.bar", 65 | foo.bar, foo.spam, Foo.bar, 66 | foo.bar + foo.spam, 67 | foo.bar + foo.spam + Foo.bar, 68 | foo, Foo 69 | ) 70 | 71 | check_eval( 72 | "Foo.spam + Foo.prop + foo.prop + foo.method() + Foo.method", 73 | foo, Foo, Foo.method, foo.method 74 | ) 75 | 76 | check_eval("typing.List", typing, typing.List) 77 | 78 | 79 | def test_eval_dict(): 80 | d = {1: 2} 81 | 82 | # All is well, d[1] is evaluated 83 | check_eval( 84 | "d[1]", 85 | d[1], d, 1 86 | ) 87 | 88 | class BadHash: 89 | def __hash__(self): 90 | return 0 91 | 92 | d[BadHash()] = 3 93 | 94 | # d[1] is not evaluated because d contains a bad key 95 | check_eval( 96 | "d[1]", 97 | d, 1 98 | ) 99 | 100 | d = {1: 2} 101 | b = BadHash() 102 | 103 | # d[b] is not evaluated because b is a bad key 104 | check_eval( 105 | "d[b]", 106 | d, b 107 | ) 108 | 109 | def make_d(): 110 | return {1: 2} 111 | 112 | str(make_d()) 113 | 114 | # Cannot eval make_d()[1] because the left part cannot eval 115 | check_eval( 116 | "make_d()[1]", 117 | make_d, 1 118 | ) 119 | 120 | # Cannot eval d[:1] because slices aren't hashable 121 | check_eval( 122 | "d[:1]", 123 | d, 1 124 | ) 125 | 126 | d = {(1, 3): 2} 127 | b = BadHash() 128 | 129 | # d[(1, b)] is not evaluated because b is a bad key 130 | check_eval( 131 | "d[(1, b)], d[(1, 3)]", 132 | # (1, b) is a bad key, but it's a valid tuple element 133 | d, b, d[(1, 3)], (1, 3), 1, 3, (1, b) 134 | ) 135 | 136 | e = 3 137 | check_eval( 138 | "{(1, e): 2}, {(1, b): 1}", # b is a bad key 139 | b, 1, (1, e), 2, e, {(1, e): 2}, (1, b) 140 | ) 141 | 142 | check_eval("{{}: {}}", {}) 143 | 144 | 145 | def test_eval_set(): 146 | a = 1 147 | b = {2, 3} # unhashable itself 148 | check_eval( 149 | "{a}, b, {a, b, 4}, {b}", # d is a bad key 150 | a, {a}, b, 4 151 | ) 152 | 153 | 154 | def test_eval_sequence_subscript(): 155 | lst = [12, 34, 56] 156 | i = 1 157 | check_eval( 158 | "lst[i] + lst[:i][0] + lst[i:][i] + lst[::2][False]", 159 | lst[i], lst[:i][0], lst[i:][i], lst[::2], 160 | lst[i] + lst[:i][0], 161 | lst[i] + lst[:i][0] + lst[i:][i], 162 | lst[i] + lst[:i][0] + lst[i:][i] + lst[::2][False], 163 | lst, i, lst[:i], 0, lst[i:], 2, 164 | ) 165 | 166 | check_eval( 167 | "('abc', 'def')[1][2]", 168 | ('abc', 'def')[1][2], 169 | total=False 170 | ) 171 | 172 | check_eval( 173 | "[lst][0][2]", 174 | lst, [lst], [lst][0], [lst][0][2], 2, 0 175 | ) 176 | 177 | check_eval( 178 | "(lst, )[0][2]", 179 | lst, (lst, ), (lst, )[0], (lst, )[0][2], 2, 0 180 | ) 181 | 182 | 183 | def test_eval_unary_op(): 184 | a = 123 185 | check_eval( 186 | "a, -a, +a, ~a", 187 | a, -a, +a, ~a, 188 | (a, -a, +a, ~a), 189 | ) 190 | check_eval( 191 | "not a", 192 | a, not a, 193 | ) 194 | b = "" 195 | check_eval( 196 | "not b, -b", 197 | b, not b, 198 | ) 199 | 200 | 201 | def test_eval_binary_op(): 202 | a = 123 203 | b = 456 204 | check_eval( 205 | "a + b - a * b - (a ** b) // (b % a)", 206 | a + b - a * b - (a ** b) // (b % a), 207 | a + b, a * b, (a ** b), (b % a), 208 | a + b - a * b, (a ** b) // (b % a), 209 | a, b, 210 | ) 211 | check_eval( 212 | "a / b", 213 | a / b, a, b, 214 | ) 215 | check_eval( 216 | "a & b", 217 | a & b, a, b, 218 | ) 219 | check_eval( 220 | "a | b", 221 | a | b, a, b, 222 | ) 223 | check_eval( 224 | "a ^ b", 225 | a ^ b, a, b, 226 | ) 227 | check_eval( 228 | "a << 2", 229 | a << 2, a, 2 230 | ) 231 | check_eval( 232 | "a >> 2", 233 | a >> 2, a, 2 234 | ) 235 | check_eval( 236 | "'a %s c' % b", 237 | 'a %s c' % b, 238 | 'a %s c', b, 239 | ) 240 | check_eval( 241 | "'a %s c' % check_eval, a @ b, a + []", 242 | 'a %s c', check_eval, a, b, [], 243 | ) 244 | 245 | 246 | def check_interesting(source): 247 | frame = inspect.currentframe().f_back 248 | evaluator = Evaluator.from_frame(frame) 249 | root = ast.parse(source) 250 | node = root.body[0].value 251 | cannot = value = None 252 | try: 253 | value = evaluator[node] 254 | except CannotEval as e: 255 | cannot = e 256 | 257 | expr = ast.Expression(body=node) 258 | ast.copy_location(expr, node) 259 | code = compile(expr, "", "eval") 260 | try: 261 | expected = eval(code, frame.f_globals, frame.f_locals) 262 | except Exception: 263 | if cannot: 264 | return None 265 | else: 266 | raise 267 | else: 268 | if cannot: 269 | raise cannot 270 | else: 271 | assert value == expected 272 | 273 | return is_expression_interesting(node, value) 274 | 275 | 276 | def test_is_expression_interesting(): 277 | class Foo: 278 | def method(self): 279 | pass 280 | 281 | alias = method 282 | 283 | foo = Foo() 284 | x = [check_interesting] 285 | foo.x = x 286 | assert check_interesting('x') 287 | assert not check_interesting('help') 288 | assert not check_interesting('check_interesting') 289 | assert not check_interesting('[1]') 290 | assert check_interesting('[1, 2][0]') 291 | assert check_interesting('foo') 292 | assert not check_interesting('Foo') 293 | assert check_interesting('foo.x') 294 | assert not check_interesting('foo.method') 295 | assert check_interesting('foo.alias') 296 | assert not check_interesting('Foo.method') 297 | assert check_interesting('Foo.alias') 298 | assert check_interesting('x[0]') 299 | assert not check_interesting('typing.List') 300 | assert check_interesting('[typing.List][0]') 301 | 302 | 303 | def test_boolop(): 304 | for a, b, c in [ 305 | [0, 123, 456], 306 | [0, [0], [[0]]], 307 | [set(), {1}, {1, (1,)}], 308 | ]: 309 | str((a, b, c)) 310 | for length in [2, 3, 4]: 311 | for vals in itertools.product(["1/0", "a", "b", "c"], repeat=length): 312 | for op in [ 313 | "not in", 314 | "is not", 315 | *"+ - / // * & ^ % @ | >> or and < <= > >= == != in is".split(), 316 | ]: 317 | op = " %s " % op 318 | source = op.join(vals) 319 | check_interesting(source) 320 | 321 | 322 | def test_is(): 323 | for a, b, c in [ 324 | [check_interesting, CannotEval(), CannotEval], 325 | ]: 326 | str((a, b, c)) 327 | for length in [2, 3, 4]: 328 | for vals in itertools.product(["1/0", "a", "b", "c"], repeat=length): 329 | for op in ["is", "is not"]: 330 | op = " %s " % op 331 | source = op.join(vals) 332 | check_interesting(source) 333 | 334 | 335 | def test_calls(): 336 | # No keywords allowed 337 | with pytest.raises(CannotEval): 338 | check_interesting("str(b'', encoding='utf8')") 339 | 340 | # This function not allowed 341 | with pytest.raises(CannotEval): 342 | check_interesting("print(3)") 343 | 344 | assert check_interesting("slice(3)") 345 | assert check_interesting("slice(3, 5)") 346 | assert check_interesting("slice(3, 5, 1)") 347 | assert check_interesting("int()") 348 | assert check_interesting("int('5')") 349 | assert check_interesting("int('55', 12)") 350 | assert check_interesting("range(3)") 351 | assert check_interesting("range(3, 5)") 352 | assert check_interesting("range(3, 5, 1)") 353 | assert check_interesting("round(3.14159)") 354 | assert check_interesting("round(3.14159, 2)") 355 | assert check_interesting("complex()") 356 | assert check_interesting("complex(5, 2)") 357 | assert check_interesting("list()") 358 | assert check_interesting("tuple()") 359 | assert check_interesting("dict()") 360 | assert check_interesting("bytes()") 361 | assert check_interesting("frozenset()") 362 | assert check_interesting("bytearray()") 363 | assert check_interesting("abs(3)") 364 | assert check_interesting("hex(3)") 365 | assert check_interesting("bin(3)") 366 | assert check_interesting("oct(3)") 367 | assert check_interesting("bool(3)") 368 | assert check_interesting("chr(3)") 369 | assert check_interesting("ord('3')") 370 | assert check_interesting("len([CannotEval, len])") 371 | assert check_interesting("list([CannotEval, len])") 372 | assert check_interesting("tuple([CannotEval, len])") 373 | assert check_interesting("str(b'123', 'utf8')") 374 | assert check_interesting("bytes('123', 'utf8')") 375 | assert check_interesting("bytearray('123', 'utf8')") 376 | assert check_interesting("divmod(123, 4)") 377 | assert check_interesting("pow(123, 4)") 378 | assert check_interesting("id(id)") 379 | assert check_interesting("type(id)") 380 | assert check_interesting("all([1, 2])") 381 | assert check_interesting("any([1, 2])") 382 | assert check_interesting("sum([1, 2])") 383 | assert check_interesting("sum([len])") is None 384 | assert check_interesting("sorted([[1, 2], [3, 4]])") 385 | assert check_interesting("min([[1, 2], [3, 4]])") 386 | assert check_interesting("max([[1, 2], [3, 4]])") 387 | assert check_interesting("hash(((1, 2), (3, 4)))") 388 | assert check_interesting("set(((1, 2), (3, 4)))") 389 | assert check_interesting("dict(((1, 2), (3, 4)))") 390 | assert check_interesting("frozenset(((1, 2), (3, 4)))") 391 | assert check_interesting("ascii(((1, 2), (3, 4)))") 392 | assert check_interesting("str(((1, 2), (3, 4)))") 393 | assert check_interesting("repr(((1, 2), (3, 4)))") 394 | 395 | 396 | def test_unsupported(): 397 | with pytest.raises(CannotEval): 398 | check_interesting("[x for x in []]") 399 | 400 | with pytest.raises(CannotEval): 401 | check_interesting("{**{}}") 402 | 403 | with pytest.raises(CannotEval): 404 | check_interesting("[*[]]") 405 | 406 | with pytest.raises(CannotEval): 407 | check_interesting("int(*[1])") 408 | 409 | 410 | def test_group_expressions(): 411 | x = (1, 2) 412 | evaluator = Evaluator({'x': x}) 413 | tree = ast.parse('x[0] + x[x[0]]').body[0].value 414 | expressions = evaluator.find_expressions(tree) 415 | grouped = set( 416 | (frozenset(nodes), value) 417 | for nodes, value in 418 | group_expressions(expressions) 419 | ) 420 | expected = { 421 | (frozenset([tree.left, subscript_item(tree.right)]), 422 | x[0]), 423 | (frozenset([tree.left.value, subscript_item(tree.right).value, tree.right.value]), 424 | x), 425 | (frozenset([subscript_item(tree.left), subscript_item(subscript_item(tree.right))]), 426 | 0), 427 | (frozenset([tree.right]), 428 | x[x[0]]), 429 | (frozenset([tree]), 430 | x[0] + x[x[0]]), 431 | } 432 | assert grouped == expected 433 | 434 | grouped = set( 435 | (frozenset(nodes), value) 436 | for nodes, value in 437 | evaluator.interesting_expressions_grouped(tree) 438 | ) 439 | expected = set( 440 | (nodes, value) 441 | for nodes, value in expected 442 | if value != 0 443 | ) 444 | assert grouped == expected 445 | 446 | 447 | def subscript_item(node): 448 | if sys.version_info < (3, 9): 449 | return node.slice.value 450 | else: 451 | return node.slice 452 | 453 | 454 | def test_evaluator_wrong_getitem(): 455 | evaluator = Evaluator({}) 456 | with pytest.raises(TypeError, match="node should be an ast.expr, not 'str'"): 457 | # noinspection PyTypeChecker 458 | str(evaluator["foo"]) 459 | 460 | 461 | @pytest.mark.parametrize("expr", ["lst[:,:]", "lst[9]"]) 462 | def test_cannot_subscript(expr): 463 | with pytest.raises(Exception): 464 | eval(expr) 465 | 466 | evaluator = Evaluator({'lst': [1]}) 467 | tree = ast.parse(expr) 468 | node = tree.body[0].value 469 | assert isinstance(node, ast.Subscript) 470 | with pytest.raises(CannotEval): 471 | str(evaluator[node]) 472 | -------------------------------------------------------------------------------- /tests/test_getattr_static.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | import types 4 | 5 | import pytest 6 | 7 | from pure_eval import CannotEval 8 | from pure_eval.my_getattr_static import getattr_static, safe_descriptors_raw 9 | 10 | 11 | class TestGetattrStatic(unittest.TestCase): 12 | def assert_getattr(self, thing, attr): 13 | self.assertEqual( 14 | getattr_static(thing, attr), 15 | getattr(thing, attr), 16 | ) 17 | 18 | def assert_cannot_getattr(self, thing, attr): 19 | with self.assertRaises(CannotEval): 20 | getattr_static(thing, attr) 21 | 22 | def test_basic(self): 23 | class Thing(object): 24 | x = object() 25 | 26 | thing = Thing() 27 | self.assert_getattr(thing, 'x') 28 | self.assert_cannot_getattr(thing, 'y') 29 | 30 | def test_inherited(self): 31 | class Thing(object): 32 | x = object() 33 | 34 | class OtherThing(Thing): 35 | pass 36 | 37 | something = OtherThing() 38 | self.assert_getattr(something, 'x') 39 | 40 | def test_instance_attr(self): 41 | class Thing(object): 42 | x = 2 43 | 44 | def __init__(self, x): 45 | self.x = x 46 | 47 | thing = Thing(3) 48 | self.assert_getattr(thing, 'x') 49 | self.assert_getattr(Thing, 'x') 50 | del thing.x 51 | self.assert_getattr(thing, 'x') 52 | 53 | def test_property(self): 54 | class Thing(object): 55 | @property 56 | def x(self): 57 | raise AttributeError("I'm pretending not to exist") 58 | 59 | thing = Thing() 60 | self.assert_cannot_getattr(thing, 'x') 61 | 62 | # TODO this should be doable as Thing.x is the property object 63 | # It would require checking that type(klass_result) is property and then just returning that 64 | self.assert_cannot_getattr(Thing, 'x') 65 | 66 | def test_descriptor_raises_AttributeError(self): 67 | class descriptor(object): 68 | def __get__(*_): 69 | raise AttributeError("I'm pretending not to exist") 70 | 71 | desc = descriptor() 72 | 73 | class Thing(object): 74 | x = desc 75 | 76 | thing = Thing() 77 | self.assert_cannot_getattr(thing, 'x') 78 | self.assert_cannot_getattr(Thing, 'x') 79 | 80 | def test_classAttribute(self): 81 | class Thing(object): 82 | x = object() 83 | 84 | self.assert_getattr(Thing, 'x') 85 | 86 | def test_classVirtualAttribute(self): 87 | class Thing(object): 88 | @types.DynamicClassAttribute 89 | def x(self): 90 | return self._x 91 | 92 | _x = object() 93 | 94 | self.assert_cannot_getattr(Thing(), 'x') 95 | self.assert_cannot_getattr(Thing, 'x') 96 | 97 | def test_inherited_classattribute(self): 98 | class Thing(object): 99 | x = object() 100 | 101 | class OtherThing(Thing): 102 | pass 103 | 104 | self.assert_getattr(OtherThing, 'x') 105 | 106 | def test_slots(self): 107 | class Thing(object): 108 | y = 'bar' 109 | __slots__ = ['x'] 110 | 111 | def __init__(self): 112 | self.x = 'foo' 113 | 114 | thing = Thing() 115 | self.assert_getattr(thing, 'x') 116 | self.assert_getattr(Thing, 'x') 117 | self.assert_getattr(thing, 'y') 118 | self.assert_getattr(Thing, 'y') 119 | 120 | del thing.x 121 | self.assert_cannot_getattr(thing, 'x') 122 | 123 | def test_metaclass(self): 124 | class meta(type): 125 | attr = 'foo' 126 | 127 | class Thing(object, metaclass=meta): 128 | pass 129 | 130 | self.assert_getattr(Thing, 'attr') 131 | 132 | class SubThing(Thing): 133 | pass 134 | 135 | self.assert_getattr(SubThing, 'attr') 136 | 137 | class sub(meta): 138 | pass 139 | 140 | class OtherThing(object, metaclass=sub): 141 | x = 3 142 | 143 | self.assert_getattr(OtherThing, 'attr') 144 | 145 | class OtherOtherThing(OtherThing): 146 | pass 147 | 148 | self.assert_getattr(OtherOtherThing, 'x') 149 | self.assert_getattr(OtherOtherThing, 'attr') 150 | 151 | def test_no_dict_no_slots(self): 152 | self.assert_cannot_getattr(1, 'foo') 153 | self.assert_getattr('foo', 'lower') 154 | 155 | def test_no_dict_no_slots_instance_member(self): 156 | # returns descriptor 157 | with open(__file__) as handle: 158 | self.assert_cannot_getattr(handle, 'name') 159 | 160 | def test_inherited_slots(self): 161 | class Thing(object): 162 | __slots__ = ['x'] 163 | 164 | def __init__(self): 165 | self.x = 'foo' 166 | 167 | class OtherThing(Thing): 168 | pass 169 | 170 | self.assert_getattr(OtherThing(), 'x') 171 | 172 | def test_descriptor(self): 173 | class descriptor(object): 174 | def __get__(self, instance, owner): 175 | return 3 176 | 177 | class Foo(object): 178 | d = descriptor() 179 | 180 | foo = Foo() 181 | 182 | # for a non data descriptor we return the instance attribute 183 | foo.__dict__['d'] = 1 184 | self.assert_getattr(foo, 'd') 185 | 186 | # if the descriptor is a data-descriptor it would be invoked so we can't get it 187 | descriptor.__set__ = lambda s, i, v: None 188 | self.assert_cannot_getattr(foo, 'd') 189 | 190 | del descriptor.__set__ 191 | descriptor.__delete__ = lambda s, i, o: None 192 | self.assert_cannot_getattr(foo, 'd') 193 | 194 | def test_metaclass_with_descriptor(self): 195 | class descriptor(object): 196 | def __get__(self, instance, owner): 197 | return 3 198 | 199 | class meta(type): 200 | d = descriptor() 201 | 202 | class Thing(object, metaclass=meta): 203 | pass 204 | 205 | self.assert_cannot_getattr(Thing, 'd') 206 | 207 | def test_class_as_property(self): 208 | class Base(object): 209 | foo = 3 210 | 211 | class Something(Base): 212 | @property 213 | def __class__(self): 214 | return 1 / 0 215 | 216 | instance = Something() 217 | self.assert_getattr(instance, 'foo') 218 | self.assert_getattr(Something, 'foo') 219 | 220 | def test_mro_as_property(self): 221 | class Meta(type): 222 | @property 223 | def __mro__(self): 224 | return 1 / 0 225 | 226 | class Base(object): 227 | foo = 3 228 | 229 | class Something(Base, metaclass=Meta): 230 | pass 231 | 232 | self.assert_getattr(Something(), 'foo') 233 | self.assert_getattr(Something, 'foo') 234 | 235 | def test_dict_as_property(self): 236 | class Foo(dict): 237 | a = 3 238 | 239 | @property 240 | def __dict__(self): 241 | return 1 / 0 242 | 243 | foo = Foo() 244 | foo.a = 4 245 | self.assert_cannot_getattr(foo, 'a') 246 | self.assert_getattr(Foo, 'a') 247 | 248 | def test_custom_object_dict(self): 249 | class Custom(dict): 250 | def get(self, key, default=None): 251 | return 1 / 0 252 | 253 | __getitem__ = get 254 | 255 | class Foo(object): 256 | a = 3 257 | 258 | foo = Foo() 259 | foo.__dict__ = Custom() 260 | foo.x = 5 261 | self.assert_getattr(foo, 'a') 262 | self.assert_getattr(foo, 'x') 263 | 264 | def test_metaclass_dict_as_property(self): 265 | class Meta(type): 266 | @property 267 | def __dict__(self): 268 | return 1 / 0 269 | 270 | class Thing(metaclass=Meta): 271 | bar = 4 272 | 273 | def __init__(self): 274 | self.spam = 42 275 | 276 | instance = Thing() 277 | self.assert_getattr(instance, "spam") 278 | 279 | # TODO this fails with CannotEval, it doesn't like the __dict__ property, 280 | # but it seems that shouldn't actually matter because it's not called 281 | # self.assert_getattr(Thing, "bar") 282 | 283 | def test_module(self): 284 | self.assert_getattr(sys, "version") 285 | 286 | def test_metaclass_with_metaclass_with_dict_as_property(self): 287 | class MetaMeta(type): 288 | @property 289 | def __dict__(self): 290 | self.executed = True 291 | return dict(spam=42) 292 | 293 | class Meta(type, metaclass=MetaMeta): 294 | executed = False 295 | 296 | class Thing(metaclass=Meta): 297 | pass 298 | 299 | self.assert_cannot_getattr(Thing, "spam") 300 | self.assertFalse(Thing.executed) 301 | 302 | 303 | def test_safe_descriptors_immutable(): 304 | for d in safe_descriptors_raw: 305 | with pytest.raises((TypeError, AttributeError)): 306 | type(d).__get__ = None 307 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import inspect 3 | import io 4 | import os 5 | import re 6 | import sys 7 | import typing 8 | from itertools import islice 9 | 10 | import pytest 11 | 12 | from pure_eval import CannotEval 13 | from pure_eval.utils import ( 14 | copy_ast_without_context, 15 | safe_name_types, 16 | safe_name_samples, 17 | safe_name, 18 | typing_annotation_samples, 19 | is_standard_types, 20 | ensure_dict, 21 | ) 22 | 23 | 24 | def sys_modules_sources(): 25 | for module in sys.modules.values(): 26 | try: 27 | filename = inspect.getsourcefile(module) 28 | except TypeError: 29 | continue 30 | 31 | if not filename: 32 | continue 33 | 34 | filename = os.path.abspath(filename) 35 | try: 36 | with io.open(filename) as f: 37 | source = f.read() 38 | except OSError: 39 | continue 40 | 41 | tree = ast.parse(source) 42 | yield filename, source, tree 43 | 44 | 45 | def test_sys_modules(): 46 | modules = sys_modules_sources() 47 | if not os.environ.get('PURE_EVAL_SLOW_TESTS'): 48 | modules = islice(modules, 0, 3) 49 | 50 | for filename, source, tree in modules: 51 | print(filename) 52 | if not filename.endswith("ast.py"): 53 | check_copy_ast_without_context(tree) 54 | 55 | 56 | def check_copy_ast_without_context(tree): 57 | tree2 = copy_ast_without_context(tree) 58 | dump1 = ast.dump(tree) 59 | dump2 = ast.dump(tree2) 60 | normalised_dump1 = re.sub( 61 | # Two possible matches: 62 | # - first one like ", ctx=…" where ", " should be removed 63 | # - second one like "(ctx=…" where "(" should be kept 64 | ( 65 | r"(" 66 | r", ctx=(Load|Store|Del)\(\)" 67 | r"|" 68 | r"(?<=\()ctx=(Load|Store|Del)\(\)" 69 | r")" 70 | ), 71 | "", 72 | dump1 73 | ) 74 | assert normalised_dump1 == dump2 75 | 76 | 77 | def test_repr_cannot_eval(): 78 | assert repr(CannotEval()) == "CannotEval" 79 | 80 | 81 | def test_safe_name_types(): 82 | for f in safe_name_types: 83 | with pytest.raises(TypeError): 84 | f.__name__ = lambda: 0 85 | 86 | 87 | def test_safe_name_samples(): 88 | for name, f in {**safe_name_samples, **typing_annotation_samples}.items(): 89 | assert name == safe_name(f) 90 | 91 | 92 | def test_safe_name_direct(): 93 | assert safe_name(list) == "list" 94 | assert safe_name(typing.List) == "List" 95 | assert safe_name(typing.Union) == "Union" 96 | assert safe_name(typing.Optional) == "Optional" 97 | assert safe_name(3) is None 98 | 99 | 100 | def test_is_standard_types(): 101 | assert is_standard_types(0, check_dict_values=True, deep=True) 102 | assert is_standard_types("0", check_dict_values=True, deep=True) 103 | assert is_standard_types([0], check_dict_values=True, deep=True) 104 | assert is_standard_types({0}, check_dict_values=True, deep=True) 105 | assert is_standard_types({0: "0"}, check_dict_values=True, deep=True) 106 | assert not is_standard_types(is_standard_types, check_dict_values=True, deep=True) 107 | assert not is_standard_types([is_standard_types], check_dict_values=True, deep=True) 108 | assert is_standard_types([is_standard_types], check_dict_values=True, deep=False) 109 | assert is_standard_types({is_standard_types}, check_dict_values=True, deep=False) 110 | assert is_standard_types( 111 | {is_standard_types: is_standard_types}, check_dict_values=True, deep=False 112 | ) 113 | assert not is_standard_types( 114 | {is_standard_types: is_standard_types}, check_dict_values=True, deep=True 115 | ) 116 | assert not is_standard_types( 117 | {0: is_standard_types}, check_dict_values=True, deep=True 118 | ) 119 | assert is_standard_types({0: is_standard_types}, check_dict_values=False, deep=True) 120 | assert is_standard_types([[[[[[[{(0,)}]]]]]]], deep=True, check_dict_values=True) 121 | assert not is_standard_types( 122 | [[[[[[[{(is_standard_types,)}]]]]]]], deep=True, check_dict_values=True 123 | ) 124 | 125 | lst = [] 126 | lst.append(lst) 127 | assert is_standard_types(lst, deep=False, check_dict_values=True) 128 | assert not is_standard_types(lst, deep=True, check_dict_values=True) 129 | 130 | lst = [0] * 1000000 131 | assert is_standard_types(lst, deep=False, check_dict_values=True) 132 | assert is_standard_types(lst[0], deep=True, check_dict_values=True) 133 | assert not is_standard_types(lst, deep=True, check_dict_values=True) 134 | 135 | lst = [[0] * 1000] * 1000 136 | assert is_standard_types(lst, deep=False, check_dict_values=True) 137 | assert is_standard_types(lst[0], deep=True, check_dict_values=True) 138 | assert not is_standard_types(lst, deep=True, check_dict_values=True) 139 | 140 | 141 | def test_ensure_dict(): 142 | assert ensure_dict({}) == {} 143 | assert ensure_dict([]) == {} 144 | assert ensure_dict('foo') == {} 145 | assert ensure_dict({'a': 1}) == {'a': 1} 146 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{37,38,39,310,311,312,313} 3 | 4 | [testenv] 5 | commands = pytest 6 | extras = tests 7 | passenv = 8 | PURE_EVAL_SLOW_TESTS 9 | --------------------------------------------------------------------------------