├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── aiomanhole └── __init__.py ├── setup.py └── test_aiomanhole.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Change History 2 | ============== 3 | 4 | 0.7.0 (23rd January 2022) 5 | - Added support for Python 3.10. Thank you to Peter Bábics for contributing this! 6 | - Removed support for Python 3.5. 7 | 8 | 0.6.0 (30th April 2019) 9 | - Don't use the global loop. Thanks Timothy Fitz! 10 | - Allow a port of 0. Thanks Timothy Fitz! 11 | - Fix unit test failure. 12 | 13 | 0.5.0 (6th August 2018) 14 | - Fix syntax error in 3.7 15 | - Drop 3.4 support. 16 | 17 | 0.4.2 (3rd March 2017) 18 | - Handle clients putting the socket into a half-closed state when an EOF 19 | occurs. 20 | 21 | 0.4.1 (3rd March 2017) 22 | - Ensure prompts are bytes, broken in 0.4.0. 23 | 24 | 0.4.0 (3rd March 2017) 25 | - Ensure actual syntax errors get reported to the client. 26 | 27 | 0.3.0 (23rd August 2016) 28 | - **Behaviour change** aiomanhole no longer attempts to remove the UNIX socket 29 | on shutdown. This was flakey behaviour and does not match best practice 30 | (i.e. removing the UNIX socket on startup before you start your server). As 31 | a result, errors creating the manhole will now be logged instead of silently 32 | failing. 33 | - `start_manhole` now returns a Future that you can wait on. 34 | - Giving a loop to `start_manhole` now works more reliably. This won't matter 35 | for most people. 36 | - Feels "snappier" 37 | 38 | 0.2.1 (14th September 2014) 39 | - Handle a banner of None. 40 | - Fixed small typo in MANIFEST.in for the changelog. 41 | - Feels "snappier" 42 | 43 | 0.2.0 (25th June 2014) 44 | - Handle multiline statements much better. 45 | - setup.py pointed to wrong domain for project URL 46 | - Removed pointless insertion of '_' into the namespace. 47 | - Added lots of tests. 48 | - Feels "snappier" 49 | 50 | 0.1.1 (19th June 2014) 51 | - Use setuptools as a fallback when installing. 52 | - Feels "snappier" 53 | 54 | 0.1 (19th June 2014) 55 | - Initial release 56 | - Feels "snappier" 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Nathan Hoad 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include LICENSE 3 | include README.rst 4 | include CHANGELOG.rst 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | aiomanhole 2 | ========== 3 | 4 | Manhole for accessing asyncio applications. This is useful for debugging 5 | application state in situations where you have access to the process, but need 6 | to access internal application state. 7 | 8 | Adding a manhole to your application is simple:: 9 | 10 | from aiomanhole import start_manhole 11 | 12 | start_manhole(namespace={ 13 | 'gizmo': application_state_gizmo, 14 | 'whatsit': application_state_whatsit, 15 | }) 16 | 17 | Quick example, in one shell, run this:: 18 | 19 | $ python -m aiomanhole 20 | 21 | In a secondary shell, run this:: 22 | 23 | $ nc -U /var/tmp/testing.manhole 24 | Well this is neat 25 | >>> f = 5 + 5 26 | >>> f 27 | 10 28 | >>> import os 29 | >>> os.getpid() 30 | 4238 31 | >>> import sys 32 | >>> sys.exit(0) 33 | 34 | 35 | And you'll see the manhole you started has exited. 36 | 37 | The package provides both a threaded and non-threaded interpreter, and allows 38 | you to share the namespace between clients if you want. 39 | 40 | 41 | I'm getting "Address is already in use" when I start! Help! 42 | =========================================================== 43 | 44 | Unlike regular TCP/UDP sockets, UNIX domain sockets are entries in the 45 | filesystem. When your process shuts down, the UNIX socket that is created is 46 | not cleaned up. What this means is that when your application starts up again, 47 | it will attempt to bind a UNIX socket to that path again and fail, as it is 48 | already present (it's "already in use"). 49 | 50 | The standard approach to working with UNIX sockets is to delete them before you 51 | try to bind to it again, for example:: 52 | 53 | import os 54 | try: 55 | os.unlink('/path/to/my.manhole') 56 | except FileNotFoundError: 57 | pass 58 | start_manhole('/path/to/my.manhole') 59 | 60 | 61 | You may be tempted to try and clean up the socket on shutdown, but don't. What 62 | if your application crashes? What if your computer loses power? There are lots 63 | of things that can go wrong, and hoping the previous run was successful, while 64 | admirably positive, is not something you can do. 65 | 66 | 67 | Can I specify what is available in the manhole? 68 | =============================================== 69 | Yes! When you call `start_manhole`, just pass along a dictionary of what you 70 | want to provide as the namespace parameter:: 71 | 72 | from aiomanhole import start_manhole 73 | 74 | start_manhole(namespace={ 75 | 'gizmo': application_state_gizmo, 76 | 'whatsit': application_state_whatsit, 77 | 'None': 5, # don't do this though 78 | }) 79 | 80 | 81 | When should I use threaded=True? 82 | ================================ 83 | 84 | Specifying threaded=True means that statements in the interactive session are 85 | executed in a thread, as opposed to executing them in the event loop. 86 | 87 | Say for example you did this in a non-threaded interactive session:: 88 | 89 | >>> while True: 90 | ... pass 91 | ... 92 | 93 | You've just broken your application! You can't abort that without restarting 94 | the application. If however you ran that in a threaded application, you'd 95 | 'only' have a thread trashing the CPU, slowing down your application, as 96 | opposed to making it totally unresponsive. 97 | 98 | By default, a threaded interpreter will time out commands after 5 seconds, 99 | though this is configurable. Not that this will **not** kill the thread, but 100 | allow you to keep running commands. 101 | -------------------------------------------------------------------------------- /aiomanhole/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import functools 4 | import sys 5 | import traceback 6 | 7 | from codeop import CommandCompiler 8 | from io import BytesIO, StringIO 9 | 10 | 11 | __all__ = ["start_manhole"] 12 | 13 | 14 | class StatefulCommandCompiler(CommandCompiler): 15 | """A command compiler that buffers input until a full command is available.""" 16 | 17 | def __init__(self): 18 | super().__init__() 19 | self.buf = BytesIO() 20 | 21 | def is_partial_command(self): 22 | return bool(self.buf.getvalue()) 23 | 24 | def __call__(self, source, **kwargs): 25 | buf = self.buf 26 | if self.is_partial_command(): 27 | buf.write(b"\n") 28 | buf.write(source) 29 | 30 | code = self.buf.getvalue().decode("utf8") 31 | 32 | codeobj = super().__call__(code, **kwargs) 33 | 34 | if codeobj: 35 | self.reset() 36 | return codeobj 37 | 38 | def reset(self): 39 | self.buf.seek(0) 40 | self.buf.truncate(0) 41 | 42 | 43 | class InteractiveInterpreter: 44 | """An interactive asynchronous interpreter.""" 45 | 46 | def __init__(self, namespace, banner, loop): 47 | self.namespace = namespace 48 | self.banner = self.get_banner(banner) 49 | self.compiler = StatefulCommandCompiler() 50 | self.loop = loop 51 | 52 | def get_banner(self, banner): 53 | if isinstance(banner, bytes): 54 | return banner 55 | elif isinstance(banner, str): 56 | return banner.encode("utf8") 57 | elif banner is None: 58 | return b"" 59 | else: 60 | raise ValueError( 61 | "Cannot handle unknown banner type {!}, expected str or bytes".format( 62 | banner.__class__.__name__ 63 | ) 64 | ) 65 | 66 | def attempt_compile(self, line): 67 | return self.compiler(line) 68 | 69 | async def send_exception(self): 70 | """When an exception has occurred, write the traceback to the user.""" 71 | self.compiler.reset() 72 | 73 | exc = traceback.format_exc() 74 | self.writer.write(exc.encode("utf8")) 75 | 76 | await self.writer.drain() 77 | 78 | async def attempt_exec(self, codeobj, namespace): 79 | with contextlib.redirect_stdout(StringIO()) as buf: 80 | value = await self._real_exec(codeobj, namespace) 81 | 82 | return value, buf.getvalue() 83 | 84 | async def _real_exec(self, codeobj, namespace): 85 | return eval(codeobj, namespace) 86 | 87 | async def handle_one_command(self): 88 | """Process a single command. May have many lines.""" 89 | 90 | while True: 91 | await self.write_prompt() 92 | codeobj = await self.read_command() 93 | 94 | if codeobj is not None: 95 | await self.run_command(codeobj) 96 | 97 | async def run_command(self, codeobj): 98 | """Execute a compiled code object, and write the output back to the client.""" 99 | try: 100 | value, stdout = await self.attempt_exec(codeobj, self.namespace) 101 | except Exception: 102 | await self.send_exception() 103 | return 104 | else: 105 | await self.send_output(value, stdout) 106 | 107 | async def write_prompt(self): 108 | writer = self.writer 109 | 110 | if self.compiler.is_partial_command(): 111 | writer.write(sys.ps2.encode("utf8")) 112 | else: 113 | writer.write(sys.ps1.encode("utf8")) 114 | 115 | await writer.drain() 116 | 117 | async def read_command(self): 118 | """Read a command from the user line by line. 119 | 120 | Returns a code object suitable for execution. 121 | """ 122 | 123 | reader = self.reader 124 | 125 | line = await reader.readline() 126 | if line == b"": # lost connection 127 | raise ConnectionResetError() 128 | 129 | try: 130 | # skip the newline to make CommandCompiler work as advertised 131 | codeobj = self.attempt_compile(line.rstrip(b"\n")) 132 | except SyntaxError: 133 | await self.send_exception() 134 | return 135 | 136 | return codeobj 137 | 138 | async def send_output(self, value, stdout): 139 | """Write the output or value of the expression back to user. 140 | 141 | >>> 5 142 | 5 143 | >>> print('cash rules everything around me') 144 | cash rules everything around me 145 | """ 146 | 147 | writer = self.writer 148 | 149 | if value is not None: 150 | writer.write("{!r}\n".format(value).encode("utf8")) 151 | 152 | if stdout: 153 | writer.write(stdout.encode("utf8")) 154 | 155 | await writer.drain() 156 | 157 | def _setup_prompts(self): 158 | try: 159 | sys.ps1 160 | except AttributeError: 161 | sys.ps1 = ">>> " 162 | try: 163 | sys.ps2 164 | except AttributeError: 165 | sys.ps2 = "... " 166 | 167 | async def __call__(self, reader, writer): 168 | """Main entry point for an interpreter session with a single client.""" 169 | 170 | self.reader = reader 171 | self.writer = writer 172 | 173 | self._setup_prompts() 174 | 175 | if self.banner: 176 | writer.write(self.banner) 177 | await writer.drain() 178 | 179 | while True: 180 | try: 181 | await self.handle_one_command() 182 | except ConnectionResetError: 183 | writer.close() 184 | break 185 | except Exception as e: 186 | traceback.print_exc() 187 | 188 | 189 | class ThreadedInteractiveInterpreter(InteractiveInterpreter): 190 | """An interactive asynchronous interpreter that executes 191 | statements/expressions in a thread. 192 | 193 | This is useful for aiding to protect against accidentally running 194 | slow/terminal code in your main loop, which would destroy the process. 195 | 196 | Also accepts a timeout, which defaults to five seconds. This won't kill 197 | the running statement (good luck killing a thread) but it will at least 198 | yield control back to the manhole. 199 | """ 200 | 201 | def __init__(self, *args, command_timeout=5, **kwargs): 202 | super().__init__(*args, **kwargs) 203 | self.command_timeout = command_timeout 204 | 205 | async def _real_exec(self, codeobj, namespace): 206 | task = self.loop.run_in_executor(None, eval, codeobj, namespace) 207 | if self.command_timeout: 208 | task = asyncio.wait_for(task, self.command_timeout) 209 | value = await task 210 | return value 211 | 212 | 213 | class InterpreterFactory: 214 | """Factory class for creating interpreters.""" 215 | 216 | def __init__( 217 | self, 218 | interpreter_class, 219 | *args, 220 | namespace=None, 221 | shared=False, 222 | loop=None, 223 | **kwargs 224 | ): 225 | self.interpreter_class = interpreter_class 226 | self.namespace = namespace or {} 227 | self.shared = shared 228 | self.args = args 229 | self.kwargs = kwargs 230 | self.loop = loop or asyncio.get_event_loop() 231 | 232 | def __call__(self, reader, writer): 233 | interpreter = self.interpreter_class( 234 | *self.args, 235 | loop=self.loop, 236 | namespace=self.namespace if self.shared else dict(self.namespace), 237 | **self.kwargs 238 | ) 239 | return asyncio.ensure_future(interpreter(reader, writer), loop=self.loop) 240 | 241 | 242 | def start_manhole( 243 | banner=None, 244 | host="127.0.0.1", 245 | port=None, 246 | path=None, 247 | namespace=None, 248 | loop=None, 249 | threaded=False, 250 | command_timeout=5, 251 | shared=False, 252 | ): 253 | 254 | """Starts a manhole server on a given TCP and/or UNIX address. 255 | 256 | Keyword arguments: 257 | banner - Text to display when client initially connects. 258 | host - interface to bind on. 259 | port - port to listen on over TCP. Default is disabled. 260 | path - filesystem path to listen on over UNIX sockets. Deafult is disabled. 261 | namespace - dictionary namespace to provide to connected clients. 262 | threaded - if True, use a threaded interpreter. False, run them in the 263 | middle of the event loop. See ThreadedInteractiveInterpreter 264 | for details. 265 | command_timeout - timeout in seconds for commands. Only applies if 266 | `threaded` is True. 267 | shared - If True, share a single namespace between all clients. 268 | 269 | Returns a Future for starting the server(s). 270 | """ 271 | 272 | loop = loop or asyncio.get_event_loop() 273 | 274 | if (port, path) == (None, None): 275 | raise ValueError("At least one of port or path must be given") 276 | 277 | if threaded: 278 | interpreter_class = functools.partial( 279 | ThreadedInteractiveInterpreter, command_timeout=command_timeout 280 | ) 281 | else: 282 | interpreter_class = InteractiveInterpreter 283 | 284 | client_cb = InterpreterFactory( 285 | interpreter_class, shared=shared, namespace=namespace, banner=banner, loop=loop 286 | ) 287 | 288 | coros = [] 289 | 290 | if path: 291 | f = asyncio.ensure_future( 292 | asyncio.start_unix_server(client_cb, path=path), loop=loop 293 | ) 294 | coros.append(f) 295 | 296 | if port is not None: 297 | f = asyncio.ensure_future( 298 | asyncio.start_server(client_cb, host=host, port=port), loop=loop 299 | ) 300 | coros.append(f) 301 | 302 | return asyncio.gather(*coros) 303 | 304 | 305 | if __name__ == "__main__": 306 | loop = asyncio.new_event_loop() 307 | asyncio.set_event_loop(loop) 308 | start_manhole( 309 | path="/var/tmp/testing.manhole", 310 | banner="Well this is neat\n", 311 | threaded=True, 312 | shared=True, 313 | loop=loop, 314 | ) 315 | loop.run_forever() 316 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | 3 | from setuptools import setup 4 | 5 | settings = { 6 | "name": "aiomanhole", 7 | "version": "0.7.0", 8 | "description": "Python module to provide a manhole in asyncio applications", 9 | "long_description": "\n\n".join( 10 | [open("README.rst").read(), open("CHANGELOG.rst").read()] 11 | ), 12 | "author": "Nathan Hoad", 13 | "author_email": "nathan@hoad.io", 14 | "url": "https://github.com/nathan-hoad/aiomanhole", 15 | "license": "BSD (3-clause)", 16 | "classifiers": [ 17 | # 'Development Status :: 5 - Production/Stable', 18 | "Intended Audience :: Developers", 19 | "Natural Language :: English", 20 | "License :: OSI Approved :: BSD License", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.6", 23 | "Programming Language :: Python :: 3.7", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | ], 28 | "packages": ["aiomanhole"], 29 | } 30 | 31 | setup(**settings) 32 | -------------------------------------------------------------------------------- /test_aiomanhole.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from contextlib import contextmanager 3 | import os 4 | import shutil 5 | import tempfile 6 | 7 | from io import BytesIO 8 | from unittest import mock 9 | 10 | import pytest 11 | 12 | from aiomanhole import StatefulCommandCompiler, InteractiveInterpreter, start_manhole 13 | 14 | 15 | @pytest.fixture(scope="function") 16 | def compiler(): 17 | return StatefulCommandCompiler() 18 | 19 | 20 | @pytest.fixture(scope="function") 21 | def interpreter(loop): 22 | s = InteractiveInterpreter({}, "", loop) 23 | s.reader = MockStream() 24 | s.writer = MockStream() 25 | 26 | return s 27 | 28 | 29 | @pytest.fixture(scope="function") 30 | def loop(): 31 | loop = asyncio.new_event_loop() 32 | asyncio.set_event_loop(None) 33 | return loop 34 | 35 | 36 | @contextmanager 37 | def tcp_server(loop): 38 | (server,) = loop.run_until_complete(start_manhole(port=0, loop=loop)) 39 | (socket,) = server.sockets 40 | (ip, port) = socket.getsockname() 41 | 42 | yield loop.run_until_complete(asyncio.open_connection("127.0.0.1", port)) 43 | 44 | server.close() 45 | loop.run_until_complete(server.wait_closed()) 46 | 47 | 48 | @contextmanager 49 | def unix_server(loop): 50 | directory = tempfile.mkdtemp() 51 | 52 | try: 53 | domain_socket = os.path.join(directory, "aiomanhole") 54 | (server,) = loop.run_until_complete( 55 | start_manhole(path=domain_socket, loop=loop) 56 | ) 57 | 58 | yield loop.run_until_complete(asyncio.open_unix_connection(path=domain_socket)) 59 | 60 | server.close() 61 | loop.run_until_complete(server.wait_closed()) 62 | 63 | finally: 64 | shutil.rmtree(directory) 65 | 66 | 67 | async def send_command(message, reader, writer, loop): 68 | # Prompt on connect 69 | assert await reader.read(4) == b">>> " 70 | 71 | # Send message 72 | writer.write(message) 73 | 74 | # Read until we see the next prompt, then strip off the prompt 75 | prompt = b"\n>>>" 76 | response = await reader.readuntil(separator=prompt) 77 | writer.close() 78 | return response[: -len(prompt)] 79 | 80 | 81 | class MockStream: 82 | def __init__(self): 83 | self.buf = BytesIO() 84 | 85 | def write(self, data): 86 | self.buf.write(data) 87 | 88 | async def drain(self): 89 | pass 90 | 91 | async def readline(self): 92 | self.buf.seek(0) 93 | return self.buf.readline() 94 | 95 | 96 | class TestStatefulCommandCompiler: 97 | def test_one_line(self, compiler): 98 | f = compiler(b"f = 5") 99 | assert f is not None 100 | ns = {} 101 | eval(f, ns) 102 | assert ns["f"] == 5 103 | 104 | f = compiler(b"5") 105 | assert f is not None 106 | eval(f, {}) 107 | 108 | assert __builtins__["_"] == 5 109 | 110 | assert compiler(b"import asyncio") is not None 111 | 112 | MULTI_LINE_1 = [ 113 | b"try:", 114 | b" raise Exception", 115 | b"except:", 116 | b" pass", 117 | b"", 118 | ] 119 | 120 | MULTI_LINE_2 = [ 121 | b"for i in range(2):", 122 | b" pass", 123 | b"", 124 | ] 125 | 126 | MULTI_LINE_3 = [ 127 | b"while False:", 128 | b" pass", 129 | b"", 130 | ] 131 | 132 | MULTI_LINE_4 = [ 133 | b"class Foo:", 134 | b" pass", 135 | b"", 136 | ] 137 | 138 | MULTI_LINE_5 = [ 139 | b"def foo():", 140 | b" pass", 141 | b"", 142 | ] 143 | 144 | MULTI_LINE_6 = [ 145 | b"if False:", 146 | b" pass", 147 | b"", 148 | ] 149 | 150 | MULTI_LINE_7 = [ 151 | b"@decorated", 152 | b"def foo():", 153 | b" pass", 154 | b"", 155 | ] 156 | 157 | @pytest.mark.parametrize( 158 | "input", 159 | [ 160 | MULTI_LINE_1, 161 | MULTI_LINE_2, 162 | MULTI_LINE_4, 163 | MULTI_LINE_4, 164 | MULTI_LINE_5, 165 | MULTI_LINE_6, 166 | MULTI_LINE_7, 167 | ], 168 | ) 169 | def test_multi_line(self, compiler, input): 170 | for line in input[:-1]: 171 | assert compiler(line) is None 172 | 173 | codeobj = compiler(input[-1]) 174 | 175 | assert codeobj is not None 176 | 177 | def test_multi_line__fails_on_missing_line_ending(self, compiler): 178 | lines = [ 179 | b"@decorated", 180 | b"def foo():", 181 | b" pass", 182 | ] 183 | for line in lines[:-1]: 184 | assert compiler(line) is None 185 | 186 | codeobj = compiler(lines[-1]) 187 | 188 | assert codeobj is None 189 | 190 | 191 | class TestInteractiveInterpreter: 192 | @pytest.mark.parametrize( 193 | "banner,expected_result", 194 | [ 195 | (b"straight up bytes", b"straight up bytes"), 196 | ("dat unicode tho", b"dat unicode tho"), 197 | (None, b""), 198 | (object(), ValueError), 199 | ], 200 | ) 201 | def test_get_banner(self, banner, expected_result, interpreter): 202 | if isinstance(expected_result, type) and issubclass(expected_result, Exception): 203 | pytest.raises(expected_result, interpreter.get_banner, banner) 204 | else: 205 | assert interpreter.get_banner(banner) == expected_result 206 | 207 | @pytest.mark.parametrize("partial", [True, False]) 208 | def test_write_prompt(self, interpreter, loop, partial): 209 | with mock.patch.object( 210 | interpreter.compiler, "is_partial_command", return_value=partial 211 | ): 212 | with mock.patch("sys.ps1", ">>> ", create=True), mock.patch( 213 | "sys.ps2", "... ", create=True 214 | ): 215 | loop.run_until_complete(interpreter.write_prompt()) 216 | 217 | expected_value = b"... " if partial else b">>> " 218 | assert interpreter.writer.buf.getvalue() == expected_value 219 | 220 | @pytest.mark.parametrize( 221 | "line,partial", 222 | [ 223 | (b"f = 5", False), 224 | (b"def foo():", True), 225 | ], 226 | ) 227 | def test_read_command(self, interpreter, loop, line, partial): 228 | interpreter.reader.write(line) 229 | f = loop.run_until_complete(interpreter.read_command()) 230 | 231 | if partial: 232 | assert f is None 233 | else: 234 | assert f is not None 235 | 236 | def test_read_command__raises_on_empty_read(self, interpreter, loop): 237 | pytest.raises( 238 | ConnectionResetError, loop.run_until_complete, interpreter.read_command() 239 | ) 240 | 241 | @pytest.mark.parametrize( 242 | "value,stdout,expected_output", 243 | [ 244 | (5, "", b"5\n"), 245 | (5, "hello", b"5\nhello"), 246 | (None, "hello", b"hello"), 247 | ], 248 | ) 249 | def test_send_output(self, interpreter, loop, value, stdout, expected_output): 250 | loop.run_until_complete(interpreter.send_output(value, stdout)) 251 | 252 | output = interpreter.writer.buf.getvalue() 253 | assert output == expected_output 254 | 255 | @pytest.mark.parametrize( 256 | "stdin,expected_output", 257 | [ 258 | (b'print("hello")', b"hello"), 259 | (b"101", b"101"), 260 | ], 261 | ) 262 | @pytest.mark.parametrize("server_factory", [tcp_server, unix_server]) 263 | def test_command_over_localhost_network( 264 | self, loop, server_factory, stdin, expected_output 265 | ): 266 | with server_factory(loop=loop) as (reader, writer): 267 | output = loop.run_until_complete( 268 | send_command(stdin + b"\n", reader, writer, loop) 269 | ) 270 | assert output == expected_output 271 | --------------------------------------------------------------------------------