Skip to content

Commit 1c93669

Browse files
authored
Merge pull request #345 from LabShare/feature/simple_auth
Simple Auth support
2 parents 04dc06d + f63c65c commit 1c93669

File tree

12 files changed

+584
-71
lines changed

12 files changed

+584
-71
lines changed

jupyterlab_git/git.py

Lines changed: 116 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,61 @@
44
import os
55
import subprocess
66
from subprocess import Popen, PIPE, CalledProcessError
7-
from urllib.parse import unquote
87

8+
import pexpect
9+
from urllib.parse import unquote
910
from tornado.web import HTTPError
1011

1112

1213
ALLOWED_OPTIONS = ['user.name', 'user.email']
1314

1415

16+
class GitAuthInputWrapper:
17+
"""
18+
Helper class which is meant to replace subprocess.Popen for communicating
19+
with git CLI when also sending username and password for auth
20+
"""
21+
def __init__(self, command, cwd, env, username, password):
22+
self.command = command
23+
self.cwd = cwd
24+
self.env = env
25+
self.username = username
26+
self.password = password
27+
def communicate(self):
28+
try:
29+
p = pexpect.spawn(
30+
self.command,
31+
cwd = self.cwd,
32+
env = self.env
33+
)
34+
35+
# We expect a prompt from git
36+
# In most of cases git will prompt for username and
37+
# then for password
38+
# In some cases (Bitbucket) username is included in
39+
# remote URL, so git will not ask for username
40+
i = p.expect(['Username for .*: ', 'Password for .*:'])
41+
if i==0: #ask for username then password
42+
p.sendline(self.username)
43+
p.expect('Password for .*:')
44+
p.sendline(self.password)
45+
elif i==1: #only ask for password
46+
p.sendline(self.password)
47+
48+
p.expect(pexpect.EOF)
49+
response = p.before
50+
51+
self.returncode = p.wait()
52+
p.close()
53+
54+
return response
55+
except pexpect.exceptions.EOF: #In case of pexpect failure
56+
response = p.before
57+
self.returncode = p.exitstatus
58+
p.close() #close process
59+
return response
60+
61+
1562
class Git:
1663
"""
1764
A single parent class containing all of the individual git methods in it.
@@ -110,23 +157,37 @@ def changed_files(self, base=None, remote=None, single_commit=None):
110157
return response
111158

112159

113-
def clone(self, current_path, repo_url):
160+
def clone(self, current_path, repo_url, auth=None):
114161
"""
115-
Execute `git clone`. Disables prompts for the password to avoid the terminal hanging.
162+
Execute `git clone`.
163+
When no auth is provided, disables prompts for the password to avoid the terminal hanging.
164+
When auth is provided, await prompts for username/passwords and sends them
116165
:param current_path: the directory where the clone will be performed.
117166
:param repo_url: the URL of the repository to be cloned.
167+
:param auth: OPTIONAL dictionary with 'username' and 'password' fields
118168
:return: response with status code and error message.
119169
"""
120170
env = os.environ.copy()
121-
env["GIT_TERMINAL_PROMPT"] = "0"
122-
p = subprocess.Popen(
123-
["git", "clone", unquote(repo_url)],
124-
stdout=PIPE,
125-
stderr=PIPE,
126-
cwd=os.path.join(self.root_dir, current_path),
127-
env=env,
128-
)
129-
_, error = p.communicate()
171+
if (auth):
172+
env["GIT_TERMINAL_PROMPT"] = "1"
173+
p = GitAuthInputWrapper(
174+
command='git clone {} -q'.format(unquote(repo_url)),
175+
cwd=os.path.join(self.root_dir, current_path),
176+
env = env,
177+
username=auth['username'],
178+
password=auth['password'],
179+
)
180+
error = p.communicate()
181+
else:
182+
env["GIT_TERMINAL_PROMPT"] = "0"
183+
p = subprocess.Popen(
184+
['git', 'clone', unquote(repo_url)],
185+
stdout=PIPE,
186+
stderr=PIPE,
187+
env = env,
188+
cwd=os.path.join(self.root_dir, current_path),
189+
)
190+
_, error = p.communicate()
130191

131192
response = {"code": p.returncode}
132193

@@ -544,22 +605,33 @@ def commit(self, commit_msg, top_repo_path):
544605
["git", "commit", "-m", commit_msg], cwd=top_repo_path
545606
)
546607
return my_output
547-
548-
def pull(self, curr_fb_path):
608+
609+
def pull(self, curr_fb_path, auth=None):
549610
"""
550611
Execute git pull --no-commit. Disables prompts for the password to avoid the terminal hanging while waiting
551612
for auth.
552613
"""
553614
env = os.environ.copy()
554-
env["GIT_TERMINAL_PROMPT"] = "0"
555-
p = subprocess.Popen(
556-
["git", "pull", "--no-commit"],
557-
stdout=PIPE,
558-
stderr=PIPE,
559-
cwd=os.path.join(self.root_dir, curr_fb_path),
560-
env=env,
561-
)
562-
_, error = p.communicate()
615+
if (auth):
616+
env["GIT_TERMINAL_PROMPT"] = "1"
617+
p = GitAuthInputWrapper(
618+
command = 'git pull --no-commit',
619+
cwd = os.path.join(self.root_dir, curr_fb_path),
620+
env = env,
621+
username = auth['username'],
622+
password = auth['password']
623+
)
624+
error = p.communicate()
625+
else:
626+
env["GIT_TERMINAL_PROMPT"] = "0"
627+
p = subprocess.Popen(
628+
['git', 'pull', '--no-commit'],
629+
stdout=PIPE,
630+
stderr=PIPE,
631+
env = env,
632+
cwd=os.path.join(self.root_dir, curr_fb_path),
633+
)
634+
_, error = p.communicate()
563635

564636
response = {"code": p.returncode}
565637

@@ -568,20 +640,31 @@ def pull(self, curr_fb_path):
568640

569641
return response
570642

571-
def push(self, remote, branch, curr_fb_path):
643+
def push(self, remote, branch, curr_fb_path, auth=None):
572644
"""
573645
Execute `git push $UPSTREAM $BRANCH`. The choice of upstream and branch is up to the caller.
574646
"""
575647
env = os.environ.copy()
576-
env["GIT_TERMINAL_PROMPT"] = "0"
577-
p = subprocess.Popen(
578-
["git", "push", remote, branch],
579-
stdout=PIPE,
580-
stderr=PIPE,
581-
cwd=os.path.join(self.root_dir, curr_fb_path),
582-
env=env,
583-
)
584-
_, error = p.communicate()
648+
if (auth):
649+
env["GIT_TERMINAL_PROMPT"] = "1"
650+
p = GitAuthInputWrapper(
651+
command = 'git push {} {}'.format(remote, branch),
652+
cwd = os.path.join(self.root_dir, curr_fb_path),
653+
env = env,
654+
username = auth['username'],
655+
password = auth['password']
656+
)
657+
error = p.communicate()
658+
else:
659+
env["GIT_TERMINAL_PROMPT"] = "0"
660+
p = subprocess.Popen(
661+
['git', 'push', remote, branch],
662+
stdout=PIPE,
663+
stderr=PIPE,
664+
env = env,
665+
cwd=os.path.join(self.root_dir, curr_fb_path),
666+
)
667+
_, error = p.communicate()
585668

586669
response = {"code": p.returncode}
587670

jupyterlab_git/handlers.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@ def post(self):
2525
Input format:
2626
{
2727
'current_path': 'current_file_browser_path',
28-
'repo_url': 'https://github.com/path/to/myrepo'
28+
'repo_url': 'https://github.com/path/to/myrepo',
29+
OPTIONAL 'auth': '{ 'username': '<username>',
30+
'password': '<password>'
31+
}'
2932
}
3033
"""
31-
data = json.loads(self.request.body.decode("utf-8"))
32-
response = self.git.clone(data["current_path"], data["clone_url"])
34+
data = json.loads(self.request.body.decode('utf-8'))
35+
response = self.git.clone(data['current_path'], data['clone_url'], data.get('auth', None))
3336
self.finish(json.dumps(response))
3437

