Skip to content

Commit 3576813

Browse files
Merge branch 'watch-files'
Conflicts: bpython/curtsiesfrontend/repl.py
2 parents 4d19eeb + 15139f8 commit 3576813

File tree

6 files changed

+188
-22
lines changed

6 files changed

+188
-22
lines changed

bpython/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def loadini(struct, configfile):
7676
'save': 'C-s',
7777
'show_source': 'F2',
7878
'suspend': 'C-z',
79+
'toggle_file_watch': 'F5',
7980
'undo': 'C-r',
8081
'reimport': 'F6',
8182
'search': 'C-o',
@@ -117,6 +118,7 @@ def loadini(struct, configfile):
117118
struct.search_key = config.get('keyboard', 'search')
118119
struct.show_source_key = config.get('keyboard', 'show_source')
119120
struct.suspend_key = config.get('keyboard', 'suspend')
121+
struct.toggle_file_watch_key = config.get('keyboard', 'toggle_file_watch')
120122
struct.undo_key = config.get('keyboard', 'undo')
121123
struct.reimport_key = config.get('keyboard', 'reimport')
122124
struct.up_one_line_key = config.get('keyboard', 'up_one_line')

bpython/curtsies.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from __future__ import absolute_import
22

3-
import sys
43
import code
54
import logging
5+
import sys
6+
import time
7+
from subprocess import Popen, PIPE
68
from optparse import Option
79
from itertools import izip
10+
from functools import wraps
811

912
import curtsies
1013
import curtsies.window
@@ -71,20 +74,32 @@ def mainloop(config, locals_, banner, interp=None, paste=None, interactive=True)
7174
hide_cursor=False,
7275
extra_bytes_callback=input_generator.unget_bytes) as window:
7376

77+
reload_requests = []
78+
def request_reload(desc):
79+
reload_requests.append(curtsies.events.ReloadEvent([desc]))
7480
refresh_requests = []
75-
def request_refresh():
76-
refresh_requests.append(curtsies.events.RefreshRequestEvent())
81+
def request_refresh(when='now'):
82+
refresh_requests.append(curtsies.events.RefreshRequestEvent(when=when))
83+
7784
def event_or_refresh(timeout=None):
7885
while True:
79-
if refresh_requests:
80-
yield refresh_requests.pop()
86+
t = time.time()
87+
refresh_requests.sort(key=lambda r: 0 if r.when == 'now' else r.when)
88+
if refresh_requests and (refresh_requests[0].when == 'now' or refresh_requests[-1].when < t):
89+
yield refresh_requests.pop(0)
90+
elif reload_requests:
91+
e = reload_requests.pop()
92+
yield e
8193
else:
82-
yield input_generator.send(timeout)
94+
e = input_generator.send(.2)
95+
if e is not None:
96+
yield e
8397

