git-cola v2.0.0
[git-cola.git] / cola / inotify.py
blobd4573c19beab5cc93e329c410b2cc2865fe42c0a
1 # Copyright (c) 2008 David Aguilar
2 """Provides an inotify plugin for Linux and other systems with pyinotify"""
3 from __future__ import division, absolute_import, unicode_literals
5 import os
6 from threading import Timer
7 from threading import Lock
9 try:
10 import pyinotify
11 from pyinotify import ProcessEvent
12 from pyinotify import WatchManager
13 from pyinotify import Notifier
14 from pyinotify import EventsCodes
15 AVAILABLE = True
16 except ImportError:
17 ProcessEvent = object
18 AVAILABLE = False
20 from cola import utils
21 if utils.is_win32():
22 try:
23 import win32file
24 import win32con
25 import pywintypes
26 import win32event
27 AVAILABLE = True
28 except ImportError:
29 ProcessEvent = object
30 AVAILABLE = False
32 from PyQt4 import QtCore
34 from cola import gitcfg
35 from cola import cmds
36 from cola import core
37 from cola.git import STDOUT
38 from cola.i18n import N_
39 from cola.interaction import Interaction
40 from cola.models import main
43 _thread = None
45 def start():
46 global _thread
48 cfg = gitcfg.instance()
49 if not cfg.get('cola.inotify', True):
50 msg = N_('inotify is disabled because "cola.inotify" is false')
51 Interaction.log(msg)
52 return
54 if not AVAILABLE:
55 if utils.is_win32():
56 msg = N_('file notification: disabled\n'
57 'Note: install pywin32 to enable.\n')
58 elif utils.is_linux():
59 msg = N_('inotify: disabled\n'
60 'Note: install python-pyinotify to enable inotify.\n')
61 else:
62 return
64 if utils.is_debian():
65 msg += N_('On Debian systems '
66 'try: sudo aptitude install python-pyinotify')
67 Interaction.log(msg)
68 return
70 # Start the notification thread
71 _thread = GitNotifier()
72 _thread.start()
73 if utils.is_win32():
74 msg = N_('File notification enabled.')
75 else:
76 msg = N_('inotify enabled.')
77 Interaction.log(msg)
80 def stop():
81 if not has_inotify():
82 return
83 _thread.stop(True)
84 _thread.wait()
87 def has_inotify():
88 """Return True if pyinotify is available."""
89 return AVAILABLE and _thread and _thread.isRunning()
92 class Handler():
93 """Queues filesystem events for broadcast"""
95 def __init__(self):
96 """Create an event handler"""
97 ## Timer used to prevent notification floods
98 self._timer = None
99 ## Lock to protect files and timer from threading issues
100 self._lock = Lock()
102 def broadcast(self):
103 """Broadcasts a list of all files touched since last broadcast"""
104 with self._lock:
105 cmds.do(cmds.UpdateFileStatus)
106 self._timer = None
108 def handle(self, path):
109 """Queues up filesystem events for broadcast"""
110 with self._lock:
111 if self._timer is None:
112 self._timer = Timer(0.888, self.broadcast)
113 self._timer.start()
116 class FileSysEvent(ProcessEvent):
117 """Generated by GitNotifier in response to inotify events"""
119 def __init__(self):
120 """Maintain event state"""
121 ProcessEvent.__init__(self)
122 ## Takes care of Queueing events for broadcast
123 self._handler = Handler()
125 def process_default(self, event):
126 """Queues up inotify events for broadcast"""
127 if not event.name:
128 return
129 path = os.path.join(event.path, event.name)
130 if os.path.exists(path):
131 path = os.path.relpath(path)
132 self._handler.handle(path)
135 class GitNotifier(QtCore.QThread):
136 """Polls inotify for changes and generates FileSysEvents"""
138 def __init__(self, timeout=333):
139 """Set up the pyinotify thread"""
140 QtCore.QThread.__init__(self)
141 ## Git command object
142 self._git = main.model().git
143 ## pyinotify timeout
144 self._timeout = timeout
145 ## Path to monitor
146 self._path = self._git.worktree()
147 ## Signals thread termination
148 self._running = True
149 ## Directories to watching
150 self._dirs_seen = set()
151 ## The inotify watch manager instantiated in run()
152 self._wmgr = None
153 ## Events to capture
154 if utils.is_linux():
155 self._mask = (EventsCodes.ALL_FLAGS['IN_ATTRIB'] |
156 EventsCodes.ALL_FLAGS['IN_CLOSE_WRITE'] |
157 EventsCodes.ALL_FLAGS['IN_DELETE'] |
158 EventsCodes.ALL_FLAGS['IN_MODIFY'] |
159 EventsCodes.ALL_FLAGS['IN_MOVED_TO'])
161 def stop(self, stopped):
162 """Tells the GitNotifier to stop"""
163 self._timeout = 0
164 self._running = not stopped
166 def _watch_directory(self, directory):
167 """Set up a directory for monitoring by inotify"""
168 if not self._wmgr:
169 return
170 directory = core.realpath(directory)
171 if not core.exists(directory):
172 return
173 if directory not in self._dirs_seen:
174 self._wmgr.add_watch(directory, self._mask)
175 self._dirs_seen.add(directory)
177 def _is_pyinotify_08x(self):
178 """Is this pyinotify 0.8.x?
180 The pyinotify API changed between 0.7.x and 0.8.x.
181 This allows us to maintain backwards compatibility.
183 if hasattr(pyinotify, '__version__'):
184 if pyinotify.__version__[:3] == '0.8':
185 return True
186 return False
188 def run(self):
189 """Create the inotify WatchManager and generate FileSysEvents"""
191 if utils.is_win32():
192 self.run_win32()
193 return
195 # Only capture events that git cares about
196 self._wmgr = WatchManager()
197 if self._is_pyinotify_08x():
198 notifier = Notifier(self._wmgr, FileSysEvent(),
199 timeout=self._timeout)
200 else:
201 notifier = Notifier(self._wmgr, FileSysEvent())
203 self._watch_directory(self._path)
205 # Register files/directories known to git
206 for filename in self._git.ls_files()[STDOUT].splitlines():
207 filename = core.realpath(filename)
208 directory = os.path.dirname(filename)
209 self._watch_directory(directory)
211 # self._running signals app termination. The timeout is a tradeoff
212 # between fast notification response and waiting too long to exit.
213 while self._running:
214 if self._is_pyinotify_08x():
215 check = notifier.check_events()
216 else:
217 check = notifier.check_events(timeout=self._timeout)
218 if not self._running:
219 break
220 if check:
221 notifier.read_events()
222 notifier.process_events()
223 notifier.stop()
225 def run_win32(self):
226 """Generate notifications using pywin32"""
228 hdir = win32file.CreateFile(
229 self._path,
230 0x0001,
231 win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE,
232 None,
233 win32con.OPEN_EXISTING,
234 win32con.FILE_FLAG_BACKUP_SEMANTICS |
235 win32con.FILE_FLAG_OVERLAPPED,
236 None)
238 flags = (win32con.FILE_NOTIFY_CHANGE_FILE_NAME |
239 win32con.FILE_NOTIFY_CHANGE_DIR_NAME |
240 win32con.FILE_NOTIFY_CHANGE_ATTRIBUTES |
241 win32con.FILE_NOTIFY_CHANGE_SIZE |
242 win32con.FILE_NOTIFY_CHANGE_LAST_WRITE |
243 win32con.FILE_NOTIFY_CHANGE_SECURITY)
245 buf = win32file.AllocateReadBuffer(8192)
246 overlapped = pywintypes.OVERLAPPED()
247 overlapped.hEvent = win32event.CreateEvent(None, 0, 0, None)
249 handler = Handler()
250 while self._running:
251 win32file.ReadDirectoryChangesW(hdir, buf, True, flags, overlapped)
253 rc = win32event.WaitForSingleObject(overlapped.hEvent,
254 self._timeout)
255 if rc != win32event.WAIT_OBJECT_0:
256 continue
257 nbytes = win32file.GetOverlappedResult(hdir, overlapped, True)
258 if not nbytes:
259 continue
260 results = win32file.FILE_NOTIFY_INFORMATION(buf, nbytes)
261 for action, path in results:
262 if not self._running:
263 break
264 path = path.replace('\\', '/')
265 if (not path.startswith('.git/') and
266 '/.git/' not in path and os.path.isfile(path)):
267 handler.handle(path)