diff --git a/CHANGES b/CHANGES index bd6a000997e..85051dfb536 100644 --- a/CHANGES +++ b/CHANGES @@ -7,9 +7,42 @@ Here you can find the recent changes to tmuxp current ------- - *Insert changes/features/fixes for next release here* +- :issue:`636` New command: ``tmuxp shell`` + + Automatically preloads session, window, and pane via `libtmux`_ + api objects and makes them available in a python console. + + .. image:: _static/tmuxp-shell.gif + :width: 100% + + In python 3.7+, supports ``PYTHONBREAKPOINT``: + + .. code-block:: sh + + $ pip install ipdb + $ env PYTHONBREAKPOINT=ipdb.set_trace tmuxp shell + + You can execute python directly via ``-c``: + + .. code-block:: sh + + $ tmuxp shell -c 'print(session.name); print(window.name)' + my_server + my_window + + $ tmuxp shell my_server -c 'print(session.name); print(window.name)' + my_server + my_window + + $ tmuxp shell my_server my_window -c 'print(session.name); print(window.name)' + my_server + my_window + + $ tmuxp shell my_server my_window -c 'print(window.name.upper())' + MY_WINDOW tmuxp 1.5.8 (2020-10-31) ------------------------ +------------------------ - :issue:`639` Passes start_directory through to new tmux session Fixes :issue:`631`, thank you @joseph-flinn! diff --git a/README.rst b/README.rst index 350ea8905e7..5a7fc26d029 100644 --- a/README.rst +++ b/README.rst @@ -82,6 +82,50 @@ Load your tmuxp config from anywhere by using the filename, assuming See `author's tmuxp configs`_ and the projects' `tmuxp.yaml`_. +Shell +----- +*New in 1.6.0*: + +``tmuxp shell`` launches into a python console preloaded with the attached server, +session, and window in `libtmux`_ objects. + +.. code-block:: shell + + $ tmuxp shell + + (Pdb) server + + (Pdb) server.sessions + [Session($1 your_project)] + (Pdb) session + Session($1 your_project) + (Pdb) session.name + 'your_project' + (Pdb) window + Window(@3 1:your_window, Session($1 your_project)) + (Pdb) window.name + 'your_window' + (Pdb) window.panes + [Pane(%6 Window(@3 1:your_window, Session($1 your_project))) + (Pdb) pane + Pane(%6 Window(@3 1:your_window, Session($1 your_project)) + +Python 3.7+ supports `PEP 553`_ ``breakpoint()`` (including +``PYTHONBREAKPOINT``). Also supports direct commands via ``-c``: + +.. code-block:: shell + + $ tmuxp shell -c 'print(window.name)' + my_window + + $ tmuxp shell -c 'print(window.name.upper())' + MY_WINDOW + +Read more on `tmuxp shell`_ in the CLI docs. + +.. _PEP 553: https://www.python.org/dev/peps/pep-0553/ +.. _tmuxp shell: http://localhost:8031/cli.html#shell + Pre-load hook ------------- Run custom startup scripts (such as installing project dependencies before diff --git a/docs/_static/tmuxp-shell.gif b/docs/_static/tmuxp-shell.gif new file mode 100644 index 00000000000..26f0d36c034 Binary files /dev/null and b/docs/_static/tmuxp-shell.gif differ diff --git a/docs/cli.rst b/docs/cli.rst index ecad270da81..7a0dd8a9e10 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -22,6 +22,85 @@ In zsh (``~/.zshrc``): eval "$(_TMUXP_COMPLETE=source_zsh tmuxp)" +.. _cli_shell: + +Shell +----- + +:: + + tmuxp shell + + tmuxp shell + + tmuxp shell + + tmuxp shell -c 'python code' + +Launch into a python console with `libtmux`_ objects. Compare to django's shell. + + .. image:: _static/tmuxp-shell.gif + :width: 100% + +Automatically preloads current tmux :class:`server `, +:class:`session `, :class:`window ` +:class:`pane `. Pass additional arguments to select a +specific one of your choice:: + + (Pdb) server + + (Pdb) server.sessions + [Session($1 your_project)] + (Pdb) session + Session($1 your_project) + (Pdb) session.name + 'your_project' + (Pdb) window + Window(@3 1:your_window, Session($1 your_project)) + (Pdb) window.name + 'your_window' + (Pdb) window.panes + [Pane(%6 Window(@3 1:your_window, Session($1 your_project))) + (Pdb) pane + Pane(%6 Window(@3 1:your_window, Session($1 your_project))) + +Python 3.7 supports `PEP 553`_'s ``PYTHONBREAKPOINT`` and supports +compatible debuggers, for instance `ipdb`_: + +.. code-block:: sh + + $ pip install ipdb + $ env PYTHONBREAKPOINT=ipdb.set_trace tmuxp shell + +You can also pass in python code directly, similar to ``python -c``, do +this via ``tmuxp -c``: + +.. code-block:: shell + + $ tmuxp shell -c 'print(session.name); print(window.name)' + my_server + my_window + + $ tmuxp shell my_server -c 'print(session.name); print(window.name)' + my_server + my_window + + $ tmuxp shell my_server my_window -c 'print(session.name); print(window.name)' + my_server + my_window + + $ tmuxp shell my_server my_window -c 'print(window.name.upper())' + MY_WINDOW + + # Assuming inside a tmux pane or one is attached on default server + $ tmuxp shell -c 'print(pane.id); print(pane.window.name)' + %2 + my_window + +.. _PEP 553: https://www.python.org/dev/peps/pep-0553/ +.. _ipdb: https://pypi.org/project/ipdb/ +.. _libtmux: https://libtmux.git-pull.com + .. _cli_freeze: Freeze sessions diff --git a/tests/conftest.py b/tests/conftest.py index eb2d34db827..7ed9c2d2f9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,9 +12,14 @@ @pytest.fixture(scope='function') -def server(request): +def socket_name(request): + return 'tmuxp_test%s' % next(namer) + + +@pytest.fixture(scope='function') +def server(request, socket_name): t = Server() - t.socket_name = 'tmuxp_test%s' % next(namer) + t.socket_name = socket_name def fin(): t.kill_server() diff --git a/tests/test_cli.py b/tests/test_cli.py index e966a4d849f..09814fbe5b3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ import libtmux from libtmux.common import has_lt_version +from libtmux.exc import LibTmuxException from tmuxp import cli, config from tmuxp.cli import ( command_ls, @@ -406,6 +407,142 @@ def test_load_zsh_autotitle_warning(cli_args, tmpdir, monkeypatch): assert 'Please set' not in result.output +@pytest.mark.parametrize( + "cli_args,inputs,env,expected_output", + [ + ( + ['shell', '-L{SOCKET_NAME}', '-c', 'print(str(server.socket_name))'], + [], + {}, + '{SERVER_SOCKET_NAME}', + ), + ( + [ + 'shell', + '-L{SOCKET_NAME}', + '{SESSION_NAME}', + '-c', + 'print(session.name)', + ], + [], + {}, + '{SESSION_NAME}', + ), + ( + [ + 'shell', + '-L{SOCKET_NAME}', + '{SESSION_NAME}', + '{WINDOW_NAME}', + '-c', + 'print(server.has_session(session.name))', + ], + [], + {}, + 'True', + ), + ( + [ + 'shell', + '-L{SOCKET_NAME}', + '{SESSION_NAME}', + '{WINDOW_NAME}', + '-c', + 'print(window.name)', + ], + [], + {}, + '{WINDOW_NAME}', + ), + ( + [ + 'shell', + '-L{SOCKET_NAME}', + '{SESSION_NAME}', + '{WINDOW_NAME}', + '-c', + 'print(pane.id)', + ], + [], + {}, + '{PANE_ID}', + ), + ( + [ + 'shell', + '-L{SOCKET_NAME}', + '-c', + 'print(pane.id)', + ], + [], + {'TMUX_PANE': '{PANE_ID}'}, + '{PANE_ID}', + ), + ], +) +def test_shell( + cli_args, inputs, expected_output, env, tmpdir, monkeypatch, server, session +): + monkeypatch.setenv('HOME', str(tmpdir)) + window_name = 'my_window' + window = session.new_window(window_name=window_name) + window.split_window() + + template_ctx = dict( + SOCKET_NAME=server.socket_name, + SOCKET_PATH=server.socket_path, + SESSION_NAME=session.name, + WINDOW_NAME=window_name, + PANE_ID=window.attached_pane.id, + SERVER_SOCKET_NAME=server.socket_name, + ) + + cli_args[:] = [cli_arg.format(**template_ctx) for cli_arg in cli_args] + for k, v in env.items(): + monkeypatch.setenv(k, v.format(**template_ctx)) + + with tmpdir.as_cwd(): + runner = CliRunner() + + result = runner.invoke( + cli.cli, cli_args, input=''.join(inputs), catch_exceptions=False + ) + assert expected_output.format(**template_ctx) in result.output + + +@pytest.mark.parametrize( + "cli_args,inputs,env,exception, message", + [ + ( + ['shell', '-L{SOCKET_NAME}', '-c', 'print(str(server.socket_name))'], + [], + {}, + LibTmuxException, + r'.*{SOCKET_NAME}\s\(No such file or directory\).*', + ), + ], +) +def test_shell_no_server( + cli_args, inputs, env, exception, message, tmpdir, monkeypatch, socket_name +): + monkeypatch.setenv('HOME', str(tmpdir)) + template_ctx = dict( + SOCKET_NAME=socket_name, + ) + + cli_args[:] = [cli_arg.format(**template_ctx) for cli_arg in cli_args] + for k, v in env.items(): + monkeypatch.setenv(k, v.format(**template_ctx)) + + with tmpdir.as_cwd(): + runner = CliRunner() + + with pytest.raises(exception, match=message.format(**template_ctx)): + runner.invoke( + cli.cli, cli_args, input=''.join(inputs), catch_exceptions=False + ) + + @pytest.mark.parametrize( "cli_args", [ diff --git a/tmuxp/_compat.py b/tmuxp/_compat.py index 36f5bc19eec..b4b1298686d 100644 --- a/tmuxp/_compat.py +++ b/tmuxp/_compat.py @@ -3,10 +3,21 @@ import sys PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 +PYMINOR = sys.version_info[1] +PYPATCH = sys.version_info[2] _identity = lambda x: x +if PY3 and PYMINOR >= 7: + breakpoint = breakpoint +else: + import pdb + + breakpoint = pdb.set_trace + + if PY2: unichr = unichr text_type = unicode diff --git a/tmuxp/cli.py b/tmuxp/cli.py index aa518bbd738..2828c8a6f54 100644 --- a/tmuxp/cli.py +++ b/tmuxp/cli.py @@ -16,7 +16,7 @@ from click.exceptions import FileError from libtmux.common import has_gte_version, has_minimum_version, which -from libtmux.exc import TmuxCommandNotFound +from libtmux.exc import LibTmuxException, TmuxCommandNotFound from libtmux.server import Server from . import config, exc, log, util @@ -661,6 +661,94 @@ def startup(config_dir): os.makedirs(config_dir) +@cli.command(name='shell') +@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', +) +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='freeze') @click.argument('session_name', nargs=1, required=False) @click.option('-S', 'socket_path', help='pass-through for tmux -S')