Skip to content

Commit 9049a84

Browse files
authored
Support conda envs without changeps1 (#125)
Most people have conda add the current env name to PS1. This breaks the parsing of PS1, and in particular prevents bash_kernel doing `echo $?` to find if the previous command succeeded. This change makes the PS1 expect string a regex that allows for the `(envname) ` which conda adds.
1 parent d844f95 commit 9049a84

File tree

1 file changed

+40
-15
lines changed

1 file changed

+40
-15
lines changed

bash_kernel/kernel.py

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from subprocess import check_output
66
import os.path
77
import uuid
8+
import random
9+
import string
810

911
import re
1012
import signal
@@ -25,23 +27,39 @@ class IREPLWrapper(replwrap.REPLWrapper):
2527
:param line_output_callback: a callback method to receive each batch
2628
of incremental output. It takes one string parameter.
2729
"""
28-
def __init__(self, cmd_or_spawn, orig_prompt, prompt_change,
30+
def __init__(self, cmd_or_spawn, orig_prompt, prompt_change, unique_prompt,
2931
extra_init_cmd=None, line_output_callback=None):
32+
self.unique_prompt = unique_prompt
3033
self.line_output_callback = line_output_callback
34+
# The extra regex at the start of PS1 below is designed to catch the
35+
# `(envname) ` which conda/mamba add to the start of PS1 by default.
36+
# Obviously anything else that looks like this, including user output,
37+
# will be eaten.
38+
# FIXME: work out if there is a way to update these by reading PS1
39+
# after each command and checking that it has changed. The answer is
40+
# probably no, as we never see individual commands but rather cells
41+
# with possibly many commands, and would need to update this half-way
42+
# through a cell.
43+
self.ps1_re = r"(\(\w+\) )?" + re.escape(self.unique_prompt + ">")
44+
self.ps2_re = re.escape(self.unique_prompt + "+")
3145
replwrap.REPLWrapper.__init__(self, cmd_or_spawn, orig_prompt,
32-
prompt_change, extra_init_cmd=extra_init_cmd)
46+
prompt_change, new_prompt=self.ps1_re,
47+
continuation_prompt=self.ps2_re, extra_init_cmd=extra_init_cmd)
3348

3449
def _expect_prompt(self, timeout=-1):
50+
prompts = [self.ps1_re, self.ps2_re]
51+
3552
if timeout == None:
3653
# "None" means we are executing code from a Jupyter cell by way of the run_command
37-
# in the do_execute() code below, so do incremental output.
54+
# in the do_execute() code below, so do incremental output, i.e.
55+
# also look for end of line or carridge return
56+
prompts.extend(['\r?\n', '\r'])
3857
while True:
39-
pos = self.child.expect_exact([self.prompt, self.continuation_prompt, u'\r\n', u'\n', u'\r'],
40-
timeout=None)
41-
if pos == 2 or pos == 3:
58+
pos = self.child.expect_list([re.compile(x) for x in prompts], timeout=None)
59+
if pos == 2:
4260
# End of line received.
4361
self.line_output_callback(self.child.before + '\n')
44-
elif pos == 4:
62+
elif pos == 3:
4563
# Carriage return ('\r') received.
4664
self.line_output_callback(self.child.before + '\r')
4765
else:
@@ -50,8 +68,8 @@ def _expect_prompt(self, timeout=-1):
5068
self.line_output_callback(self.child.before)
5169
break
5270
else:
53-
# Otherwise, use existing non-incremental code
54-
pos = replwrap.REPLWrapper._expect_prompt(self, timeout=timeout)
71+
# Otherwise, wait (with timeout) until the next prompt
72+
pos = self.child.expect_list([re.compile(x) for x in prompts], timeout=timeout)
5573

5674
# Prompt received, so return normally
5775
return pos
@@ -79,6 +97,9 @@ def banner(self):
7997
'file_extension': '.sh'}
8098

8199
def __init__(self, **kwargs):
100+
# Make a random prompt, further reducing chances of accidental matches.
101+
rand = ''.join(random.choices(string.ascii_uppercase, k=12))
102+
self.unique_prompt = "PROMPT_" + rand
82103
Kernel.__init__(self, **kwargs)
83104
self._start_bash()
84105
self._known_display_ids = set()
@@ -97,12 +118,16 @@ def _start_bash(self):
97118
bashrc = os.path.join(os.path.dirname(pexpect.__file__), 'bashrc.sh')
98119
child = pexpect.spawn("bash", ['--rcfile', bashrc], echo=False,
99120
encoding='utf-8', codec_errors='replace')
100-
ps1 = replwrap.PEXPECT_PROMPT[:5] + u'\[\]' + replwrap.PEXPECT_PROMPT[5:]
101-
ps2 = replwrap.PEXPECT_CONTINUATION_PROMPT[:5] + u'\[\]' + replwrap.PEXPECT_CONTINUATION_PROMPT[5:]
121+
# Following comment stolen from upstream's REPLWrap:
122+
# If the user runs 'env', the value of PS1 will be in the output. To avoid
123+
# replwrap seeing that as the next prompt, we'll embed the marker characters
124+
# for invisible characters in the prompt; these show up when inspecting the
125+
# environment variable, but not when bash displays the prompt.
126+
ps1 = self.unique_prompt + u'\[\]' + ">"
127+
ps2 = self.unique_prompt + u'\[\]' + "+"
102128
prompt_change = u"PS1='{0}' PS2='{1}' PROMPT_COMMAND=''".format(ps1, ps2)
103-
104129
# Using IREPLWrapper to get incremental output
105-
self.bashwrapper = IREPLWrapper(child, u'\$', prompt_change,
130+
self.bashwrapper = IREPLWrapper(child, u'\$', prompt_change, self.unique_prompt,
106131
extra_init_cmd="export PAGER=cat",
107132
line_output_callback=self.process_output)
108133
finally:
@@ -182,8 +207,8 @@ def do_execute(self, code, silent, store_history=True,
182207
return {'status': 'abort', 'execution_count': self.execution_count}
183208

184209
try:
185-
exitcode = int(self.bashwrapper.run_command('echo $?').rstrip())
186-
except Exception:
210+
exitcode = int(self.bashwrapper.run_command('echo $?').rstrip().split("\r\n")[0])
211+
except Exception as exc:
187212
exitcode = 1
188213

189214
if exitcode:

0 commit comments

Comments
 (0)