diff --git a/CHANGES b/CHANGES index 6ffa7e38e14..fbf320e294d 100644 --- a/CHANGES +++ b/CHANGES @@ -7,6 +7,28 @@ Here you can find the recent changes to tmuxp current ------- - *Insert changes/features/fixes for next release here* +- :issue:`641` Improvements to ``shell`` + + Thanks `django-extensions`_ (licensed MIT) for the shell detection abstraction. + + - Deprecate ``shell_plus`` + - ``tmuxp shell`` now detects the best shell available by default + - Python 3.7+ with ``PYTHONBREAKPOINT`` set in env will drop into ``pdb`` by + default + - Drop into ``code.interact`` by default instead of ``pdb`` if no third + party shells found + - New options, override: + + - ``--pdb``: Use plain old ``breakpoint()`` (python 3.7+) or + ``pdb.set_trace`` + - ``--code``: Drop into ``code.interact``, accepts ``--use-pythonrc`` + - ``--bpython``: Drop into bpython + - ``--ipython``: Drop into ipython + - ``--ptpython``: Drop into ptpython, accepts ``--use-vi-mode`` + - ``--ptipython``: Drop into ipython + ptpython, accepts + ``--use-vi-mode`` + +.. _django-extensions: https://github.com/django-extensions/django-extensions tmuxp 1.6.0 (2020-11-06) ------------------------ diff --git a/docs/api.rst b/docs/api.rst index 031e3d9f1bf..c3b523ddd38 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -15,6 +15,18 @@ Internals .. automethod:: tmuxp.util.run_before_script +.. automethod:: tmuxp.util.oh_my_zsh_auto_title + +.. automethod:: tmuxp.util.raise_if_tmux_not_running + +.. automethod:: tmuxp.util.get_current_pane + +.. automethod:: tmuxp.util.get_session + +.. automethod:: tmuxp.util.get_window + +.. automethod:: tmuxp.util.get_pane + CLI --- diff --git a/docs/cli.rst b/docs/cli.rst index 7a0dd8a9e10..59cb2cee60b 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -101,6 +101,20 @@ this via ``tmuxp -c``: .. _ipdb: https://pypi.org/project/ipdb/ .. _libtmux: https://libtmux.git-pull.com +Shell detection +~~~~~~~~~~~~~~~ + +``tmuxp shell`` detects the richest shell available in your *site packages*, you can also pick your shell via args: + +- ``--pdb``: Use plain old ``breakpoint()`` (python 3.7+) or + ``pdb.set_trace`` +- ``--code``: Drop into ``code.interact``, accepts ``--use-pythonrc`` +- ``--bpython``: Drop into bpython +- ``--ipython``: Drop into ipython +- ``--ptpython``: Drop into ptpython, accepts ``--use-vi-mode`` +- ``--ptipython``: Drop into ipython + ptpython, accepts + ``--use-vi-mode`` + .. _cli_freeze: Freeze sessions diff --git a/tests/test_cli.py b/tests/test_cli.py index 7c8cbf51881..2741a30a2f7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,7 +13,7 @@ import libtmux from libtmux.common import has_lt_version from libtmux.exc import LibTmuxException -from tmuxp import cli, config +from tmuxp import cli, config, exc from tmuxp.cli import ( command_ls, get_config_dir, @@ -407,7 +407,7 @@ def test_load_zsh_autotitle_warning(cli_args, tmpdir, monkeypatch): assert 'Please set' not in result.output -@pytest.mark.parametrize("cli_cmd", ['shell', 'shell_plus']) +@pytest.mark.parametrize("cli_cmd", ['shell', ('shell', '--pdb')]) @pytest.mark.parametrize( "cli_args,inputs,env,expected_output", [ @@ -501,7 +501,8 @@ def test_shell( SERVER_SOCKET_NAME=server.socket_name, ) - cli_args = [cli_cmd] + [cli_arg.format(**template_ctx) for cli_arg in cli_args] + cli_cmd = list(cli_cmd) if isinstance(cli_cmd, (list, tuple)) else [cli_cmd] + cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] for k, v in env.items(): monkeypatch.setenv(k, v.format(**template_ctx)) @@ -515,7 +516,13 @@ def test_shell( assert expected_output.format(**template_ctx) in result.output -@pytest.mark.parametrize("cli_cmd", ['shell', 'shell_plus']) +@pytest.mark.parametrize( + "cli_cmd", + [ + 'shell', + ('shell', '--pdb'), + ], +) @pytest.mark.parametrize( "cli_args,inputs,env,template_ctx,exception,message", [ @@ -537,7 +544,7 @@ def test_shell( [], {}, {'session_name': 'nonexistant_session'}, - None, + exc.TmuxpException, 'Session not found: nonexistant_session', ), ( @@ -551,7 +558,7 @@ def test_shell( [], {}, {'window_name': 'nonexistant_window'}, - None, + exc.TmuxpException, 'Window not found: {WINDOW_NAME}', ), ], @@ -583,7 +590,8 @@ def test_shell_target_missing( PANE_ID=template_ctx.get('pane_id'), SERVER_SOCKET_NAME=server.socket_name, ) - cli_args = [cli_cmd] + [cli_arg.format(**template_ctx) for cli_arg in cli_args] + cli_cmd = list(cli_cmd) if isinstance(cli_cmd, (list, tuple)) else [cli_cmd] + cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] for k, v in env.items(): monkeypatch.setenv(k, v.format(**template_ctx)) @@ -603,12 +611,23 @@ def test_shell_target_missing( assert message.format(**template_ctx) in result.output +@pytest.mark.parametrize( + "cli_cmd", + [ + # 'shell', + # ('shell', '--pdb'), + ('shell', '--code'), + # ('shell', '--bpython'), + # ('shell', '--ptipython'), + # ('shell', '--ptpython'), + # ('shell', '--ipython'), + ], +) @pytest.mark.parametrize( "cli_args,inputs,env,message", [ ( [ - 'shell_plus', '-L{SOCKET_NAME}', ], [], @@ -617,7 +636,6 @@ def test_shell_target_missing( ), ( [ - 'shell_plus', '-L{SOCKET_NAME}', ], [], @@ -627,6 +645,7 @@ def test_shell_target_missing( ], ) def test_shell_plus( + cli_cmd, cli_args, inputs, env, @@ -650,7 +669,9 @@ def test_shell_plus( SERVER_SOCKET_NAME=server.socket_name, ) - cli_args[:] = [cli_arg.format(**template_ctx) for cli_arg in cli_args] + cli_cmd = list(cli_cmd) if isinstance(cli_cmd, (list, tuple)) else [cli_cmd] + cli_args = cli_cmd + [cli_arg.format(**template_ctx) for cli_arg in cli_args] + for k, v in env.items(): monkeypatch.setenv(k, v.format(**template_ctx)) diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 00000000000..9c700b50396 --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,14 @@ +from tmuxp import shell +from tmuxp._compat import string_types + + +def test_detect_best_shell(): + result = shell.detect_best_shell() + assert isinstance(result, string_types) + + +def test_shell_detect(): + assert isinstance(shell.has_bpython(), bool) + assert isinstance(shell.has_ipython(), bool) + assert isinstance(shell.has_ptpython(), bool) + assert isinstance(shell.has_ptipython(), bool) diff --git a/tmuxp/cli.py b/tmuxp/cli.py index 59306f3d450..6b1ac0cf5df 100644 --- a/tmuxp/cli.py +++ b/tmuxp/cli.py @@ -16,12 +16,12 @@ from click.exceptions import FileError from libtmux.common import has_gte_version, has_minimum_version, which -from libtmux.exc import LibTmuxException, TmuxCommandNotFound +from libtmux.exc import TmuxCommandNotFound from libtmux.server import Server from . import config, exc, log, util from .__about__ import __version__ -from ._compat import string_types +from ._compat import PY3, PYMINOR, string_types from .workspacebuilder import WorkspaceBuilder, freeze logger = logging.getLogger(__name__) @@ -671,234 +671,90 @@ def startup(config_dir): 'command', help='Instead of opening shell, execute python code in libtmux and exit', ) -def command_shell(session_name, window_name, socket_name, socket_path, command): - """Launch python shell for tmux server, session, window and pane. - - Priority given to loaded session/wndow/pane objects: - - session_name and window_name arguments - - current shell: environmental variable of TMUX_PANE (which gives us window and - session) - - ``server.attached_session``, ``session.attached_window``, ``window.attached_pane`` - """ - server = Server(socket_name=socket_name, socket_path=socket_path) - - try: - server.sessions - except LibTmuxException as e: - if 'No such file or directory' in str(e): - raise LibTmuxException( - 'no tmux session found. Start a tmux session and try again. \n' - 'Original error: ' + str(e) - ) - else: - raise e - - current_pane = None - if os.getenv('TMUX_PANE') is not None: - try: - current_pane = [ - p - for p in server._list_panes() - if p.get('pane_id') == os.getenv('TMUX_PANE') - ][0] - except IndexError: - pass - - try: - if session_name: - session = server.find_where({'session_name': session_name}) - elif current_pane is not None: - session = server.find_where({'session_id': current_pane['session_id']}) - else: - session = server.list_sessions()[0] - - if not session: - raise exc.TmuxpException('Session not found: %s' % session_name) - except exc.TmuxpException as e: - print(e) - return - - try: - if window_name: - window = session.find_where({'window_name': window_name}) - if not window: - raise exc.TmuxpException('Window not found: %s' % window_name) - elif current_pane is not None: - window = session.find_where({'window_id': current_pane['window_id']}) - else: - window = session.list_windows()[0] - - except exc.TmuxpException as e: - print(e) - return - - try: - if current_pane is not None: - pane = window.find_where({'pane_id': current_pane['pane_id']}) # NOQA: F841 - else: - pane = window.attached_pane # NOQA: F841 - except exc.TmuxpException as e: - print(e) - return - - if command is not None: - exec(command) - else: - from ._compat import breakpoint as tmuxp_breakpoint - - tmuxp_breakpoint() - - -@cli.command(name='shell_plus') -@click.argument('session_name', nargs=1, required=False) -@click.argument('window_name', nargs=1, required=False) -@click.option('-S', 'socket_path', help='pass-through for tmux -S') -@click.option('-L', 'socket_name', help='pass-through for tmux -L') @click.option( - '-c', - 'command', - help='Instead of opening shell, execute python code in libtmux and exit', + '--best', + 'shell', + flag_value='best', + help='Use best shell available in site packages', + default=True, +) +@click.option('--pdb', 'shell', flag_value='pdb', help='Use plain pdb') +@click.option( + '--code', 'shell', flag_value='code', help='Use stdlib\'s code.interact()' ) +@click.option( + '--ptipython', 'shell', flag_value='ptipython', help='Use ptpython + ipython' +) +@click.option('--ptpython', 'shell', flag_value='ptpython', help='Use ptpython') +@click.option('--ipython', 'shell', flag_value='ipython', help='Use ipython') +@click.option('--bpython', 'shell', flag_value='bpython', help='Use bpython') @click.option( '--use-pythonrc/--no-startup', 'use_pythonrc', - help='Load the PYTHONSTARTUP environment variable and ~/.pythonrc.py script.', + help='Load PYTHONSTARTUP env var and ~/.pythonrc.py script in --code', + default=False, +) +@click.option( + '--use-vi-mode/--no-vi-mode', + 'use_vi_mode', + help='Use vi-mode in ptpython/ptipython', default=False, ) -def command_shell_plus( +def command_shell( session_name, window_name, socket_name, socket_path, command, + shell, use_pythonrc, + use_vi_mode, ): - """shell w/ tab completion. + """Launch python shell for tmux server, session, window and pane. - Credits: django-extensions shell_plus.py 51fef74 (MIT License) + Priority given to loaded session/wndow/pane objects: + - session_name and window_name arguments + - current shell: environmental variable of TMUX_PANE (which gives us window and + session) + - ``server.attached_session``, ``session.attached_window``, ``window.attached_pane`` """ server = Server(socket_name=socket_name, socket_path=socket_path) - try: - server.sessions - except LibTmuxException as e: - if 'No such file or directory' in str(e): - raise LibTmuxException( - 'no tmux session found. Start a tmux session and try again. \n' - 'Original error: ' + str(e) - ) - else: - raise e - - current_pane = None - if os.getenv('TMUX_PANE') is not None: - try: - current_pane = [ - p - for p in server._list_panes() - if p.get('pane_id') == os.getenv('TMUX_PANE') - ][0] - except IndexError: - pass + util.raise_if_tmux_not_running(server=server) - try: - if session_name: - session = server.find_where({'session_name': session_name}) - elif current_pane is not None: - session = server.find_where({'session_id': current_pane['session_id']}) - else: - session = server.list_sessions()[0] + current_pane = util.get_current_pane(server=server) - if not session: - raise exc.TmuxpException('Session not found: %s' % session_name) - except exc.TmuxpException as e: - print(e) - return - - try: - if window_name: - window = session.find_where({'window_name': window_name}) - if not window: - raise exc.TmuxpException('Window not found: %s' % window_name) - elif current_pane is not None: - window = session.find_where({'window_id': current_pane['window_id']}) - else: - window = session.list_windows()[0] + session = util.get_session( + server=server, session_name=session_name, current_pane=current_pane + ) - except exc.TmuxpException as e: - print(e) - return + window = util.get_window( + session=session, window_name=window_name, current_pane=current_pane + ) - try: - if current_pane is not None: - pane = window.find_where({'pane_id': current_pane['pane_id']}) # NOQA: F841 - else: - pane = window.attached_pane # NOQA: F841 - except exc.TmuxpException as e: - print(e) - return + pane = util.get_pane(window=window, current_pane=current_pane) # NOQA: F841 if command is not None: exec(command) else: - # Using normal Python shell - import code - - import libtmux - - imported_objects = { - 'libtmux': libtmux, - 'Server': libtmux.Server, - 'Session': libtmux.Session, - 'Window': libtmux.Window, - 'Pane': libtmux.Pane, - 'server': server, - 'session': session, - 'window': window, - 'pane': pane, - } - - try: - # Try activating rlcompleter, because it's handy. - import readline - except ImportError: - pass - else: - # We don't have to wrap the following import in a 'try', because - # we already know 'readline' was imported successfully. - import rlcompleter - - readline.set_completer(rlcompleter.Completer(imported_objects).complete) - # Enable tab completion on systems using libedit (e.g. macOS). - # These lines are copied from Lib/site.py on Python 3.4. - readline_doc = getattr(readline, '__doc__', '') - if readline_doc is not None and 'libedit' in readline_doc: - readline.parse_and_bind("bind ^I rl_complete") - else: - readline.parse_and_bind("tab:complete") + if shell == 'pdb' or (os.getenv('PYTHONBREAKPOINT') and PY3 and PYMINOR >= 7): + from ._compat import breakpoint as tmuxp_breakpoint - # We want to honor both $PYTHONSTARTUP and .pythonrc.py, so follow system - # conventions and get $PYTHONSTARTUP first then .pythonrc.py. - if use_pythonrc: - for pythonrc in set( - [os.environ.get("PYTHONSTARTUP"), os.path.expanduser('~/.pythonrc.py')] - ): - if not pythonrc: - continue - if not os.path.isfile(pythonrc): - continue - with open(pythonrc) as handle: - pythonrc_code = handle.read() - # Match the behavior of the cpython shell where an error in - # PYTHONSTARTUP prints an exception and continues. - try: - exec(compile(pythonrc_code, pythonrc, 'exec'), imported_objects) - except Exception: - import traceback - - traceback.print_exc() - - code.interact(local=imported_objects) + tmuxp_breakpoint() + return + else: + from .shell import launch + + launch( + shell=shell, + use_pythonrc=use_pythonrc, # shell: code + use_vi_mode=use_vi_mode, # shell: ptpython, ptipython + # tmux environment / libtmux variables + server=server, + session=session, + window=window, + pane=pane, + ) @cli.command(name='freeze') diff --git a/tmuxp/shell.py b/tmuxp/shell.py new file mode 100644 index 00000000000..2920b590fdc --- /dev/null +++ b/tmuxp/shell.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- +"""Utility and helper methods for tmuxp. + +tmuxp.shell +~~~~~~~~~~~ + +""" +from __future__ import absolute_import, unicode_literals + +import logging +import os + +logger = logging.getLogger(__name__) + + +def has_ipython(): + try: + from IPython import start_ipython # NOQA F841 + except ImportError: + try: + from IPython.Shell import IPShell # NOQA F841 + except ImportError: + return False + + return True + + +def has_ptpython(): + try: + from ptpython.repl import embed, run_config # NOQA F841 + except ImportError: + try: + from prompt_toolkit.contrib.repl import embed, run_config # NOQA F841 + except ImportError: + return False + + return True + + +def has_ptipython(): + try: + from ptpython.ipython import embed # NOQA F841 + from ptpython.repl import run_config # NOQA F841 + except ImportError: + try: + from prompt_toolkit.contrib.ipython import embed # NOQA F841 + from prompt_toolkit.contrib.repl import run_config # NOQA F841 + except ImportError: + return False + + return True + + +def has_bpython(): + try: + from bpython import embed # NOQA F841 + except ImportError: + return False + return True + + +def detect_best_shell(): + if has_ptipython(): + return 'ptipython' + elif has_ptpython(): + return 'ptpython' + elif has_ipython(): + return 'ipython' + elif has_bpython(): + return 'bpython' + return 'code' + + +def get_bpython(options, extra_args=None): + if extra_args is None: + extra_args = {} + + from bpython import embed # NOQA F841 + + def launch_bpython(): + imported_objects = get_launch_args(**options) + kwargs = {} + if extra_args: + kwargs['args'] = extra_args + embed(imported_objects, **kwargs) + + return launch_bpython + + +def get_ipython_arguments(): + ipython_args = 'IPYTHON_ARGUMENTS' + return os.environ.get(ipython_args, '').split() + + +def get_ipython(options, **extra_args): + try: + from IPython import start_ipython + + def launch_ipython(): + imported_objects = get_launch_args(**options) + ipython_arguments = extra_args or get_ipython_arguments() + start_ipython(argv=ipython_arguments, user_ns=imported_objects) + + return launch_ipython + except ImportError: + # IPython < 0.11 + # Explicitly pass an empty list as arguments, because otherwise + # IPython would use sys.argv from this script. + # Notebook not supported for IPython < 0.11. + from IPython.Shell import IPShell + + def launch_ipython(): + imported_objects = get_launch_args(**options) + shell = IPShell(argv=[], user_ns=imported_objects) + shell.mainloop() + + return launch_ipython + + +def get_ptpython(options, vi_mode=False): + try: + from ptpython.repl import embed, run_config + except ImportError: + from prompt_toolkit.contrib.repl import embed, run_config + + def launch_ptpython(): + imported_objects = get_launch_args(**options) + history_filename = os.path.expanduser('~/.ptpython_history') + embed( + globals=imported_objects, + history_filename=history_filename, + vi_mode=vi_mode, + configure=run_config, + ) + + return launch_ptpython + + +def get_ptipython(options, vi_mode=False): + """Based on django-extensions + + Run renamed to launch, get_imported_objects renamed to get_launch_args + """ + try: + from ptpython.ipython import embed + from ptpython.repl import run_config + except ImportError: + # prompt_toolkit < v0.27 + from prompt_toolkit.contrib.ipython import embed + from prompt_toolkit.contrib.repl import run_config + + def launch_ptipython(): + imported_objects = get_launch_args(**options) + history_filename = os.path.expanduser('~/.ptpython_history') + embed( + user_ns=imported_objects, + history_filename=history_filename, + vi_mode=vi_mode, + configure=run_config, + ) + + return launch_ptipython + + +def get_launch_args(**kwargs): + import libtmux + + return { + 'libtmux': libtmux, + 'Server': libtmux.Server, + 'Session': libtmux.Session, + 'Window': libtmux.Window, + 'Pane': libtmux.Pane, + 'server': kwargs.get('server'), + 'session': kwargs.get('session'), + 'window': kwargs.get('window'), + 'pane': kwargs.get('pane'), + } + + +def get_code(use_pythonrc, imported_objects): + import code + + try: + # Try activating rlcompleter, because it's handy. + import readline + except ImportError: + pass + else: + # We don't have to wrap the following import in a 'try', because + # we already know 'readline' was imported successfully. + import rlcompleter + + readline.set_completer(rlcompleter.Completer(imported_objects).complete) + # Enable tab completion on systems using libedit (e.g. macOS). + # These lines are copied from Lib/site.py on Python 3.4. + readline_doc = getattr(readline, '__doc__', '') + if readline_doc is not None and 'libedit' in readline_doc: + readline.parse_and_bind("bind ^I rl_complete") + else: + readline.parse_and_bind("tab:complete") + + # We want to honor both $PYTHONSTARTUP and .pythonrc.py, so follow system + # conventions and get $PYTHONSTARTUP first then .pythonrc.py. + if use_pythonrc: + for pythonrc in set( + [os.environ.get("PYTHONSTARTUP"), os.path.expanduser('~/.pythonrc.py')] + ): + if not pythonrc: + continue + if not os.path.isfile(pythonrc): + continue + with open(pythonrc) as handle: + pythonrc_code = handle.read() + # Match the behavior of the cpython shell where an error in + # PYTHONSTARTUP prints an exception and continues. + exec(compile(pythonrc_code, pythonrc, 'exec'), imported_objects) + + def launch_code(): + code.interact(local=imported_objects) + + return launch_code + + +def launch(shell='best', use_pythonrc=False, use_vi_mode=False, **kwargs): + # Also allowing passing shell='code' to force using code.interact + imported_objects = get_launch_args(**kwargs) + + if shell == 'best': + shell = detect_best_shell() + + if shell == 'ptipython': + launch = get_ptipython(options=kwargs, vi_mode=use_vi_mode) + elif shell == 'ptpython': + launch = get_ptpython(options=kwargs, vi_mode=use_vi_mode) + elif shell == 'ipython': + launch = get_ipython(options=kwargs) + elif shell == 'bpython': + launch = get_bpython(options=kwargs) + else: + launch = get_code(use_pythonrc=use_pythonrc, imported_objects=imported_objects) + + launch() diff --git a/tmuxp/util.py b/tmuxp/util.py index 5b7f3c73e6f..6cf66b68cec 100644 --- a/tmuxp/util.py +++ b/tmuxp/util.py @@ -13,6 +13,8 @@ import subprocess import sys +from libtmux.exc import LibTmuxException + from . import exc from ._compat import console_to_str @@ -74,3 +76,70 @@ def oh_my_zsh_auto_title(): 'Then create a new shell or type:\n\n' '\t$ source ~/.zshrc' ) + + +def raise_if_tmux_not_running(server): + """Raise exception if not running. More descriptive error if no server found.""" + try: + server.sessions + except LibTmuxException as e: + if 'No such file or directory' in str(e): + raise LibTmuxException( + 'no tmux session found. Start a tmux session and try again. \n' + 'Original error: ' + str(e) + ) + else: + raise e + + +def get_current_pane(server): + """Return Pane if one found in env""" + if os.getenv('TMUX_PANE') is not None: + try: + return [ + p + for p in server._list_panes() + if p.get('pane_id') == os.getenv('TMUX_PANE') + ][0] + except IndexError: + pass + + +def get_session(server, session_name=None, current_pane=None): + if session_name: + session = server.find_where({'session_name': session_name}) + elif current_pane is not None: + session = server.find_where({'session_id': current_pane['session_id']}) + else: + session = server.list_sessions()[0] + + if not session: + raise exc.TmuxpException('Session not found: %s' % session_name) + + return session + + +def get_window(session, window_name=None, current_pane=None): + if window_name: + window = session.find_where({'window_name': window_name}) + if not window: + raise exc.TmuxpException('Window not found: %s' % window_name) + elif current_pane is not None: + window = session.find_where({'window_id': current_pane['window_id']}) + else: + window = session.list_windows()[0] + + return window + + +def get_pane(window, current_pane=None): + try: + if current_pane is not None: + pane = window.find_where({'pane_id': current_pane['pane_id']}) # NOQA: F841 + else: + pane = window.attached_pane # NOQA: F841 + except exc.TmuxpException as e: + print(e) + return + + return pane