Skip to content

Commit

Permalink
Custom actions (#700)
Browse files Browse the repository at this point in the history
* 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 <udoff@deshaw.com>
  • Loading branch information
mlucool and Marc Udoff authored Aug 12, 2020
1 parent abfdcf2 commit 06326ae
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 11 deletions.
22 changes: 21 additions & 1 deletion jupyterlab_git/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
59 changes: 49 additions & 10 deletions jupyterlab_git/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""
import os
import re
import shlex
import subprocess
from urllib.parse import unquote

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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/',
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -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"]
Expand All @@ -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
"""
Expand All @@ -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
Expand Down
122 changes: 122 additions & 0 deletions jupyterlab_git/tests/test_init.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 06326ae

Please sign in to comment.