Skip to content

Commit 06326ae

Browse files
mlucoolMarc Udoff
andauthored
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 <udoff@deshaw.com>
1 parent abfdcf2 commit 06326ae

File tree

3 files changed

+192
-11
lines changed

3 files changed

+192
-11
lines changed

jupyterlab_git/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,28 @@
22
"""
33
# need this in order to show version in `jupyter serverextension list`
44
from ._version import __version__
5+
from traitlets import List, Dict, Unicode
6+
from traitlets.config import Configurable
57

68
from jupyterlab_git.handlers import setup_handlers
79
from jupyterlab_git.git import Git
810

11+
class JupyterLabGit(Configurable):
12+
"""
13+
Config options for jupyterlab_git
14+
15+
Modeled after: https://github.com/jupyter/jupyter_server/blob/9dd2a9a114c045cfd8fd8748400c6a697041f7fa/jupyter_server/serverapp.py#L1040
16+
"""
17+
18+
actions = Dict(
19+
help='Actions to be taken after a git command. Each action takes a list of commands to execute (strings). Supported actions: post_init',
20+
config=True,
21+
trait=List(
22+
trait=Unicode(),
23+
help='List of commands to run. E.g. ["touch baz.py"]'
24+
)
25+
# TODO Validate
26+
)
927

1028
def _jupyter_server_extension_paths():
1129
"""Declare the Jupyter server extension paths.
@@ -16,6 +34,8 @@ def _jupyter_server_extension_paths():
1634
def load_jupyter_server_extension(nbapp):
1735
"""Load the Jupyter server extension.
1836
"""
19-
git = Git(nbapp.web_app.settings['contents_manager'])
37+
38+
config = JupyterLabGit(config=nbapp.config)
39+
git = Git(nbapp.web_app.settings['contents_manager'], config)
2040
nbapp.web_app.settings["git"] = git
2141
setup_handlers(nbapp.web_app)

jupyterlab_git/git.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44
import os
55
import re
6+
import shlex
67
import subprocess
78
from urllib.parse import unquote
89

@@ -143,9 +144,10 @@ class Git:
143144
A single parent class containing all of the individual git methods in it.
144145
"""
145146

146-
def __init__(self, contents_manager):
147+
def __init__(self, contents_manager, config=None):
147148
self.contents_manager = contents_manager
148149
self.root_dir = os.path.expanduser(contents_manager.root_dir)
150+
self._config = config
149151

150152
async def config(self, top_repo_path, **kwargs):
151153
"""Get or set Git options.
@@ -302,7 +304,7 @@ async def status(self, current_path):
302304
for line in filter(lambda l: len(l) > 0, strip_and_split(text_output)):
303305
diff, name = line.rsplit("\t", maxsplit=1)
304306
are_binary[name] = diff.startswith("-\t-")
305-
307+
306308
result = []
307309
line_iterable = (line for line in strip_and_split(my_output) if line)
308310
for line in line_iterable:
@@ -873,13 +875,50 @@ async def init(self, current_path):
873875
Execute git init command & return the result.
874876
"""
875877
cmd = ["git", "init"]
878+
cwd = os.path.join(self.root_dir, current_path)
876879
code, _, error = await execute(
877-
cmd, cwd=os.path.join(self.root_dir, current_path)
880+
cmd, cwd=cwd
878881
)
879882

883+
actions = None
884+
if code == 0:
885+
code, actions = await self._maybe_run_actions('post_init', cwd)
886+
880887
if code != 0:
881-
return {"code": code, "command": " ".join(cmd), "message": error}
882-
return {"code": code}
888+
return {"code": code, "command": " ".join(cmd), "message": error, "actions": actions}
889+
return {"code": code, "actions": actions}
890+
891+
async def _maybe_run_actions(self, name, cwd):
892+
code = 0
893+
actions = None
894+
if self._config and name in self._config.actions:
895+
actions = []
896+
actions_list = self._config.actions[name]
897+
for action in actions_list:
898+
try:
899+
# We trust the actions as they were passed via a config and not the UI
900+
code, stdout, stderr = await execute(
901+
shlex.split(action), cwd=cwd
902+
)
903+
actions.append({
904+
'cmd': action,
905+
'code': code,
906+
'stdout': stdout,
907+
'stderr': stderr
908+
})
909+
# After any failure, stop
910+
except Exception as e:
911+
code = 1
912+
actions.append({
913+
'cmd': action,
914+
'code': 1,
915+
'stdout': None,
916+
'stderr': 'Exception: {}'.format(e)
917+
})
918+
if code != 0:
919+
break
920+
921+
return code, actions
883922

884923
def _is_remote_branch(self, branch_reference):
885924
"""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):
10661105
10671106
Returns:
10681107
bool: Is file binary?
1069-
1108+
10701109
Raises:
10711110
HTTPError: if git command failed
10721111
"""
@@ -1153,7 +1192,7 @@ async def ignore(self, top_repo_path, file_path):
11531192

11541193
async def version(self):
11551194
"""Return the Git command version.
1156-
1195+
11571196
If an error occurs, return None.
11581197
"""
11591198
command = ["git", "--version"]
@@ -1162,12 +1201,12 @@ async def version(self):
11621201
version = GIT_VERSION_REGEX.match(output)
11631202
if version is not None:
11641203
return version.group('version')
1165-
1204+
11661205
return None
11671206

11681207
async def tags(self, current_path):
11691208
"""List all tags of the git repository.
1170-
1209+
11711210
current_path: str
11721211
Git path repository
11731212
"""
@@ -1180,7 +1219,7 @@ async def tags(self, current_path):
11801219

11811220
async def tag_checkout(self, current_path, tag):
11821221
"""Checkout the git repository at a given tag.
1183-
1222+
11841223
current_path: str
11851224
Git path repository
11861225
tag : str

