From 06326ae5f76e09546cab232838c1756b6d1b8b6a Mon Sep 17 00:00:00 2001 From: Marc Udoff Date: Wed, 12 Aug 2020 15:18:47 -0400 Subject: [PATCH] Custom actions (#700) * Add ability to have custom actions * Move from config based to string based actions * Review feedback + testing * Test when actions fail to run Co-authored-by: Marc Udoff --- jupyterlab_git/__init__.py | 22 +++++- jupyterlab_git/git.py | 59 ++++++++++++--- jupyterlab_git/tests/test_init.py | 122 ++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 jupyterlab_git/tests/test_init.py diff --git a/jupyterlab_git/__init__.py b/jupyterlab_git/__init__.py index 64a32ccab..adc977a2e 100644 --- a/jupyterlab_git/__init__.py +++ b/jupyterlab_git/__init__.py @@ -2,10 +2,28 @@ """ # need this in order to show version in `jupyter serverextension list` from ._version import __version__ +from traitlets import List, Dict, Unicode +from traitlets.config import Configurable from jupyterlab_git.handlers import setup_handlers from jupyterlab_git.git import Git +class JupyterLabGit(Configurable): + """ + Config options for jupyterlab_git + + Modeled after: https://github.com/jupyter/jupyter_server/blob/9dd2a9a114c045cfd8fd8748400c6a697041f7fa/jupyter_server/serverapp.py#L1040 + """ + + actions = Dict( + help='Actions to be taken after a git command. Each action takes a list of commands to execute (strings). Supported actions: post_init', + config=True, + trait=List( + trait=Unicode(), + help='List of commands to run. E.g. ["touch baz.py"]' + ) + # TODO Validate + ) def _jupyter_server_extension_paths(): """Declare the Jupyter server extension paths. @@ -16,6 +34,8 @@ def _jupyter_server_extension_paths(): def load_jupyter_server_extension(nbapp): """Load the Jupyter server extension. """ - git = Git(nbapp.web_app.settings['contents_manager']) + + config = JupyterLabGit(config=nbapp.config) + git = Git(nbapp.web_app.settings['contents_manager'], config) nbapp.web_app.settings["git"] = git setup_handlers(nbapp.web_app) diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index bf0892866..4ad8f14ff 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -3,6 +3,7 @@ """ import os import re +import shlex import subprocess from urllib.parse import unquote @@ -143,9 +144,10 @@ class Git: A single parent class containing all of the individual git methods in it. """ - def __init__(self, contents_manager): + def __init__(self, contents_manager, config=None): self.contents_manager = contents_manager self.root_dir = os.path.expanduser(contents_manager.root_dir) + self._config = config async def config(self, top_repo_path, **kwargs): """Get or set Git options. @@ -302,7 +304,7 @@ async def status(self, current_path): for line in filter(lambda l: len(l) > 0, strip_and_split(text_output)): diff, name = line.rsplit("\t", maxsplit=1) are_binary[name] = diff.startswith("-\t-") - + result = [] line_iterable = (line for line in strip_and_split(my_output) if line) for line in line_iterable: @@ -873,13 +875,50 @@ async def init(self, current_path): Execute git init command & return the result. """ cmd = ["git", "init"] + cwd = os.path.join(self.root_dir, current_path) code, _, error = await execute( - cmd, cwd=os.path.join(self.root_dir, current_path) + cmd, cwd=cwd ) + actions = None + if code == 0: + code, actions = await self._maybe_run_actions('post_init', cwd) + if code != 0: - return {"code": code, "command": " ".join(cmd), "message": error} - return {"code": code} + return {"code": code, "command": " ".join(cmd), "message": error, "actions": actions} + return {"code": code, "actions": actions} + + async def _maybe_run_actions(self, name, cwd): + code = 0 + actions = None + if self._config and name in self._config.actions: + actions = [] + actions_list = self._config.actions[name] + for action in actions_list: + try: + # We trust the actions as they were passed via a config and not the UI + code, stdout, stderr = await execute( + shlex.split(action), cwd=cwd + ) + actions.append({ + 'cmd': action, + 'code': code, + 'stdout': stdout, + 'stderr': stderr + }) + # After any failure, stop + except Exception as e: + code = 1 + actions.append({ + 'cmd': action, + 'code': 1, + 'stdout': None, + 'stderr': 'Exception: {}'.format(e) + }) + if code != 0: + break + + return code, actions def _is_remote_branch(self, branch_reference): """Check if given branch is remote branch by comparing with 'remotes/', @@ -1066,7 +1105,7 @@ async def _is_binary(self, filename, ref, top_repo_path): Returns: bool: Is file binary? - + Raises: HTTPError: if git command failed """ @@ -1153,7 +1192,7 @@ async def ignore(self, top_repo_path, file_path): async def version(self): """Return the Git command version. - + If an error occurs, return None. """ command = ["git", "--version"] @@ -1162,12 +1201,12 @@ async def version(self): version = GIT_VERSION_REGEX.match(output) if version is not None: return version.group('version') - + return None async def tags(self, current_path): """List all tags of the git repository. - + current_path: str Git path repository """ @@ -1180,7 +1219,7 @@ async def tags(self, current_path): async def tag_checkout(self, current_path, tag): """Checkout the git repository at a given tag. - + current_path: str Git path repository tag : str diff --git a/jupyterlab_git/tests/test_init.py b/jupyterlab_git/tests/test_init.py new file mode 100644 index 000000000..e3788178d --- /dev/null +++ b/jupyterlab_git/tests/test_init.py @@ -0,0 +1,122 @@ +import os +from subprocess import CalledProcessError +from unittest.mock import Mock, call, patch + +import pytest +import tornado + +from jupyterlab_git import JupyterLabGit +from jupyterlab_git.git import Git + +from .testutils import FakeContentManager, maybe_future + + +@pytest.mark.asyncio +async def test_init(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + mock_execute.return_value = maybe_future((0, "", "")) + + # When + actual_response = await Git(FakeContentManager("/bin")).init("test_curr_path") + + mock_execute.assert_called_once_with( + ["git", "init"], cwd=os.path.join("/bin", "test_curr_path") + ) + + assert {"code": 0, "actions": None} == actual_response + + +@pytest.mark.asyncio +async def test_init_and_post_init(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + mock_execute.side_effect = [ + maybe_future((0, "", "")), + maybe_future((0, "hello", "")), + ] + + # When + actual_response = await Git( + FakeContentManager("/bin"), + JupyterLabGit(actions={"post_init": ['echo "hello"']}), + ).init("test_curr_path") + + mock_execute.assert_called_with( + ["echo", "hello"], cwd=os.path.join("/bin", "test_curr_path") + ) + + assert { + "code": 0, + "actions": [ + {"cmd": 'echo "hello"', "code": 0, "stderr": "", "stdout": "hello"} + ], + } == actual_response + + +@pytest.mark.asyncio +async def test_init_and_post_init_fail(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + mock_execute.side_effect = [ + maybe_future((0, "", "")), + maybe_future((1, "", "not_there: command not found")), + ] + + # When + actual_response = await Git( + FakeContentManager("/bin"), + JupyterLabGit(actions={"post_init": ["not_there arg"]}), + ).init("test_curr_path") + + mock_execute.assert_called_with( + ["not_there", "arg"], cwd=os.path.join("/bin", "test_curr_path") + ) + + assert { + "code": 1, + "message": "", + "command": "git init", + "actions": [ + { + "stderr": "not_there: command not found", + "stdout": "", + "code": 1, + "cmd": "not_there arg", + } + ], + } == actual_response + + +@pytest.mark.asyncio +async def test_init_and_post_init_fail_to_run(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + mock_execute.side_effect = [ + maybe_future((0, "", "")), + Exception("Not a command!"), + ] + + # When + actual_response = await Git( + FakeContentManager("/bin"), + JupyterLabGit(actions={"post_init": ["not_there arg"]}), + ).init("test_curr_path") + + mock_execute.assert_called_with( + ["not_there", "arg"], cwd=os.path.join("/bin", "test_curr_path") + ) + + assert { + "code": 1, + "message": "", + "command": "git init", + "actions": [ + { + "stderr": "Exception: Not a command!", + "stdout": None, + "code": 1, + "cmd": "not_there arg", + } + ], + } == actual_response