8498
global repl # global for easy introspection `from bpython.curtsies import repl`
8599
with Repl(config=config,
86100
locals_=locals_,
87101
request_refresh=request_refresh,
102+
request_reload=request_reload,
88103
get_term_hw=window.get_term_hw,
89104
get_cursor_vertical_diff=window.get_cursor_vertical_diff,
90105
banner=banner,

bpython/curtsiesfrontend/beeper.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import time
2+
import sys
3+
4+
if __name__ == '__main__':
5+
while True:
6+
sys.stdout.write('beep\n')
7+
sys.stdout.flush()
8+
time.sleep(5)
9+
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import time
2+
import os
3+
from collections import defaultdict
4+
5+
from bpython import importcompletion
6+
7+
from watchdog.observers import Observer
8+
from watchdog.events import FileSystemEventHandler
9+
10+
class ModuleChangedEventHandler(FileSystemEventHandler):
11+
def __init__(self, paths, on_change):
12+
self.dirs = defaultdict(set)
13+
self.on_change = on_change
14+
self.modules_to_add_later = []
15+
self.observer = Observer()
16+
self.old_dirs = defaultdict(set)
17+
for path in paths:
18+
self.add_module(path)
19+
self.observer.start()
20+
21+
def reset(self):
22+
self.dirs = defaultdict(set)
23+
del self.modules_to_add_later[:]
24+
self.old_dirs = defaultdict(set)
25+
self.observer.unschedule_all()
26+
27+
def add_module(self, path):
28+
"""Add a python module to track changes to"""
29+
path = os.path.abspath(path)
30+
for suff in importcompletion.SUFFIXES:
31+
if path.endswith(suff):
32+
path = path[:-len(suff)]
33+
break
34+
dirname = os.path.dirname(path)
35+
if dirname not in self.dirs:
36+
self.observer.schedule(self, dirname, recursive=False)
37+
self.dirs[os.path.dirname(path)].add(path)
38+
39+
def add_module_later(self, path):
40+
self.modules_to_add_later.append(path)
41+
42+
def activate(self):
43+
self.dirs = self.old_dirs
44+
for dirname in self.dirs:
45+
self.observer.schedule(self, dirname, recursive=False)
46+
for module in self.modules_to_add_later:
47+
self.add_module(module)
48+
del self.modules_to_add_later[:]
49+
50+
def deactivate(self):
51+
self.observer.unschedule_all()
52+
self.old_dirs = self.dirs
53+
self.dirs = defaultdict(set)
54+
55+
def on_any_event(self, event):
56+
dirpath = os.path.dirname(event.src_path)
57+
paths = [path + '.py' for path in self.dirs[dirpath]]
58+
if event.src_path in paths:
59+
self.on_change(event.src_path)
60+
61+
if __name__ == '__main__':
62+
m = ModuleChangedEventHandler([])
63+
m.add_module('./wdtest.py')
64+
try:
65+
while True:
66+
time.sleep(1)
67+
except KeyboardInterrupt:
68+
m.observer.stop()
69+
m.observer.join()
70+

bpython/curtsiesfrontend/interaction.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,30 @@ def __init__(self, initial_message='', permanent_text="", refresh_request=lambda
2929
self._message = initial_message
3030
self.message_start_time = time.time()
3131
self.message_time = 3
32-
self.permanent_text = permanent_text
32+
self.permanent_stack = []
33+
if permanent_text:
34+
self.permanent_stack.append(permanent_text)
3335
self.main_greenlet = greenlet.getcurrent()
3436
self.request_greenlet = None
3537
self.refresh_request = refresh_request
3638

39+
def push_permanent_message(self, msg):
40+
self.permanent_stack.append(msg)
41+
42+
def pop_permanent_message(self, msg):
43+
if msg in self.permanent_stack:
44+
self.permanent_stack.remove(msg)
45+
else:
46+
raise ValueError("Messsage %r was not in permanent_stack" % msg)
47+
3748
@property
3849
def has_focus(self):
3950
return self.in_prompt or self.in_confirm or self.waiting_for_refresh
4051

4152
def message(self, msg):
4253
self.message_start_time = time.time()
4354
self._message = msg
55+
self.refresh_request(time.time() + self.message_time)
4456

4557
def _check_for_expired_message(self):
4658
if self._message and time.time() > self.message_start_time + self.message_time:
@@ -101,7 +113,13 @@ def current_line(self):
101113
return self.prompt
102114
if self._message:
103115
return self._message
104-
return self.permanent_text
116+
if self.permanent_stack:
117+
return self.permanent_stack[-1]
118+
return ''
119+
120+
@property
121+
def should_show_message(self):
122+
return bool(self.current_line)
105123

106124
# interaction interface - should be called from other greenlets
107125
def notify(self, msg, n=3):

bpython/curtsiesfrontend/repl.py

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import code
22
import contextlib
33
import errno
4+
import functools
45
import greenlet
56
import logging
67
import os
@@ -10,6 +11,7 @@
1011
import sys
1112
import tempfile
1213
import threading
14+
import time
1315
import unicodedata
1416

1517
from bpython import autocomplete
@@ -39,6 +41,7 @@
3941
from bpython.curtsiesfrontend import sitefix; sitefix.monkeypatch_quit()
4042
import bpython.curtsiesfrontend.replpainter as paint
4143
from bpython.curtsiesfrontend.coderunner import CodeRunner, FakeOutput
44+
from bpython.curtsiesfrontend.filewatch import ModuleChangedEventHandler
4245

4346
#TODO other autocomplete modes (also fix in other bpython implementations)
4447

@@ -193,15 +196,16 @@ class Repl(BpythonRepl):
193196
"""
194197

195198
## initialization, cleanup
196-
def __init__(self, locals_=None, config=None,
197-
request_refresh=lambda: None, get_term_hw=lambda:(50, 10),
198-
get_cursor_vertical_diff=lambda: 0, banner=None, interp=None, interactive=True,
199-
orig_tcattrs=None):
199+
def __init__(self, locals_=None, config=None, request_refresh=lambda: None,
200+
request_reload=lambda desc: None, get_term_hw=lambda:(50, 10),
201+
get_cursor_vertical_diff=lambda: 0, banner=None, interp=None,
202+
interactive=True, orig_tcattrs=None):
200203
"""
201204
locals_ is a mapping of locals to pass into the interpreter
202205
config is a bpython config.Struct with config attributes
203206
request_refresh is a function that will be called when the Repl
204207
wants to refresh the display, but wants control returned to it afterwards
208+
Takes as a kwarg when= which is when to fire
205209
get_term_hw is a function that returns the current width and height
206210
of the terminal
207211
get_cursor_vertical_diff is a function that returns how the cursor moved
@@ -229,18 +233,26 @@ def __init__(self, locals_=None, config=None,
229233

230234
self.reevaluating = False
231235
self.fake_refresh_requested = False
232-
def smarter_request_refresh():
236+
def smarter_request_refresh(when='now'):
233237
if self.reevaluating or self.paste_mode:
234238
self.fake_refresh_requested = True
235239
else:
236-
request_refresh()
240+
request_refresh(when=when)
237241
self.request_refresh = smarter_request_refresh
242+
def smarter_request_reload(desc):
243+
if self.watching_files:
244+
request_reload(desc)
245+
else:
246+
pass
247+
self.request_reload = smarter_request_reload
238248
self.get_term_hw = get_term_hw
239249
self.get_cursor_vertical_diff = get_cursor_vertical_diff
240250

241-
self.status_bar = StatusBar(banner, _(
242-
" <%s> Rewind <%s> Save <%s> Pastebin <%s> Editor"
243-
) % (config.undo_key, config.save_key, config.pastebin_key, config.external_editor_key),
251+
self.status_bar = StatusBar(
252+
banner,
253+
(_(" <%s> Rewind <%s> Save <%s> Pastebin <%s> Editor")
254+
% (config.undo_key, config.save_key, config.pastebin_key, config.external_editor_key)
255+
if config.curtsies_fill_terminal else ''),
244256
refresh_request=self.request_refresh
245257
)
246258
self.rl_char_sequences = get_updated_char_sequences(key_dispatch, config)
@@ -280,12 +292,15 @@ def smarter_request_refresh():
280292
self.paste_mode = False
281293
self.current_match = None
282294
self.list_win_visible = False
295+
self.watching_files = False
283296

284297
self.original_modules = sys.modules.keys()
285298

286299
self.width = None # will both be set by a window resize event
287300
self.height = None
288301

302+
self.watcher = ModuleChangedEventHandler([], smarter_request_reload)
303+
289304
def __enter__(self):
290305
self.orig_stdout = sys.stdout
291306
self.orig_stderr = sys.stderr
@@ -295,13 +310,27 @@ def __enter__(self):
295310
sys.stdin = self.stdin
296311
self.orig_sigwinch_handler = signal.getsignal(signal.SIGWINCH)
297312
signal.signal(signal.SIGWINCH, self.sigwinch_handler)
313+
314+
self.orig_import = __builtins__['__import__']
315+
@functools.wraps(self.orig_import)
316+
def new_import(name, globals={}, locals={}, fromlist=[], level=-1):
317+
m = self.orig_import(name, globals=globals, locals=locals, fromlist=fromlist)
318+
if hasattr(m, "__file__"):
319+
if self.watching_files:
320+
self.watcher.add_module(m.__file__)
321+
else:
322+
self.watcher.add_module_later(m.__file__)
323+
return m
324+
__builtins__['__import__'] = new_import
325+
298326
return self
299327

300328
def __exit__(self, *args):
301329
sys.stdin = self.orig_stdin
302330
sys.stdout = self.orig_stdout
303331
sys.stderr = self.orig_stderr
304332
signal.signal(signal.SIGWINCH, self.orig_sigwinch_handler)
333+
__builtins__['__import__'] = self.orig_import
305334

306335
def sigwinch_handler(self, signum, frame):
307336
old_rows, old_columns = self.height, self.width
@@ -347,7 +376,9 @@ def process_event(self, e):
347376

348377
logger.debug("processing event %r", e)
349378
if isinstance(e, events.RefreshRequestEvent):
350-
if self.status_bar.has_focus:
379+
if e.when != 'now':
380+
pass # This is a scheduled refresh - it's really just a refresh (so nop)
381+
elif self.status_bar.has_focus:
351382
self.status_bar.process_event(e)
352383
else:
353384
assert self.coderunner.code_is_waiting
@@ -377,6 +408,28 @@ def process_event(self, e):
377408
self.update_completion()
378409
return
379410

411+
elif isinstance(e, events.ReloadEvent):
412+
if self.watching_files:
413+
self.clear_modules_and_reevaluate()
414+
self.update_completion()
415+
self.status_bar.message('Reloaded at ' + time.strftime('%H:%M:%S') + ' because ' + ' & '.join(e.files_modified) + ' modified')
416+
417+
elif e in key_dispatch[self.config.toggle_file_watch_key]:
418+
msg = "Auto-reloading active, watching for file changes..."
419+
if self.watching_files:
420+
self.watcher.deactivate()
421+
self.watching_files = False
422+
self.status_bar.pop_permanent_message(msg)
423+
else:
424+
self.watching_files = True
425+
self.status_bar.push_permanent_message(msg)
426+
self.watcher.activate()
427+
428+
elif e in key_dispatch[self.config.reimport_key]:
429+
self.clear_modules_and_reevaluate()
430+
self.update_completion()
431+
self.status_bar.message('Reloaded at ' + time.strftime('%H:%M:%S') + ' by user')
432+
380433
elif (e in ("<RIGHT>", '<Ctrl-f>') and self.config.curtsies_right_arrow_completion
381434
and self.cursor_offset == len(self.current_line)):
382435
self.current_line += self.current_suggestion
@@ -441,9 +494,6 @@ def process_event(self, e):
441494
elif e in ("<Shift-TAB>",):
442495
self.on_tab(back=True)
443496
self.rl_history.reset()
444-
elif e in key_dispatch[self.config.reimport_key]:
445-
self.clear_modules_and_reevaluate()
446-
self.update_completion()
447497
elif e in key_dispatch[self.config.undo_key]: #ctrl-r for undo
448498
self.undo()
449499
self.update_completion()
@@ -554,6 +604,7 @@ def send_session_to_external_editor(self, filename=None):
554604
self.cursor_offset = len(self.current_line)
555605

556606
def clear_modules_and_reevaluate(self):
607+
self.watcher.reset()
557608
cursor, line = self.cursor_offset, self.current_line
558609
for modname in sys.modules.keys():
559610
if modname not in self.original_modules:
@@ -804,7 +855,7 @@ def paint(self, about_to_exit=False, user_quit=False):
804855
self.clean_up_current_line_for_exit() # exception to not changing state!
805856

806857
width, min_height = self.width, self.height
807-
show_status_bar = bool(self.status_bar._message) or (self.config.curtsies_fill_terminal or self.status_bar.has_focus)
858+
show_status_bar = bool(self.status_bar.should_show_message) or (self.config.curtsies_fill_terminal or self.status_bar.has_focus)
808859
if show_status_bar:
809860
min_height -= 1
810861

@@ -992,6 +1043,7 @@ def reprint_line(self, lineno, tokens):
9921043
self.display_buffer[lineno] = bpythonparse(format(tokens, self.formatter))
9931044
def reevaluate(self, insert_into_history=False):
9941045
"""bpython.Repl.undo calls this"""
1046+
self.watcher.reset()
9951047
old_logical_lines = self.history
9961048
self.history = []
9971049
self.display_lines = []

0 commit comments

Comments
 (0)