├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── LICENSE-header ├── MANIFEST.in ├── README.md ├── import_expression ├── __init__.py ├── __main__.py ├── _parser.py ├── _syntax.py ├── constants.py └── version.py ├── setup.py └── tests.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | import_expression/__main__.py 4 | import_expression/_main2.py 5 | import_expression/version.py 6 | import_expression/constants.py 7 | import_expression/_codec/compat.py 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .py[cod] 2 | __pycache__ 3 | .mypy_cache 4 | 5 | build/ 6 | dist/ 7 | *.egg-info/ 8 | 9 | .pytest_cache 10 | .coverage 11 | /htmlcov/ 12 | 13 | .venv/ 14 | venv/ 15 | 16 | *.swp 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | { 2 | "dist": "bionic", 3 | "language": "python", 4 | "python": [ 5 | 3.6, 6 | 3.7, 7 | 3.8 8 | ], 9 | "script": [ 10 | "pip install coveralls pytest pytest-cov", 11 | "./setup.py test" 12 | ], 13 | "after_script": [ 14 | "if [[ \"$TRAVIS_PYTHON_VERSION\" == 3.6* ]]; then coveralls; fi" 15 | ] 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Portions of import_expression/codec.py are Copyright © 2017 Anthony Sottile, 2 | under the MIT license, which follows. 3 | The original work is Copyright © 2018—2020 io mintz , under the 4 | MIT license, which follows. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the “Software”), 8 | to deal in the Software without restriction, including without limitation the 9 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 10 | sell copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 19 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | 24 | =========================================================================== 25 | 26 | Portions of this software, specifically import_expression/__main__.py, 27 | import_expression/_syntax.py, and import_expression/codec_compat.py 28 | contain software vendored from the CPython standard library. 29 | Its license folllows: 30 | 31 | Copyright © 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 32 | 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019 Python Software Foundation; 33 | All Rights Reserved 34 | 35 | PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 36 | -------------------------------------------- 37 | 38 | 1. This LICENSE AGREEMENT is between the Python Software Foundation 39 | ("PSF"), and the Individual or Organization ("Licensee") accessing and 40 | otherwise using this software ("Python") in source or binary form and 41 | its associated documentation. 42 | 43 | 2. Subject to the terms and conditions of this License Agreement, PSF hereby 44 | grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, 45 | analyze, test, perform and/or display publicly, prepare derivative works, 46 | distribute, and otherwise use Python alone or in any derivative version, 47 | provided, however, that PSF's License Agreement and PSF's notice of copyright, 48 | i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 49 | 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019 Python Software Foundation; 50 | All Rights Reserved" are retained in Python alone or in any derivative version 51 | prepared by Licensee. 52 | 53 | 3. In the event Licensee prepares a derivative work that is based on 54 | or incorporates Python or any part thereof, and wants to make 55 | the derivative work available to others as provided herein, then 56 | Licensee hereby agrees to include in any such work a brief summary of 57 | the changes made to Python. 58 | 59 | 4. PSF is making Python available to Licensee on an "AS IS" 60 | basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 61 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND 62 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 63 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT 64 | INFRINGE ANY THIRD PARTY RIGHTS. 65 | 66 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 67 | FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS 68 | A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, 69 | OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 70 | 71 | 6. This License Agreement will automatically terminate upon a material 72 | breach of its terms and conditions. 73 | 74 | 7. Nothing in this License Agreement shall be deemed to create any 75 | relationship of agency, partnership, or joint venture between PSF and 76 | Licensee. This License Agreement does not grant permission to use PSF 77 | trademarks or trade name in a trademark sense to endorse or promote 78 | products or services of Licensee, or any third party. 79 | 80 | 8. By copying, installing or otherwise using Python, Licensee 81 | agrees to be bound by the terms and conditions of this License 82 | Agreement. 83 | 84 | =========================================================================== 85 | -------------------------------------------------------------------------------- /LICENSE-header: -------------------------------------------------------------------------------- 1 | # Copyright © io mintz 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the “Software”), 5 | # to deal in the Software without restriction, including without limitation the 6 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | # sell copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include import_expression.pth 2 | include tests.py 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Import Expression Parser (for lack of a better name) 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/iomintz/import-expression-parser/badge.svg?branch=main)](https://coveralls.io/github/ioistired/import-expression-parser?branch=main) 4 | 5 | Import Expression Parser converts code like this: 6 | 7 | ```py 8 | urllib.parse!.quote('hello there') 9 | ``` 10 | 11 | Into this equivalent code: 12 | ```py 13 | __import__('importlib').import_module('urllib.parse').quote('hello there') 14 | ``` 15 | 16 | ## Usage 17 | 18 | ```py 19 | >>> import import_expression 20 | >>> import_expression.eval('collections!.Counter("bccdddeeee")') 21 | Counter({'e': 4, 'd': 3, 'c': 2, 'b': 1}) 22 | ``` 23 | 24 | The other public functions are `exec`, `compile`, `parse`, and `find_imports`. 25 | See their docstrings for details. 26 | 27 | By default, the filename for `SyntaxError`s is ``. 28 | To change this, pass in a filename via the `filename` kwarg. 29 | 30 | ### Reusing compiled code objects 31 | 32 | import_expression.eval/exec/compile should not be passed strings in a tight loop. \ 33 | Doing so will recompile the string every time. Instead, you should pre-compile the string to a code object 34 | and pass that to import_expression.eval / import_expression.exec. 35 | For example, instead of this: 36 | 37 | ```py 38 | for line in sys.stdin: 39 | print(import_expression.eval('foo!.bar(l)', dict(l=line)) 40 | ``` 41 | 42 | Prefer this: 43 | 44 | ```py 45 | code = import_expression.compile('foo!.bar(l)', mode='eval') 46 | for line in sys.stdin: 47 | print(import_expression.eval(code, dict(l=line))) 48 | ``` 49 | 50 | ### REPL usage 51 | 52 | Run `import-expression` for an import expression enabled REPL. \ 53 | Run `import-expression -a` for a REPL that supports both import expressions and top level `await` (3.8+). \ 54 | Combine these with `-i` to open a REPL after running the file specified on the command line. `-ia` allows top-level await. 55 | 56 | See `import-expression --help` for more details. 57 | 58 | ### Running a file 59 | 60 | Run `import-expression `. 61 | 62 | ## Limitations / Known Issues 63 | 64 | * Due to the hell that is f-string parsing, and because `!` is already an operator inside f-strings, 65 | import expressions inside f-strings will likely never be supported. 66 | * Due to python limitations, results of `import_expression.exec` will have no effect on the caller's globals or locals 67 | without an explicit `globals` argument. 68 | * Unlike real operators, spaces before and after the import expression operator (such as `x ! .y`) are not supported. 69 | 70 | ## [License](https://github.com/ioistired/import-expression/blob/main/LICENSE) 71 | 72 | Copyright © io mintz <>. All Rights Reserved. \ 73 | Licensed under the MIT License. See the LICENSE file for details. 74 | -------------------------------------------------------------------------------- /import_expression/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © io mintz 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the “Software”), 5 | # to deal in the Software without restriction, including without limitation the 6 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | # sell copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import ast as _ast 22 | import builtins as _builtins 23 | import contextlib as _contextlib 24 | import importlib as _importlib 25 | import inspect as _inspect 26 | import typing as _typing 27 | import types as _types 28 | from codeop import PyCF_DONT_IMPLY_DEDENT 29 | 30 | from . import constants 31 | from ._syntax import fix_syntax as _fix_syntax 32 | from ._parser import transform_ast as _transform_ast 33 | from ._parser import find_imports as _find_imports 34 | from .version import __version__ 35 | 36 | with _contextlib.suppress(NameError): 37 | del version 38 | 39 | __all__ = ('compile', 'parse', 'eval', 'exec', 'constants') 40 | 41 | _source = _typing.Union[_ast.AST, _typing.AnyStr] 42 | 43 | def parse(source: _source, filename=constants.DEFAULT_FILENAME, mode='exec', *, flags=0, **kwargs) -> _ast.AST: 44 | """ 45 | convert Import Expression Python™ to an AST 46 | 47 | Keyword arguments: 48 | mode: determines the type of the returned root node. 49 | As in the mode argument of :func:`ast.parse`, 50 | it must be one of "eval" or "exec". 51 | Eval mode returns an :class:`ast.Expression` object. Source must represent a single expression. 52 | Exec mode returns a :class:` Module` object. Source represents zero or more statements. 53 | 54 | Filename is used in tracebacks, in case of invalid syntax or runtime exceptions. 55 | 56 | The remaining keyword arguments are passed to ast.parse as is. 57 | """ 58 | # for some API compatibility with ast, allow parse(parse('foo')) to work 59 | if isinstance(source, _ast.AST): 60 | return _transform_ast(source, filename=filename) 61 | 62 | fixed = _fix_syntax(source, filename=filename) 63 | if flags & PyCF_DONT_IMPLY_DEDENT: 64 | # just run it for the syntax errors, which codeop picks up on 65 | _builtins.compile(fixed, filename, mode, flags) 66 | tree = _ast.parse(fixed, filename, mode, **kwargs) 67 | return _transform_ast(tree, source=source, filename=filename) 68 | 69 | def compile( 70 | source: _source, 71 | filename=constants.DEFAULT_FILENAME, 72 | mode='exec', 73 | flags=0, 74 | dont_inherit=False, 75 | optimize=-1, 76 | ): 77 | """compile a string or AST containing import expressions to a code object""" 78 | if isinstance(source, (str, bytes)): 79 | source = parse(source, filename=filename, mode=mode, flags=flags) 80 | 81 | return _builtins.compile(source, filename, mode, flags, dont_inherit, optimize) 82 | 83 | _code = _typing.Union[str, _types.CodeType] 84 | 85 | def eval(source: _code, globals=None, locals=None): 86 | """evaluate Import Expression Python™ in the given globals and locals""" 87 | globals, locals = _parse_eval_exec_args(globals, locals) 88 | if _inspect.iscode(source): 89 | return _builtins.eval(source, globals, locals) 90 | return _builtins.eval(compile(source, constants.DEFAULT_FILENAME, 'eval'), globals, locals) 91 | 92 | def exec(source: _code, globals=None, locals=None): 93 | """execute Import Expression Python™ in the given globals and locals 94 | 95 | Note: unlike :func:`exec`, the default globals are *not* the caller's globals! 96 | This is due to a python limitation. 97 | Therefore, if no globals are provided, the results will be discarded! 98 | """ 99 | globals, locals = _parse_eval_exec_args(globals, locals) 100 | if _inspect.iscode(source): 101 | return _builtins.eval(source, globals, locals) 102 | _builtins.eval(compile(source, constants.DEFAULT_FILENAME, 'exec'), globals, locals) 103 | 104 | def find_imports(source: str, filename=constants.DEFAULT_FILENAME, mode='exec'): 105 | """return a list of all module names required by the given source code.""" 106 | # passing an AST is not supported because it doesn't make sense to. 107 | # either the AST is one that we made, in which case the imports have already been made and calling parse_ast again 108 | # would find no imports, or it's an AST made by parsing the output of fix_syntax, which is internal. 109 | fixed = _fix_syntax(source, filename=filename) 110 | tree = _ast.parse(fixed, filename, mode) 111 | return _find_imports(tree, filename=filename) 112 | 113 | def _parse_eval_exec_args(globals, locals): 114 | if globals is None: 115 | globals = {} 116 | 117 | if locals is None: 118 | locals = globals 119 | 120 | return globals, locals 121 | -------------------------------------------------------------------------------- /import_expression/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright © io mintz 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the “Software”), 5 | # to deal in the Software without restriction, including without limitation the 6 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | # sell copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | # This file primarily consists of code vendored from the CPython standard library. 22 | # It is used under the Python Software Foundation License Version 2. 23 | # See LICENSE for details. 24 | 25 | import __future__ 26 | import ast 27 | import asyncio 28 | import atexit 29 | import code 30 | import codeop 31 | import concurrent.futures 32 | import contextlib 33 | import importlib 34 | import inspect 35 | import os.path 36 | import rlcompleter 37 | import sys 38 | import traceback 39 | import threading 40 | import types 41 | import warnings 42 | from asyncio import futures 43 | from codeop import PyCF_DONT_IMPLY_DEDENT, PyCF_ALLOW_INCOMPLETE_INPUT 44 | 45 | import import_expression 46 | from import_expression import constants 47 | 48 | if os.path.basename(sys.argv[0]) == 'import_expression': 49 | import warnings 50 | warnings.warn(UserWarning( 51 | 'The import_expression alias is deprecated, and will be removed in v2.0. ' 52 | 'Please use import-expression (with a hyphen) instead.' 53 | )) 54 | 55 | features = [getattr(__future__, fname) for fname in __future__.all_feature_names] 56 | 57 | try: 58 | from ast import PyCF_ALLOW_TOP_LEVEL_AWAIT 59 | except ImportError: 60 | SUPPORTS_ASYNCIO_REPL = False 61 | else: 62 | SUPPORTS_ASYNCIO_REPL = True 63 | 64 | class ImportExpressionCommandCompiler(codeop.CommandCompiler): 65 | def __init__(self): 66 | super().__init__() 67 | self.compiler = ImportExpressionCompile() 68 | 69 | # this must be vendored as codeop.Compile is hardcoded to use builtins.compile 70 | class ImportExpressionCompile: 71 | """Instances of this class behave much like the built-in compile 72 | function, but if one is used to compile text containing a future 73 | statement, it "remembers" and compiles all subsequent program texts 74 | with the statement in force.""" 75 | def __init__(self): 76 | self.flags = PyCF_DONT_IMPLY_DEDENT | PyCF_ALLOW_INCOMPLETE_INPUT 77 | 78 | def __call__(self, source, filename, symbol, **kwargs): 79 | flags = self.flags 80 | if kwargs.get('incomplete_input', True) is False: 81 | flags &= ~PyCF_DONT_IMPLY_DEDENT 82 | flags &= ~PyCF_ALLOW_INCOMPLETE_INPUT 83 | codeob = import_expression.compile(source, filename, symbol, flags, True) 84 | for feature in features: 85 | if codeob.co_flags & feature.compiler_flag: 86 | self.flags |= feature.compiler_flag 87 | return codeob 88 | 89 | class ImportExpressionInteractiveConsole(code.InteractiveConsole): 90 | def __init__(self, locals=None, filename=''): 91 | super().__init__(locals, filename) 92 | self.compile = ImportExpressionCommandCompiler() 93 | 94 | # we must vendor this class because it creates global variables that the main code depends on 95 | class ImportExpressionAsyncIOInteractiveConsole(ImportExpressionInteractiveConsole): 96 | def __init__(self, locals, loop): 97 | super().__init__(locals) 98 | self.loop = loop 99 | self.locals.update(dict(asyncio=asyncio, loop=loop)) 100 | self.compile.compiler.flags |= PyCF_ALLOW_TOP_LEVEL_AWAIT 101 | 102 | self.loop = loop 103 | 104 | def runcode(self, code): 105 | future = concurrent.futures.Future() 106 | 107 | def callback(): 108 | global repl_future 109 | global repl_future_interrupted 110 | 111 | repl_future = None 112 | repl_future_interrupted = False 113 | 114 | func = types.FunctionType(code, self.locals) 115 | try: 116 | coro = func() 117 | except SystemExit: 118 | raise 119 | except KeyboardInterrupt as ex: 120 | repl_future_interrupted = True 121 | future.set_exception(ex) 122 | return 123 | except BaseException as ex: 124 | future.set_exception(ex) 125 | return 126 | 127 | if not inspect.iscoroutine(coro): 128 | future.set_result(coro) 129 | return 130 | 131 | try: 132 | repl_future = self.loop.create_task(coro) 133 | futures._chain_future(repl_future, future) 134 | except BaseException as exc: 135 | future.set_exception(exc) 136 | 137 | self.loop.call_soon_threadsafe(callback) 138 | 139 | try: 140 | return future.result() 141 | except SystemExit: 142 | raise 143 | except BaseException: 144 | if repl_future_interrupted: 145 | self.write("\nKeyboardInterrupt\n") 146 | else: 147 | self.showtraceback() 148 | 149 | class REPLThread(threading.Thread): 150 | def __init__(self, interact_kwargs): 151 | self.interact_kwargs = interact_kwargs 152 | super().__init__() 153 | 154 | def run(self): 155 | try: 156 | console.interact(**self.interact_kwargs) 157 | finally: 158 | warnings.filterwarnings( 159 | 'ignore', 160 | message=r'^coroutine .* was never awaited$', 161 | category=RuntimeWarning, 162 | ) 163 | 164 | loop.call_soon_threadsafe(loop.stop) 165 | 166 | class ImportExpressionCompleter(rlcompleter.Completer): 167 | def attr_matches(self, text): 168 | # hack to help ensure valid syntax 169 | mod_names = import_expression.find_imports(text.rstrip().rstrip('.')) 170 | if not mod_names: 171 | return super().attr_matches(text) 172 | mod_name = mod_names[0] 173 | mod_name_with_import_op = mod_name + constants.IMPORT_OP 174 | # don't import the module in our current namespace, otherwise tab completion would also have side effects 175 | old_namespace = self.namespace 176 | # __import__ is used instead of importlib.import_module 177 | # because __import__ is designed for updating module-level globals, which we are doing. 178 | # Specifically, __import__('x.y') returns x, which is necessary for tab completion. 179 | mod = __import__(mod_name) 180 | self.namespace = {mod.__name__: mod} 181 | res = [ 182 | # this is a hack because it also replaces non-identifiers 183 | # however, readline / rlcompleter only operates on identifiers so it's OK i guess 184 | # we need to replace so that the tab completions all have the correct prefix 185 | match.replace(mod_name, mod_name_with_import_op, 1) 186 | for match 187 | in super().attr_matches(text.replace(mod_name_with_import_op, mod_name)) 188 | ] 189 | self.namespace = old_namespace 190 | return res 191 | 192 | def asyncio_main(repl_locals, interact_kwargs): 193 | global console 194 | global loop 195 | global repl_future 196 | global repl_future_interrupted 197 | 198 | loop = asyncio.get_event_loop() 199 | 200 | console = ImportExpressionAsyncIOInteractiveConsole(repl_locals, loop) 201 | 202 | repl_future = None 203 | repl_future_interrupted = False 204 | 205 | repl_thread = REPLThread(interact_kwargs) 206 | repl_thread.daemon = True 207 | repl_thread.start() 208 | 209 | while True: 210 | try: 211 | loop.run_forever() 212 | except KeyboardInterrupt: 213 | if repl_future and not repl_future.done(): 214 | repl_future.cancel() 215 | repl_future_interrupted = True 216 | continue 217 | else: 218 | break 219 | 220 | def parse_args(): 221 | import argparse 222 | 223 | version_info = ( 224 | f'Import Expression Parser {import_expression.__version__}\n' 225 | f'Python {sys.version}' 226 | ) 227 | 228 | parser = argparse.ArgumentParser(prog='import-expression', description='a python REPL with inline import support') 229 | parser.add_argument('-q', '--quiet', action='store_true', help='hide the intro banner and exit message') 230 | parser.add_argument('-a', '--asyncio', action='store_true', help='use the asyncio REPL (python 3.8+)') 231 | parser.add_argument('-i', dest='interactive', action='store_true', help='inspect interactively after running script') 232 | parser.add_argument('-V', '--version', action='version', version=version_info) 233 | parser.add_argument('filename', help='run this file', nargs='?') 234 | 235 | return parser.parse_args() 236 | 237 | def setup_history_and_tab_completion(locals): 238 | try: 239 | import readline 240 | import site 241 | import rlcompleter 242 | except ImportError: 243 | # readline is not available on all platforms 244 | return 245 | 246 | try: 247 | # set up history 248 | sys.__interactivehook__() 249 | except AttributeError: 250 | # site has not set __interactivehook__ because python was run without site packages 251 | return 252 | 253 | # allow completion of text containing an import op (otherwise it is treated as a word boundary) 254 | readline.set_completer_delims(readline.get_completer_delims().replace(constants.IMPORT_OP, '')) 255 | # inform tab completion of what variables were set at the REPL 256 | readline.set_completer(ImportExpressionCompleter(locals).complete) 257 | 258 | def main(): 259 | cwd = os.getcwd() 260 | if cwd not in sys.path: 261 | # if invoked as a script, the user would otherwise not be able to import modules from the cwd, 262 | # which would be inconsistent with `python -m import_expression`. 263 | sys.path.insert(0, cwd) 264 | 265 | repl_locals = { 266 | key: globals()[key] for key in [ 267 | '__name__', '__package__', 268 | '__loader__', '__spec__', 269 | '__builtins__', '__file__' 270 | ] 271 | if key in globals() 272 | } 273 | 274 | args = parse_args() 275 | 276 | if args.asyncio and not SUPPORTS_ASYNCIO_REPL: 277 | print('Python3.8+ required for the AsyncIO REPL.', file=sys.stderr) 278 | sys.exit(2) 279 | 280 | if args.filename: 281 | with open(args.filename) as f: 282 | flags = 0 283 | if args.asyncio: 284 | flags |= PyCF_ALLOW_TOP_LEVEL_AWAIT 285 | prelude = import_expression.compile(f.read(), flags=flags) 286 | if args.asyncio: 287 | prelude_result = eval(prelude, repl_locals) 288 | # if there are no top level awaits in the code, eval will not return a coroutine 289 | if inspect.isawaitable(prelude_result): 290 | # we need a new loop because using asyncio.run here breaks the console 291 | loop = asyncio.new_event_loop() 292 | loop.run_until_complete(prelude_result) 293 | else: 294 | import_expression.exec(prelude, globals=repl_locals) 295 | if not args.interactive: 296 | sys.exit(0) 297 | 298 | setup_history_and_tab_completion(repl_locals) 299 | 300 | interact_kwargs = dict(banner='' if args.quiet else None, exitmsg='' if args.quiet else None) 301 | 302 | if args.asyncio: 303 | asyncio_main(repl_locals, interact_kwargs) 304 | sys.exit(0) 305 | 306 | ImportExpressionInteractiveConsole(repl_locals).interact(**interact_kwargs) 307 | 308 | if __name__ == '__main__': 309 | main() 310 | -------------------------------------------------------------------------------- /import_expression/_parser.py: -------------------------------------------------------------------------------- 1 | # Copyright © io mintz 2 | # Copyright © Thanos <111999343+Sachaa-Thanasius@users.noreply.github.com> 3 | 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the “Software”), 6 | # to deal in the Software without restriction, including without limitation the 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | # sell copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | 11 | # The above copyright notice and this permission notice shall be included in 12 | # all copies or substantial portions of the Software. 13 | 14 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 17 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | # THE SOFTWARE. 21 | 22 | import ast 23 | import sys 24 | import typing 25 | import functools 26 | import contextlib 27 | from collections import namedtuple 28 | from .constants import * 29 | 30 | T = typing.TypeVar("T") 31 | 32 | # https://github.com/python/cpython/blob/5d04cc50e51cb262ee189a6ef0e79f4b372d1583/Objects/exceptions.c#L2438-L2441 33 | _sec_fields = 'filename lineno offset text'.split() 34 | if sys.version_info >= (3, 10): 35 | _sec_fields.extend('end_lineno end_offset'.split()) 36 | 37 | SyntaxErrorContext = namedtuple('SyntaxErrorContext', _sec_fields) 38 | 39 | del _sec_fields 40 | 41 | def transform_ast(root_node, **kwargs): return ast.fix_missing_locations(Transformer(**kwargs).visit(root_node)) 42 | 43 | def find_imports(root_node, **kwargs): 44 | t = ListingTransformer(**kwargs) 45 | t.visit(root_node) 46 | return t.imports 47 | 48 | class Transformer(ast.NodeTransformer): 49 | """An AST transformer that replaces calls to MARKER with '__import__("importlib").import_module(...)'.""" 50 | 51 | def __init__(self, *, filename=None, source=None): 52 | self.filename = filename 53 | self.source_lines = source.splitlines() if source is not None else None 54 | 55 | def _collapse_attributes(self, node: typing.Union[ast.Attribute, ast.Name]) -> str: 56 | if isinstance(node, ast.Name): 57 | return node.id 58 | 59 | if not ( 60 | isinstance(node, ast.Attribute) # pyright: ignore[reportUnnecessaryIsInstance] 61 | and isinstance(node.value, (ast.Attribute, ast.Name)) 62 | ): 63 | raise self._syntax_error( 64 | "Only names and attribute access (dot operator) " 65 | "can be within the inline import expression.", 66 | node, 67 | ) # noqa: TRY004 68 | 69 | return self._collapse_attributes(node.value) + f".{node.attr}" 70 | 71 | def visit_Call(self, node: ast.Call) -> ast.AST: 72 | """Replace the import calls with a valid inline import expression.""" 73 | 74 | if ( 75 | isinstance(node.func, ast.Name) 76 | and node.func.id == MARKER 77 | and len(node.args) == 1 78 | and isinstance(node.args[0], (ast.Attribute, ast.Name)) 79 | ): 80 | identifier = self._collapse_attributes(node.args[0]) 81 | self.transform_import_expr(node, identifier, node.args[0].ctx) 82 | return self.generic_visit(node) 83 | 84 | def transform_import_expr(self, node, identifier, ctx): 85 | node.func = ast.Attribute( 86 | value=ast.Call( 87 | func=ast.Name(id="__import__", ctx=ast.Load()), 88 | args=[ast.Constant(value="importlib")], 89 | keywords=[], 90 | ), 91 | attr="import_module", 92 | ctx=ctx, 93 | ) 94 | identifier = self._collapse_attributes(node.args[0]) 95 | self.import_hook(identifier) 96 | node.args[0] = ast.Constant(value=identifier) 97 | 98 | def import_hook(self, identifier): 99 | """defined by subclasses""" 100 | ... 101 | 102 | def _syntax_error(self, message, node): 103 | lineno = getattr(node, 'lineno', None) 104 | offset = getattr(node, 'col_offset', None) 105 | end_lineno = getattr(node, 'end_lineno', None) 106 | end_offset = getattr(node, 'end_offset', None) 107 | 108 | text = None 109 | if self.source_lines is not None and lineno: 110 | if end_offset is None: 111 | sl = lineno-1 112 | else: 113 | sl = slice(lineno-1, end_lineno-1) 114 | 115 | with contextlib.suppress(IndexError): 116 | text = self.source_lines[sl] 117 | 118 | kwargs = dict( 119 | filename=self.filename, 120 | lineno=lineno, 121 | offset=offset, 122 | text=text, 123 | ) 124 | if sys.version_info >= (3, 10): 125 | kwargs.update(dict( 126 | end_lineno=end_lineno, 127 | end_offset=end_offset, 128 | )) 129 | 130 | return SyntaxError(message, SyntaxErrorContext(**kwargs)) 131 | 132 | class ListingTransformer(Transformer): 133 | """like the parent class but lists all imported modules as self.imports""" 134 | 135 | def __init__(self, *args, **kwargs): 136 | super().__init__(*args, **kwargs) 137 | self.imports = [] 138 | 139 | def import_hook(self, attribute_source): 140 | self.imports.append(attribute_source) 141 | -------------------------------------------------------------------------------- /import_expression/_syntax.py: -------------------------------------------------------------------------------- 1 | # Copyright © io mintz 2 | # Copyright © Thanos <111999343+Sachaa-Thanasius@users.noreply.github.com> 3 | 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the “Software”), 6 | # to deal in the Software without restriction, including without limitation the 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | # sell copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | 11 | # The above copyright notice and this permission notice shall be included in 12 | # all copies or substantial portions of the Software. 13 | 14 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 17 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | # THE SOFTWARE. 21 | 22 | # This file primarily consists of code vendored from the CPython standard library. 23 | # It is used under the Python Software Foundation License Version 2. 24 | # See LICENSE for details. 25 | 26 | import io 27 | import re 28 | import sys 29 | import string 30 | import typing 31 | import collections 32 | from token import * 33 | from .constants import * 34 | import tokenize as tokenize_ 35 | 36 | T = typing.TypeVar("T") 37 | 38 | def fix_syntax(s: typing.AnyStr, filename=DEFAULT_FILENAME) -> bytes: 39 | try: 40 | tokens, encoding = tokenize(s) 41 | tokens = list(tokens) 42 | except tokenize_.TokenError as ex: 43 | message, (lineno, offset) = ex.args 44 | 45 | try: 46 | source_line = s.splitlines()[lineno-2] 47 | except IndexError: 48 | source_line = None 49 | 50 | raise SyntaxError(message, (filename, lineno-1, offset, source_line)) from None 51 | 52 | transformed = transform_tokens(tokens) 53 | return tokenize_.untokenize(transformed).decode(encoding) 54 | 55 | def offset_token_horizontal(tok: tokenize_.TokenInfo, offset: int) -> tokenize_.TokenInfo: 56 | """Takes a token and returns a new token with the columns for start and end offset by a given amount.""" 57 | 58 | start_row, start_col = tok.start 59 | end_row, end_col = tok.end 60 | return tok._replace(start=(start_row, start_col + offset), end=(end_row, end_col + offset)) 61 | 62 | def offset_line_horizontal( 63 | tokens: typing.List[tokenize_.TokenInfo], 64 | start_index: int = 0, 65 | *, 66 | line: int, 67 | offset: int, 68 | ) -> None: 69 | """Takes a list of tokens and changes the offset of some of the tokens in place.""" 70 | 71 | for i, tok in enumerate(tokens[start_index:], start=start_index): 72 | if tok.start[0] != line: 73 | break 74 | tokens[i] = offset_token_horizontal(tok, offset) 75 | 76 | def transform_tokens(tokens: typing.Iterable[tokenize_.TokenInfo]) -> typing.List[tokenize_.TokenInfo]: 77 | """Find the inline import expressions in a list of tokens and replace the relevant tokens to wrap the imported 78 | modules with a call to MARKER. 79 | 80 | Later, the AST transformer step will replace those with valid import expressions. 81 | """ 82 | 83 | orig_tokens = list(tokens) 84 | new_tokens: typing.List[tokenize_.TokenInfo] = [] 85 | 86 | for orig_i, tok in enumerate(orig_tokens): 87 | # "!" is only an OP in >=3.12. 88 | if tok.type in {tokenize_.OP, tokenize_.ERRORTOKEN} and tok.string == IMPORT_OP: 89 | has_invalid_syntax = False 90 | 91 | # Collect all name and attribute access-related tokens directly connected to the "!". 92 | last_place = len(new_tokens) 93 | looking_for_name = True 94 | 95 | for old_tok in reversed(new_tokens): 96 | if old_tok.exact_type != (tokenize_.NAME if looking_for_name else tokenize_.DOT): 97 | # The "!" was placed somewhere in a class definition, e.g. "class Fo!o: pass". 98 | has_invalid_syntax = (old_tok.exact_type == tokenize_.NAME and old_tok.string == "class") 99 | 100 | # There's a name immediately following "!". Might be a f-string conversion flag 101 | # like "f'{thing!r}'" or just something invalid like "def fo!o(): pass". 102 | try: 103 | peek = orig_tokens[orig_i + 1] 104 | except IndexError: 105 | pass 106 | else: 107 | has_invalid_syntax = (has_invalid_syntax or peek.type == tokenize_.NAME) 108 | 109 | break 110 | 111 | last_place -= 1 112 | looking_for_name = not looking_for_name 113 | 114 | # The "!" is just by itself or in a bad spot. Let it error later if it's wrong. 115 | # Also allows other token transformers to work with it without erroring early. 116 | if has_invalid_syntax or last_place == len(new_tokens): 117 | new_tokens.append(tok) 118 | continue 119 | 120 | # Insert a call to the MARKER just before the inline import expression. 121 | old_first = new_tokens[last_place] 122 | old_f_row, old_f_col = old_first.start 123 | 124 | new_tokens[last_place:last_place] = [ 125 | old_first._replace(type=tokenize_.NAME, string=MARKER, end=(old_f_row, old_f_col + len(MARKER))), 126 | tokenize_.TokenInfo( 127 | tokenize_.OP, 128 | "(", 129 | (old_f_row, old_f_col + len(MARKER)), 130 | (old_f_row, old_f_col + len(MARKER)+1), 131 | old_first.line, 132 | ), 133 | ] 134 | 135 | # Adjust the positions of the following tokens within the inline import expression. 136 | new_tokens[last_place + 2:] = (offset_token_horizontal(tok, len(MARKER)+1) for tok in new_tokens[last_place + 2:]) 137 | 138 | # Add a closing parenthesis. 139 | (end_row, end_col) = new_tokens[-1].end 140 | line = new_tokens[-1].line 141 | end_paren_token = tokenize_.TokenInfo(tokenize_.OP, ")", (end_row, end_col), (end_row, end_col + 1), line) 142 | new_tokens.append(end_paren_token) 143 | 144 | # Fix the positions of the rest of the tokens on the same line. 145 | fixed_line_tokens: typing.List[tokenize_.TokenInfo] = [] 146 | offset_line_horizontal(orig_tokens, orig_i, line=new_tokens[-1].start[0], offset=len(MARKER)+1) 147 | 148 | # Check the rest of the line for inline import expressions. 149 | new_tokens.extend(transform_tokens(fixed_line_tokens)) 150 | 151 | else: 152 | new_tokens.append(tok) 153 | 154 | # Hack to get around a bug where code that ends in a comment, but no newline, has an extra 155 | # NEWLINE token added in randomly. This patch wasn't backported to 3.8. 156 | # https://github.com/python/cpython/issues/79288 157 | # https://github.com/python/cpython/issues/88833 158 | if sys.version_info < (3, 9): 159 | if len(new_tokens) >= 4 and ( 160 | new_tokens[-4].type == tokenize_.COMMENT 161 | and new_tokens[-3].type == tokenize_.NL 162 | and new_tokens[-2].type == tokenize_.NEWLINE 163 | and new_tokens[-1].type == tokenize_.ENDMARKER 164 | ): 165 | del new_tokens[-2] 166 | 167 | return new_tokens 168 | 169 | def tokenize(source) -> (str, str): 170 | if isinstance(source, str): 171 | source = source.encode('utf-8') 172 | stream = io.BytesIO(source) 173 | encoding, _ = tokenize_.detect_encoding(stream.readline) 174 | stream.seek(0) 175 | return tokenize_.tokenize(stream.readline), encoding 176 | -------------------------------------------------------------------------------- /import_expression/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright © io mintz 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the “Software”), 5 | # to deal in the Software without restriction, including without limitation the 6 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | # sell copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | IMPORT_OP = '!' 22 | MARKER = '_IMPORT_MARKER' 23 | 24 | DEFAULT_FILENAME = '' 25 | -------------------------------------------------------------------------------- /import_expression/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.2.1.post1' 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Copyright © io mintz 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”), 7 | # to deal in the Software without restriction, including without limitation the 8 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 9 | # sell 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 13 | # all copies or substantial portions of the Software. 14 | 15 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS 16 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 18 | # THE 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 21 | # THE SOFTWARE. 22 | 23 | import distutils 24 | import importlib.util 25 | import logging 26 | import os 27 | import os.path 28 | import setuptools 29 | import subprocess 30 | import shutil 31 | import sys 32 | import typing 33 | from setuptools.command.install import install as _install 34 | 35 | logging.basicConfig(level=logging.INFO) 36 | here = os.path.realpath(os.path.dirname(__file__)) 37 | 38 | def import_by_path(name, path): 39 | spec = importlib.util.spec_from_file_location(name, path) 40 | module = importlib.util.module_from_spec(spec) 41 | spec.loader.exec_module(module) 42 | 43 | return module 44 | 45 | init_path = os.path.join(here, 'import_expression', 'version.py') 46 | # we put the version in a separate file because: 47 | # A) We can't import the module directly before it's installed 48 | # B) If we put __version__ in __init__.py, this alternate import method would fail 49 | # because the modules that __init__ imports would not be available. 50 | version = import_by_path('version', init_path).__version__ 51 | 52 | with open('README.md') as f: 53 | long_description = f.read() 54 | 55 | command_classes = {} 56 | 57 | class ScriptCommand(type): 58 | def __new__(metacls, clsname, bases, attrs, *, name, description='', commands: typing.Sequence[typing.Tuple[str, ...]]): 59 | bases += (setuptools.Command,) 60 | cls = type.__new__(metacls, name, bases, attrs) 61 | 62 | cls.__commands = commands 63 | cls.description = description 64 | cls.user_options = [] 65 | 66 | def run(self): 67 | for command in self.__commands: 68 | logging.info(repr(command)) 69 | p = subprocess.Popen(command) 70 | status = p.wait() 71 | if status != 0: 72 | sys.exit(status) 73 | 74 | sys.exit(0) 75 | 76 | def noop(*args, **kwargs): 77 | pass 78 | 79 | cls.run = run 80 | cls.initialize_options = cls.finalize_options = noop 81 | command_classes[name] = cls 82 | 83 | return cls 84 | 85 | class UnitTestCommand( 86 | metaclass=ScriptCommand, 87 | name='test', 88 | description='run unit tests', 89 | commands=( 90 | 'pytest tests.py ' 91 | '--cov import_expression ' 92 | '--cov-config .coveragerc ' 93 | '--cov-report term-missing ' 94 | '--cov-report html ' 95 | '--cov-branch' 96 | .split(),) 97 | ): 98 | pass 99 | 100 | class ReleaseCommand( 101 | metaclass=ScriptCommand, 102 | name='release', 103 | description='build and upload a release', 104 | commands=( 105 | (sys.executable, __file__, 'sdist', 'bdist_wheel'), 106 | ('twine', 'upload', 'dist/*'), 107 | ) 108 | ): 109 | pass 110 | 111 | setuptools.setup( 112 | name='import_expression', 113 | version=version, 114 | 115 | description='Parses a superset of Python allowing for inline module import expressions', 116 | long_description=long_description, 117 | long_description_content_type='text/markdown', 118 | 119 | license='MIT', 120 | 121 | author='io mintz', 122 | author_email='io@mintz.cc', 123 | url='https://github.com/ioistired/import-expression', 124 | 125 | packages=['import_expression'], 126 | 127 | extras_require={ 128 | 'test': [ 129 | 'pytest', 130 | 'pytest-cov', 131 | ], 132 | }, 133 | 134 | entry_points={ 135 | 'console_scripts': [ 136 | 'import-expression = import_expression.__main__:main', 137 | ], 138 | }, 139 | 140 | cmdclass=command_classes, 141 | 142 | classifiers=[ 143 | 'License :: OSI Approved :: MIT License', 144 | ], 145 | ) 146 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | # Copyright © io mintz 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the “Software”), 5 | # to deal in the Software without restriction, including without limitation the 6 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | # sell copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import contextlib 22 | import os 23 | import textwrap 24 | 25 | import pytest 26 | 27 | import import_expression as ie 28 | 29 | invalid_attribute_cases = ( 30 | # arrange this as if ! is binary 1, empty str is 0 31 | '!a', 32 | 33 | 'a.!b', 34 | '!a.b', 35 | 'a!.b!', 36 | 37 | 'a.b!.c!', 38 | 'a!.b!.c', 39 | 40 | 'a.b.!c', 41 | 'a.!b.c', 42 | 'a.!b.!c' 43 | '!a.b.c', 44 | '!a.b.!c', 45 | '!a.!b.c', 46 | '!a.!b.!c' 47 | 48 | 'a!b', 49 | 'ab.bc.d!e', 50 | 'ab.b!c', 51 | ) 52 | 53 | @pytest.mark.parametrize('valid', [f'"{invalid}"' for invalid in invalid_attribute_cases]) 54 | def test_valid_string_literals(valid): 55 | ie.compile(valid) 56 | 57 | @pytest.mark.parametrize('invalid', invalid_attribute_cases) 58 | def test_invalid_attribute_syntax(invalid): 59 | with pytest.raises(SyntaxError): 60 | ie.compile(invalid) 61 | 62 | def test_import_op_as_attr_name(): 63 | with pytest.raises(SyntaxError): 64 | ie.compile('a.!.b') 65 | 66 | del_store_import_tests = [] 67 | for test in ( 68 | 'a!.b', 69 | 'a.b.c!.d', 70 | ): 71 | del_store_import_tests.append(f'del {test}') 72 | del_store_import_tests.append(f'{test} = 1') 73 | 74 | @pytest.mark.parametrize('test', del_store_import_tests) 75 | def test_del_store_import(test): 76 | ie.compile(test) 77 | 78 | invalid_del_store_import_tests = [] 79 | for test in ( 80 | 'a!', 81 | 'a.b!', 82 | ): 83 | invalid_del_store_import_tests.append(f'del {test}') 84 | invalid_del_store_import_tests.append(f'{test} = 1') 85 | 86 | @pytest.mark.parametrize('test', invalid_del_store_import_tests) 87 | def test_invalid_del_store_import(test): 88 | with pytest.raises(( 89 | ValueError, # raised by builtins.compile 90 | SyntaxError, # ie.parse 91 | )): 92 | ie.compile(test) 93 | 94 | def test_lone_import_op(): 95 | with pytest.raises(SyntaxError): 96 | ie.compile('!') 97 | 98 | @pytest.mark.parametrize('invalid', ( 99 | 'def foo(x!): pass', 100 | 'def foo(*x!): pass', 101 | 'def foo(**y!): pass', 102 | 'def foo(*, z!): pass', 103 | # note space around equals sign: 104 | # class Y(Z!=1) is valid if Z.__ne__ returns a class 105 | 'class Y(Z! = 1): pass', 106 | )) 107 | def test_invalid_argument_syntax(invalid): 108 | with pytest.raises(SyntaxError): 109 | ie.compile(invalid) 110 | 111 | @pytest.mark.parametrize('invalid', ( 112 | 'def !foo(y): pass', 113 | 'def fo!o(y): pass', 114 | 'def foo!(y): pass', 115 | 'class X!: pass', 116 | 'class Fo!o: pass' 117 | 'class !Foo: pass', 118 | # note space around equals sign: 119 | # class Y(Z!=1) is valid if Z.__ne__ returns a class 120 | 'class Y(Z! = 1): pass', 121 | )) 122 | def test_invalid_def_syntax(invalid): 123 | with pytest.raises(SyntaxError): 124 | ie.compile(invalid) 125 | 126 | def test_del_store_attribute(): 127 | class AttributeBox: 128 | pass 129 | 130 | x = AttributeBox() 131 | g = dict(x=x) 132 | 133 | ie.exec('x.y = 1', g) 134 | assert x.y == 1 135 | 136 | ie.exec('del x.y', g) 137 | assert not hasattr(x, 'y') 138 | 139 | def test_kwargs(): 140 | # see issue #1 141 | ie.compile('f(**a)', mode='eval') 142 | 143 | import collections 144 | assert ie.eval('dict(x=collections!)')['x'] is collections 145 | 146 | @pytest.mark.parametrize(('stmt', 'annotation_var'), ( 147 | ('def foo() -> typing!.Any: pass', 'return'), 148 | ('def foo(x: typing!.Any): pass', 'x'), 149 | ('def foo(x: typing!.Any = 1): pass', 'x'), 150 | )) 151 | def test_typehint_conversion(stmt, annotation_var): 152 | from typing import Any 153 | g = {} 154 | ie.exec(stmt, g) 155 | assert g['foo'].__annotations__[annotation_var] is Any 156 | 157 | def test_comments(): 158 | ie.exec('# a') 159 | 160 | @pytest.mark.parametrize('invalid', ( 161 | 'import x!', 162 | 'import x.y!', 163 | 'import x!.y!', 164 | 'from x!.y import z', 165 | 'from x.y import z!', 166 | 'from w.x import y as z!', 167 | 'from w.x import y as z, a as b!', 168 | )) 169 | def test_import_statement(invalid): 170 | with pytest.raises(SyntaxError): 171 | ie.compile(invalid, mode='exec') 172 | 173 | def test_eval_exec(): 174 | import ipaddress 175 | import urllib.parse 176 | 177 | assert ie.eval('collections!.Counter(urllib.parse!.quote("foo"))') == dict(f=1, o=2) 178 | assert ie.eval('ipaddress!.IPV6LENGTH') == ipaddress.IPV6LENGTH 179 | assert ie.eval('urllib.parse!.quote("?")') == urllib.parse.quote('?') 180 | 181 | g = {} 182 | ie.exec(textwrap.dedent(""" 183 | a = urllib.parse!.unquote 184 | def b(): 185 | return operator!.concat(a('%3F'), a('these_tests_are_overkill_for_a_debug_cog%3D1'))""" 186 | ), g) 187 | 188 | assert g['b']() == '?these_tests_are_overkill_for_a_debug_cog=1' 189 | 190 | g = {} 191 | ie.exec(textwrap.dedent(""" 192 | def foo(x): 193 | x = x + 1 194 | x = x + 1 195 | x = x + 1 196 | x = x + 1 197 | 198 | def bar(): 199 | return urllib.parse!.unquote('can%20we%20make%20it%20into%20jishaku%3F') 200 | 201 | bar.x = 1 # ensure normal attribute syntax is untouched 202 | 203 | # the hanging indent on the following line is intentional 204 | 205 | 206 | return bar() 207 | """), g) 208 | 209 | assert g['foo'](1) == 'can we make it into jishaku?' 210 | 211 | def test_flags(): 212 | import ast 213 | assert isinstance(ie.compile('foo', flags=ast.PyCF_ONLY_AST), ast.AST) 214 | 215 | def test_eval_code_object(): 216 | import collections 217 | code = ie.compile('collections!.Counter', '', 'eval') 218 | assert ie.eval(code) is collections.Counter 219 | 220 | def test_exec_code_object(): 221 | import collections 222 | code = ie.compile('def foo(): return collections!.Counter', '', 'exec') 223 | g = {} 224 | ie.exec(code, globals=g) 225 | assert g['foo']() is collections.Counter 226 | 227 | @pytest.mark.parametrize('invalid', (')', '"')) 228 | def test_normal_invalid_syntax(invalid): 229 | """ensure regular syntax errors are still caught""" 230 | with pytest.raises(SyntaxError): 231 | ie.compile(invalid) 232 | 233 | def test_dont_imply_dedent(): 234 | from codeop import PyCF_DONT_IMPLY_DEDENT 235 | with pytest.raises(SyntaxError): 236 | ie.compile('def foo():\n\tpass', mode='single', flags=PyCF_DONT_IMPLY_DEDENT) 237 | 238 | def test_transform_ast(): 239 | from typing import Any 240 | node = ie.parse(ie.parse('typing!.Any', mode='eval')) 241 | assert ie.eval(node) is Any 242 | 243 | def test_locals_arg(): 244 | ie.exec('assert locals() is globals()', {}) 245 | ie.exec('assert locals() is not globals()', {}, {}) 246 | 247 | def test_bytes(): 248 | import typing 249 | assert ie.eval(b'typing!.TYPE_CHECKING') == typing.TYPE_CHECKING 250 | 251 | def test_beat_is_gay(): 252 | with pytest.raises(SyntaxError): 253 | ie.compile('"beat".succ!') 254 | --------------------------------------------------------------------------------