jupyterlab_git/tests/test_init.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import os
2+
from subprocess import CalledProcessError
3+
from unittest.mock import Mock, call, patch
4+
5+
import pytest
6+
import tornado
7+
8+
from jupyterlab_git import JupyterLabGit
9+
from jupyterlab_git.git import Git
10+
11+
from .testutils import FakeContentManager, maybe_future
12+
13+
14+
@pytest.mark.asyncio
15+
async def test_init():
16+
with patch("jupyterlab_git.git.execute") as mock_execute:
17+
# Given
18+
mock_execute.return_value = maybe_future((0, "", ""))
19+
20+
# When
21+
actual_response = await Git(FakeContentManager("/bin")).init("test_curr_path")
22+
23+
mock_execute.assert_called_once_with(
24+
["git", "init"], cwd=os.path.join("/bin", "test_curr_path")
25+
)
26+
27+
assert {"code": 0, "actions": None} == actual_response
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_init_and_post_init():
32+
with patch("jupyterlab_git.git.execute") as mock_execute:
33+
# Given
34+
mock_execute.side_effect = [
35+
maybe_future((0, "", "")),
36+
maybe_future((0, "hello", "")),
37+
]
38+
39+
# When
40+
actual_response = await Git(
41+
FakeContentManager("/bin"),
42+
JupyterLabGit(actions={"post_init": ['echo "hello"']}),
43+
).init("test_curr_path")
44+
45+
mock_execute.assert_called_with(
46+
["echo", "hello"], cwd=os.path.join("/bin", "test_curr_path")
47+
)
48+
49+
assert {
50+
"code": 0,
51+
"actions": [
52+
{"cmd": 'echo "hello"', "code": 0, "stderr": "", "stdout": "hello"}
53+
],
54+
} == actual_response
55+
56+
57+
@pytest.mark.asyncio
58+
async def test_init_and_post_init_fail():
59+
with patch("jupyterlab_git.git.execute") as mock_execute:
60+
# Given
61+
mock_execute.side_effect = [
62+
maybe_future((0, "", "")),
63+
maybe_future((1, "", "not_there: command not found")),
64+
]
65+
66+
# When
67+
actual_response = await Git(
68+
FakeContentManager("/bin"),
69+
JupyterLabGit(actions={"post_init": ["not_there arg"]}),
70+
).init("test_curr_path")
71+
72+
mock_execute.assert_called_with(
73+
["not_there", "arg"], cwd=os.path.join("/bin", "test_curr_path")
74+
)
75+
76+
assert {
77+
"code": 1,
78+
"message": "",
79+
"command": "git init",
80+
"actions": [
81+
{
82+
"stderr": "not_there: command not found",
83+
"stdout": "",
84+
"code": 1,
85+
"cmd": "not_there arg",
86+
}
87+
],
88+
} == actual_response
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_init_and_post_init_fail_to_run():
93+
with patch("jupyterlab_git.git.execute") as mock_execute:
94+
# Given
95+
mock_execute.side_effect = [
96+
maybe_future((0, "", "")),
97+
Exception("Not a command!"),
98+
]
99+
100+
# When
101+
actual_response = await Git(
102+
FakeContentManager("/bin"),
103+
JupyterLabGit(actions={"post_init": ["not_there arg"]}),
104+
).init("test_curr_path")
105+
106+
mock_execute.assert_called_with(
107+
["not_there", "arg"], cwd=os.path.join("/bin", "test_curr_path")
108+
)
109+
110+
assert {
111+
"code": 1,
112+
"message": "",
113+
"command": "git init",
114+
"actions": [
115+
{
116+
"stderr": "Exception: Not a command!",
117+
"stdout": None,
118+
"code": 1,
119+
"cmd": "not_there arg",
120+
}
121+
],
122+
} == actual_response

0 commit comments

Comments
 (0)