Skip to content

Commit fc7d8b3

Browse files
committed
Integrate width awareness
update display_linize, add number_of_padding_chars_on_current_cursor_line so cursor is correct with wrapped fullwidth chars
1 parent 8e25e01 commit fc7d8b3

15 files changed

+432
-243
lines changed

CHANGELOG

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@ Changelog
55
----
66

77
New features:
8+
* #802: Provide redo.
9+
Thanks to Evan.
810

911
Fixes:
12+
* #806: Prevent symbolic link loops in import completion.
13+
Thanks to Etienne Richart.
14+
* #807: Support packages using importlib.metadata API.
15+
Thanks to uriariel.
16+
* #817: Fix cursor position with full-width characters.
17+
Thanks to Jack Rybarczyk.
1018

1119
0.19
1220
----

bpython/autocomplete.py

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -340,9 +340,6 @@ def attr_matches(self, text, namespace):
340340
"""Taken from rlcompleter.py and bent to my will.
341341
"""
342342

343-
# Gna, Py 2.6's rlcompleter searches for __call__ inside the
344-
# instance instead of the type, so we monkeypatch to prevent
345-
# side-effects (__getattr__/__getattribute__)
346343
m = self.attr_matches_re.match(text)
347344
if not m:
348345
return []
@@ -356,19 +353,17 @@ def attr_matches(self, text, namespace):
356353
obj = safe_eval(expr, namespace)
357354
except EvaluationError:
358355
return []
359-
with inspection.AttrCleaner(obj):
360-
matches = self.attr_lookup(obj, expr, attr)
356+
matches = self.attr_lookup(obj, expr, attr)
361357
return matches
362358

363359
def attr_lookup(self, obj, expr, attr):
364-
"""Second half of original attr_matches method factored out so it can
365-
be wrapped in a safe try/finally block in case anything bad happens to
366-
restore the original __getattribute__ method."""
360+
"""Second half of attr_matches."""
367361
words = self.list_attributes(obj)
368-
if hasattr(obj, "__class__"):
362+
if inspection.hasattr_safe(obj, "__class__"):
369363
words.append("__class__")
370-
words = words + rlcompleter.get_class_members(obj.__class__)
371-
if not isinstance(obj.__class__, abc.ABCMeta):
364+
klass = inspection.getattr_safe(obj, "__class__")
365+
words = words + rlcompleter.get_class_members(klass)
366+
if not isinstance(klass, abc.ABCMeta):
372367
try:
373368
words.remove("__abstractmethods__")
374369
except ValueError:
@@ -388,21 +383,25 @@ def attr_lookup(self, obj, expr, attr):
388383
if py3:
389384

390385
def list_attributes(self, obj):
391-
return dir(obj)
386+
# TODO: re-implement dir using getattr_static to avoid using
387+
# AttrCleaner here?
388+
with inspection.AttrCleaner(obj):
389+
return dir(obj)
392390

393391
else:
394392

395393
def list_attributes(self, obj):
396-
if isinstance(obj, InstanceType):
397-
try:
394+
with inspection.AttrCleaner(obj):
395+
if isinstance(obj, InstanceType):
396+
try:
397+
return dir(obj)
398+
except Exception:
399+
# This is a case where we can not prevent user code from
400+
# running. We return a default list attributes on error
401+
# instead. (#536)
402+
return ["__doc__", "__module__"]
403+
else:
398404
return dir(obj)
399-
except Exception:
400-
# This is a case where we can not prevent user code from
401-
# running. We return a default list attributes on error
402-
# instead. (#536)
403-
return ["__doc__", "__module__"]
404-
else:
405-
return dir(obj)
406405

407406

408407
class DictKeyCompletion(BaseCompletionType):
@@ -537,10 +536,9 @@ def matches(self, cursor_offset, line, **kwargs):
537536
obj = evaluate_current_expression(cursor_offset, line, locals_)
538537
except EvaluationError:
539538
return set()
540-
with inspection.AttrCleaner(obj):
541-
# strips leading dot
542-
matches = [m[1:] for m in self.attr_lookup(obj, "", attr.word)]
543539