3538

@@ -339,8 +342,10 @@ def post(self):
339342
"""
340343
POST request handler, pulls files from a remote branch to your current branch.
341344
"""
342-
output = self.git.pull(self.get_json_body()["current_path"])
343-
self.finish(json.dumps(output))
345+
data = self.get_json_body()
346+
response = self.git.pull(data['current_path'], data.get('auth', None))
347+
348+
self.finish(json.dumps(response))
344349

345350

346351
class GitPushHandler(GitHandler):
@@ -354,7 +359,8 @@ def post(self):
354359
POST request handler,
355360
pushes committed files from your current branch to a remote branch
356361
"""
357-
current_path = self.get_json_body()["current_path"]
362+
data = self.get_json_body()
363+
current_path = data['current_path']
358364

359365
current_local_branch = self.git.get_current_branch(current_path)
360366
current_upstream_branch = self.git.get_upstream_branch(
@@ -372,7 +378,7 @@ def post(self):
372378
remote = upstream[0]
373379
branch = ":".join(["HEAD", upstream[1]])
374380

375-
response = self.git.push(remote, branch, current_path)
381+
response = self.git.push(remote, branch, current_path, data.get('auth', None))
376382

377383
else:
378384
response = {

jupyterlab_git/tests/test_clone.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from mock import patch, call, Mock
44

5-
from jupyterlab_git.git import Git
5+
from jupyterlab_git.git import Git, GitAuthInputWrapper
66

77

88
@patch('subprocess.Popen')
@@ -66,3 +66,109 @@ def test_git_clone_failure_from_git(mock_subproc_popen):
6666
call().communicate()
6767
])
6868
assert {'code': 128, 'message': 'fatal: Not a git repository'} == actual_response
69+
70+
@patch('jupyterlab_git.git.GitAuthInputWrapper')
71+
@patch('os.environ', {'TEST': 'test'})
72+
def test_git_clone_with_auth_success(mock_GitAuthInputWrapper):
73+
# Given
74+
process_mock = Mock()
75+
attrs = {
76+
'communicate.return_value': '',
77+
'returncode': 0
78+
}
79+
process_mock.configure_mock(**attrs)
80+
mock_GitAuthInputWrapper.return_value = process_mock
81+
82+
# When
83+
auth = {
84+
'username' : 'asdf',
85+
'password' : 'qwerty'
86+
}
87+
actual_response = Git(root_dir='/bin').clone(current_path='test_curr_path', repo_url='ghjkhjkl', auth=auth)
88+
89+
# Then
90+
mock_GitAuthInputWrapper.assert_has_calls([
91+
call(
92+
command = 'git clone ghjkhjkl -q',
93+
cwd = '/bin/test_curr_path',
94+
env={'TEST': 'test', 'GIT_TERMINAL_PROMPT': '1'},
95+
username = 'asdf',
96+
password = 'qwerty'
97+
),
98+
call().communicate()
99+
])
100+
assert {'code': 0} == actual_response
101+
102+
@patch('jupyterlab_git.git.GitAuthInputWrapper')
103+
@patch('os.environ', {'TEST': 'test'})
104+
def test_git_clone_with_auth_wrong_repo_url_failure_from_git(mock_GitAuthInputWrapper):
105+
"""
106+
Git internally will throw an error if it is an invalid URL, or if there is a permissions issue. We want to just
107+
relay it back to the user.
108+
109+
"""
110+
# Given
111+
process_mock = Mock()
112+
attrs = {
113+
'communicate.return_value': "fatal: repository 'ghjkhjkl' does not exist".encode('utf-8'),
114+
'returncode': 128
115+
}
116+
process_mock.configure_mock(**attrs)
117+
mock_GitAuthInputWrapper.return_value = process_mock
118+
119+
# When
120+
auth = {
121+
'username' : 'asdf',
122+
'password' : 'qwerty'
123+
}
124+
actual_response = Git(root_dir='/bin').clone(current_path='test_curr_path', repo_url='ghjkhjkl', auth=auth)
125+
126+
# Then
127+
mock_GitAuthInputWrapper.assert_has_calls([
128+
call(
129+
command = 'git clone ghjkhjkl -q',
130+
cwd = '/bin/test_curr_path',
131+
env={'TEST': 'test', 'GIT_TERMINAL_PROMPT': '1'},
132+
username = 'asdf',
133+
password = 'qwerty'
134+
),
135+
call().communicate()
136+
])
137+
assert {'code': 128, 'message': "fatal: repository 'ghjkhjkl' does not exist"} == actual_response
138+
139+
@patch('jupyterlab_git.git.GitAuthInputWrapper')
140+
@patch('os.environ', {'TEST': 'test'})
141+
def test_git_clone_with_auth_auth_failure_from_git(mock_GitAuthInputWrapper):
142+
"""
143+
Git internally will throw an error if it is an invalid URL, or if there is a permissions issue. We want to just
144+
relay it back to the user.
145+
146+
"""
147+
# Given
148+
process_mock = Mock()
149+
attrs = {
150+
'communicate.return_value': "remote: Invalid username or password.\r\nfatal: Authentication failed for 'ghjkhjkl'".encode('utf-8'),
151+
'returncode': 128
152+
}
153+
process_mock.configure_mock(**attrs)
154+
mock_GitAuthInputWrapper.return_value = process_mock
155+
156+
# When
157+
auth = {
158+
'username' : 'asdf',
159+
'password' : 'qwerty'
160+
}
161+
actual_response = Git(root_dir='/bin').clone(current_path='test_curr_path', repo_url='ghjkhjkl', auth=auth)
162+
163+
# Then
164+
mock_GitAuthInputWrapper.assert_has_calls([
165+
call(
166+
command = 'git clone ghjkhjkl -q',
167+
cwd = '/bin/test_curr_path',
168+
env={'TEST': 'test', 'GIT_TERMINAL_PROMPT': '1'},
169+
username = 'asdf',
170+
password = 'qwerty'
171+
),
172+
call().communicate()
173+
])
174+
assert {'code': 128, 'message': "remote: Invalid username or password.\r\nfatal: Authentication failed for 'ghjkhjkl'"} == actual_response

jupyterlab_git/tests/test_handlers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def test_push_handler_localbranch(mock_finish, mock_git):
4747
# Then
4848
mock_git.get_current_branch.assert_called_with('test_path')
4949
mock_git.get_upstream_branch.assert_called_with('test_path', 'foo')
50-
mock_git.push.assert_called_with('.', 'HEAD:localbranch', 'test_path')
50+
mock_git.push.assert_called_with('.', 'HEAD:localbranch', 'test_path', None)
5151
mock_finish.assert_called_with('{"code": 0}')
5252

5353

@@ -67,7 +67,7 @@ def test_push_handler_remotebranch(mock_finish, mock_git):
6767
# Then
6868
mock_git.get_current_branch.assert_called_with('test_path')
6969
mock_git.get_upstream_branch.assert_called_with('test_path', 'foo')
70-
mock_git.push.assert_called_with('origin', 'HEAD:remotebranch', 'test_path')
70+
mock_git.push.assert_called_with('origin', 'HEAD:remotebranch', 'test_path', None)
7171
mock_finish.assert_called_with('{"code": 0}')
7272

7373

0 commit comments

Comments
 (0)