Merge pull request #488 from AndiDog/feature/status-context-menu-view-history
[git-cola.git] / cola / inotify.py
blob5e90212a46b1b0db014a8188760a67b8eb423c42
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 WatchManagerError
14 from pyinotify import Notifier
15 from pyinotify import EventsCodes
16 AVAILABLE = True
17 except Exception:
18 ProcessEvent = object
19 AVAILABLE = False
21 from cola import utils
22 if utils.is_win32():
23 try:
24 import win32file
25 import win32con
26 import pywintypes
27 import win32event
28 AVAILABLE = True
29 except ImportError:
30 ProcessEvent = object
31 AVAILABLE = False
33 from PyQt4 import QtCore
35 from cola import gitcfg
36 from cola import core
37 from cola.compat import ustr, PY3
38 from cola.git import STDOUT
39 from cola.i18n import N_
40 from cola.interaction import Interaction
41 from cola.models import main
44 _thread = None
45 _observers = []
48 def observer(fn):
49 _observers.append(fn)
52 def start():
53 global _thread
55 cfg = gitcfg.current()
56 if not cfg.get('cola.inotify', True):
57 msg = N_('inotify is disabled because "cola.inotify" is false')
58 Interaction.log(msg)
59 return
61 if not AVAILABLE:
62 if utils.is_win32():
63 msg = N_('file notification: disabled\n'
64 'Note: install pywin32 to enable.\n')
65 elif utils.is_linux():
66 msg = N_('inotify: disabled\n'
67 'Note: install python-pyinotify to enable inotify.\n')
68 else:
69 return
71 if utils.is_debian():
72 msg += N_('On Debian-based systems '
73 'try: sudo apt-get install python-pyinotify')
74 Interaction.log(msg)
75 return
77 # Start the notification thread
78 _thread = GitNotifier()
79 _thread.start()
80 if utils.is_win32():
81 msg = N_('File notification enabled.')
82 else:
83 msg = N_('inotify enabled.')
84 Interaction.log(msg)
87 def stop():
88 if not has_inotify():
89 return
90 _thread.stop(True)
91 _thread.wait()
94 def has_inotify():
95 """Return True if pyinotify is available."""
96 return AVAILABLE and _thread and _thread.isRunning()
99 class Handler():
100 """Queues filesystem events for broadcast"""
102 def __init__(self):
103 """Create an event handler"""
104 ## Timer used to prevent notification floods
105 self._timer = None
106 ## Lock to protect files and timer from threading issues
107 self._lock = Lock()
109 def broadcast(self):
110 """Broadcasts a list of all files touched since last broadcast"""
111 with self._lock:
112 for observer in _observers:
113 observer()
114 self._timer = None
116 def handle(self, path):
117 """Queues up filesystem events for broadcast"""
118 with self._lock:
119 if self._timer is None:
120 self._timer = Timer(0.888, self.broadcast)
121 self._timer.start()
124 class FileSysEvent(ProcessEvent):
125 """Generated by GitNotifier in response to inotify events"""
127 def __init__(self):
128 """Maintain event state"""
129 ProcessEvent.__init__(self)
130 ## Takes care of Queueing events for broadcast
131 self._handler = Handler()
133 def process_default(self, event):
134 """Queues up inotify events for broadcast"""
135 if not event.name:
136 return
137 path = os.path.relpath(os.path.join(event.path, event.name))
138 self._handler.handle(path)
141 class GitNotifier(QtCore.QThread):
142 """Polls inotify for changes and generates FileSysEvents"""
144 def __init__(self, timeout=333):
145 """Set up the pyinotify thread"""
146 QtCore.QThread.__init__(self)
147 ## Git command object
148 self._git = main.model().git
149 ## pyinotify timeout
150 self._timeout = timeout
151 ## Path to monitor
152 self._path = self._git.worktree()
153 ## Signals thread termination
154 self._running = True
155 ## Directories to watching
156 self._dirs_seen = set()
157 ## The inotify watch manager instantiated in run()
158 self._wmgr = None
159 ## Has add_watch() failed?
160 self._add_watch_failed = False
161 ## Events to capture
162 if utils.is_linux():
163 self._mask = (EventsCodes.ALL_FLAGS['IN_ATTRIB'] |
164 EventsCodes.ALL_FLAGS['IN_CLOSE_WRITE'] |
165 EventsCodes.ALL_FLAGS['IN_CREATE'] |
166 EventsCodes.ALL_FLAGS['IN_DELETE'] |
167 EventsCodes.ALL_FLAGS['IN_MODIFY'] |
168 EventsCodes.ALL_FLAGS['IN_MOVED_TO'])
170 def stop(self, stopped):
171 """Tells the GitNotifier to stop"""
172 self._timeout = 0
173 self._running = not stopped
175 def _watch_directory(self, directory):
176 """Set up a directory for monitoring by inotify"""
177 if self._wmgr is None or self._add_watch_failed:
178 return
179 directory = core.realpath(directory)
180 if directory in self._dirs_seen:
181 return
182 self._dirs_seen.add(directory)
183 if core.exists(directory):
184 dir_arg = directory if PY3 else core.encode(directory)
185 try:
186 self._wmgr.add_watch(dir_arg, self._mask, quiet=False)
187 except WatchManagerError as e:
188 self._add_watch_failed = True
189 self._add_watch_failed_warning(directory, e)
191 def _add_watch_failed_warning(self, directory, e):
192 core.stderr('inotify: failed to watch "%s"' % directory)
193 core.stderr(ustr(e))
194 core.stderr('')
195 core.stderr('If you have run out of watches then you may be able to')
196 core.stderr('increase the number of allowed watches by running:')
197 core.stderr('')
198 core.stderr(' echo fs.inotify.max_user_watches=100000 |')
199 core.stderr(' sudo tee -a /etc/sysctl.conf &&')
200 core.stderr(' sudo sysctl -p\n')
202 def _is_pyinotify_08x(self):
203 """Is this pyinotify 0.8.x?
205 The pyinotify API changed between 0.7.x and 0.8.x.
206 This allows us to maintain backwards compatibility.
208 if hasattr(pyinotify, '__version__'):
209 if pyinotify.__version__[:3] < '0.8':
210 return False
211 return True
213 def run(self):
214 """Create the inotify WatchManager and generate FileSysEvents"""
216 if utils.is_win32():
217 self.run_win32()
218 return
220 # Only capture events that git cares about
221 self._wmgr = WatchManager()
222 if self._is_pyinotify_08x():
223 notifier = Notifier(self._wmgr, FileSysEvent(),
224 timeout=self._timeout)
225 else:
226 notifier = Notifier(self._wmgr, FileSysEvent())
228 self._watch_directory(self._path)
230 # Register files/directories known to git
231 for filename in self._git.ls_files()[STDOUT].splitlines():
232 filename = core.realpath(filename)
233 directory = os.path.dirname(filename)
234 self._watch_directory(directory)
236 # self._running signals app termination. The timeout is a tradeoff
237 # between fast notification response and waiting too long to exit.
238 while self._running:
239 if self._is_pyinotify_08x():
240 check = notifier.check_events()
241 else:
242 check = notifier.check_events(timeout=self._timeout)
243 if not self._running:
244 break
245 if check:
246 notifier.read_events()
247 notifier.process_events()
248 notifier.stop()
250 def run_win32(self):
251 """Generate notifications using pywin32"""
253 hdir = win32file.CreateFile(
254 self._path,
255 0x0001,
256 win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE,
257 None,
258 win32con.OPEN_EXISTING,
259 win32con.FILE_FLAG_BACKUP_SEMANTICS |
260 win32con.FILE_FLAG_OVERLAPPED,
261 None)
263 flags = (win32con.FILE_NOTIFY_CHANGE_FILE_NAME |
264 win32con.FILE_NOTIFY_CHANGE_DIR_NAME |
265 win32con.FILE_NOTIFY_CHANGE_ATTRIBUTES |
266 win32con.FILE_NOTIFY_CHANGE_SIZE |
267 win32con.FILE_NOTIFY_CHANGE_LAST_WRITE |
268 win32con.FILE_NOTIFY_CHANGE_SECURITY)
270 buf = win32file.AllocateReadBuffer(8192)
271 overlapped = pywintypes.OVERLAPPED()
272 overlapped.hEvent = win32event.CreateEvent(None, 0, 0, None)
274 handler = Handler()
275 while self._running:
276 win32file.ReadDirectoryChangesW(hdir, buf, True, flags, overlapped)
278 rc = win32event.WaitForSingleObject(overlapped.hEvent,
279 self._timeout)
280 if rc != win32event.WAIT_OBJECT_0:
281 continue
282 nbytes = win32file.GetOverlappedResult(hdir, overlapped, True)
283 if not nbytes:
284 continue
285 results = win32file.FILE_NOTIFY_INFORMATION(buf, nbytes)
286 for action, path in results:
287 if not self._running:
288 break
289 path = path.replace('\\', '/')
290 if (not path.startswith('.git/') and
291 '/.git/' not in path and os.path.isfile(path)):
292 handler.handle(path)