540+
# strips leading dot
541+
matches = [m[1:] for m in self.attr_lookup(obj, "", attr.word)]
544542
return set(m for m in matches if few_enough_underscores(attr.word, m))
545543

546544

@@ -679,7 +677,6 @@ def get_completer_bpython(cursor_offset, line, **kwargs):
679677

680678
def _callable_postfix(value, word):
681679
"""rlcompleter's _callable_postfix done right."""
682-
with inspection.AttrCleaner(value):
683-
if inspection.is_callable(value):
684-
word += "("
680+
if inspection.is_callable(value):
681+
word += "("
685682
return word

bpython/curtsiesfrontend/repl.py

Lines changed: 83 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
import bpython
3232
from bpython.repl import Repl as BpythonRepl, SourceNotFound
33+
from bpython.repl import LineTypeTranslator as LineType
3334
from bpython.config import (
3435
Struct,
3536
loadini,
@@ -278,14 +279,26 @@ def __init__(self, watcher, old_meta_path):
278279

279280
def find_distributions(self, context):
280281
for finder in self.old_meta_path:
281-
distribution_finder = getattr(finder, 'find_distributions', None)
282+
distribution_finder = getattr(finder, "find_distributions", None)
282283
if distribution_finder is not None:
283284
loader = finder.find_distributions(context)
284285
if loader is not None:
285286
return loader
286287

287288
return None
288289

290+
def find_spec(self, fullname, path, target=None):
291+
for finder in self.old_meta_path:
292+
# Consider the finder only if it implements find_spec
293+
if not getattr(finder, "find_spec", None):
294+
continue
295+
# Attempt to find the spec
296+
spec = finder.find_spec(fullname, path, target)
297+
if spec is not None:
298+
# Patch the loader to enable reloading
299+
spec.__loader__ = ImportLoader(self.watcher, spec.__loader__)
300+
return spec
301+
289302
def find_module(self, fullname, path=None):
290303
for finder in self.old_meta_path:
291304
loader = finder.find_module(fullname, path)
@@ -389,20 +402,28 @@ def __init__(
389402
# so we're just using the same object
390403
self.interact = self.status_bar
391404

392-
# line currently being edited, without ps1 (usually '>>> ')
405+
# logical line currently being edited, without ps1 (usually '>>> ')
393406
self._current_line = ""
394407

395408
# current line of output - stdout and stdin go here
396409
self.current_stdouterr_line = ""
397410

398-
# lines separated whenever logical line
399-
# length goes over what the terminal width
400-
# was at the time of original output
411+
# this is every line that's been displayed (input and output)
412+
# as with formatting applied. Logical lines that exceeded the terminal width
413+
# at the time of output are split across multiple entries in this list.
401414
self.display_lines = []
402415

403416
# this is every line that's been executed; it gets smaller on rewind
404417
self.history = []
405418

419+
# This is every logical line that's been displayed, both input and output.
420+
# Like self.history, lines are unwrapped, uncolored, and without prompt.
421+
# Entries are tuples, where
422+
# - the first element the line (string, not fmtsr)
423+
# - the second element is one of 2 global constants: "input" or "output"
424+
# (use LineType.INPUT or LineType.OUTPUT to avoid typing these strings)
425+
self.all_logical_lines = []
426+
406427
# formatted version of lines in the buffer kept around so we can
407428
# unhighlight parens using self.reprint_line as called by bpython.Repl
408429
self.display_buffer = []
@@ -411,7 +432,8 @@ def __init__(
411432
# because there wasn't room to display everything
412433
self.scroll_offset = 0
413434

414-
# from the left, 0 means first char
435+
436+
# cursor position relative to start of current_line, 0 is first char
415437
self._cursor_offset = 0
416438

417439
self.orig_tcattrs = orig_tcattrs
@@ -723,8 +745,9 @@ def process_key_event(self, e):
723745
)
724746
and self.config.curtsies_right_arrow_completion
725747
and self.cursor_offset == len(self.current_line)
748+
# if at end of current line and user presses RIGHT (to autocomplete)
726749
):
727-
750+
# then autocomplete
728751
self.current_line += self.current_suggestion
729752
self.cursor_offset = len(self.current_line)
730753
elif e in ("<UP>",) + key_dispatch[self.config.up_one_line_key]:
@@ -872,6 +895,7 @@ def on_enter(self, new_code=True, reset_rl_history=True):
872895
self.rl_history.reset()
873896

874897
self.history.append(self.current_line)
898+
self.all_logical_lines.append((self.current_line, LineType.INPUT))
875899
self.push(self.current_line, insert_into_history=new_code)
876900

877901
def on_tab(self, back=False):
@@ -982,6 +1006,9 @@ def process_simple_keypress(self, e):
9821006
self.add_normal_character(e)
9831007

9841008
def send_current_block_to_external_editor(self, filename=None):
1009+
""""
1010+
Sends the current code block to external editor to be edited. Usually bound to C-x.
1011+
"""
9851012
text = self.send_to_external_editor(self.get_current_block())
9861013
lines = [line for line in text.split("\n")]
9871014
while lines and not lines[-1].split():
@@ -994,15 +1021,12 @@ def send_current_block_to_external_editor(self, filename=None):
9941021
self.cursor_offset = len(self.current_line)
9951022

9961023
def send_session_to_external_editor(self, filename=None):
1024+
"""
1025+
Sends entire bpython session to external editor to be edited. Usually bound to F7.
1026+
"""
9971027
for_editor = EDIT_SESSION_HEADER
998-
for_editor += "\n".join(
999-
line[len(self.ps1) :]
1000-
if line.startswith(self.ps1)
1001-
else line[len(self.ps2) :]
1002-
if line.startswith(self.ps2)
1003-
else "### " + line
1004-
for line in self.getstdout().split("\n")
1005-
)
1028+
for_editor += self.get_session_formatted_for_file()
1029+
10061030
text = self.send_to_external_editor(for_editor)
10071031
if text == for_editor:
10081032
self.status_bar.message(
@@ -1016,7 +1040,7 @@ def send_session_to_external_editor(self, filename=None):
10161040
current_line = lines[-1][4:]
10171041
else:
10181042
current_line = ""
1019-
from_editor = [line for line in lines if line[:3] != "###"]
1043+
from_editor = [line for line in lines if line[:6] != "# OUT:" and line[:3] != "###"]
10201044
if all(not line.strip() for line in from_editor):
10211045
self.status_bar.message(
10221046
_("Session not reevaluated because saved file was blank")
@@ -1237,13 +1261,17 @@ def clear_current_block(self, remove_from_history=True):
12371261
if remove_from_history:
12381262
for unused in self.buffer:
12391263
self.history.pop()
1264+
self.all_logical_lines.pop()
12401265
self.buffer = []
12411266
self.cursor_offset = 0
12421267
self.saved_indent = 0
12431268
self.current_line = ""
12441269
self.cursor_offset = len(self.current_line)
12451270

12461271
def get_current_block(self):
1272+
"""
1273+
Returns the current code block as string (without prompts)
1274+
"""
12471275
return "\n".join(self.buffer + [self.current_line])
12481276

12491277
def send_to_stdouterr(self, output):
@@ -1271,6 +1299,13 @@ def send_to_stdouterr(self, output):
12711299
[],
12721300
)
12731301
)
1302+
# These can be FmtStrs, but self.all_logical_lines only wants strings
1303+
for line in [self.current_stdouterr_line] + lines[1:-1]:
1304+
if isinstance(line, FmtStr):
1305+
self.all_logical_lines.append((line.s, LineType.OUTPUT))
1306+
else:
1307+
self.all_logical_lines.append((line, LineType.OUTPUT))
1308+
12741309
self.current_stdouterr_line = lines[-1]
12751310
logger.debug("display_lines: %r", self.display_lines)
12761311

@@ -1402,6 +1437,25 @@ def current_output_line(self, value):
14021437
self.current_stdouterr_line = ""
14031438
self.stdin.current_line = "\n"
14041439

1440+
def number_of_padding_chars_on_current_cursor_line(self):
1441+
""" To avoid cutting off two-column characters at the end of lines where
1442+
there's only one column left, curtsies adds a padding char (u' ').
1443+
It's important to know about these for cursor positioning.
1444+
1445+
Should return zero unless there are fullwidth characters. """
1446+
full_line = self.current_cursor_line_without_suggestion
1447+
line_with_padding = "".join(
1448+
[
1449+
line.s
1450+
for line in paint.display_linize(
1451+
self.current_cursor_line_without_suggestion.s, self.width
1452+
)
1453+
]
1454+
)
1455+
1456+
# the difference in length here is how much padding there is
1457+
return len(line_with_padding) - len(full_line)
1458+
14051459
def paint(
14061460
self,
14071461
about_to_exit=False,
@@ -1581,13 +1635,14 @@ def move_screen_up(current_line_start_row):
15811635
len(self.current_line),
15821636
self.cursor_offset,
15831637
)
1584-
else: # Common case for determining cursor position
1638+
else: # Common case for determining cursor position
15851639
cursor_row, cursor_column = divmod(
15861640
(
15871641
wcswidth(self.current_cursor_line_without_suggestion.s)
15881642
- wcswidth(self.current_line)
1589-
+ wcswidth(self.current_line[: self.cursor_offset])
1590-
),
1643+
+ wcswidth(self.current_line[: self.cursor_offset])
1644+
)
1645+
+ self.number_of_padding_chars_on_current_cursor_line(),
15911646
width,
15921647
)
15931648
assert cursor_column >= 0, (
@@ -1804,11 +1859,13 @@ def take_back_buffer_line(self):
18041859
self.display_buffer.pop()
18051860
self.buffer.pop()
18061861
self.history.pop()
1862+
self.all_logical_lines.pop()
18071863

18081864
def take_back_empty_line(self):
18091865
assert self.history and not self.history[-1]
18101866
self.history.pop()
18111867
self.display_lines.pop()
1868+
self.all_logical_lines.pop()
18121869

18131870
def prompt_undo(self):
18141871
if self.buffer:
@@ -1824,10 +1881,11 @@ def prompt_for_undo():
18241881
greenlet.greenlet(prompt_for_undo).switch()
18251882

18261883
def redo(self):
1827-
if (self.redo_stack):
1884+
if self.redo_stack:
18281885
temp = self.redo_stack.pop()
1829-
self.push(temp)
18301886
self.history.append(temp)
1887+
self.all_logical_lines.append((temp, LineType.INPUT))
1888+
self.push(temp)
18311889
else:
18321890
self.status_bar.message("Nothing to redo.")
18331891

@@ -1839,6 +1897,7 @@ def reevaluate(self, new_code=False):
18391897
old_display_lines = self.display_lines
18401898
self.history = []
18411899
self.display_lines = []
1900+
self.all_logical_lines = []
18421901

18431902
if not self.weak_rewind:
18441903
self.interp = self.interp.__class__()
@@ -1903,6 +1962,9 @@ def initialize_interp(self):
19031962
del self.coderunner.interp.locals["_Helper"]
19041963

19051964
def getstdout(self):
1965+
"""
1966+
Returns a string of the current bpython session, wrapped, WITH prompts.
1967+
"""
19061968
lines = self.lines_for_display + [self.current_line_formatted]
19071969
s = (
19081970
"\n".join(x.s if isinstance(x, FmtStr) else x for x in lines)

bpython/curtsiesfrontend/replpainter.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,10 @@ def display_linize(msg, columns, blank_line=False):
2626
"""Returns lines obtained by splitting msg over multiple lines.
2727
2828
Warning: if msg is empty, returns an empty list of lines"""
29-
display_lines = (
30-
[
31-
msg[start:end]
32-
for start, end in zip(
33-
range(0, len(msg), columns),
34-
range(columns, len(msg) + columns, columns),
35-
)
36-
]
37-
if msg
38-
else ([""] if blank_line else [])
39-
)
40-
return display_lines
29+
if not msg:
30+
return [''] if blank_line else []
31+
msg = fmtstr(msg)
32+
return list(msg.width_aware_splitlines(columns))
4133

4234

4335
def paint_history(rows, columns, display_lines):

0 commit comments

Comments
 (0)