1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3
4
5__copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>'
6__docformat__ = 'restructuredtext en'
7
8import sys, os, errno, select
9
10
11class INotifyError(Exception):
12    pass
13
14
15class NoSuchDir(ValueError):
16    pass
17
18
19class BaseDirChanged(ValueError):
20    pass
21
22
23class DirTooLarge(ValueError):
24
25    def __init__(self, bdir):
26        ValueError.__init__(self, 'The directory {} is too large to monitor. Try increasing the value in /proc/sys/fs/inotify/max_user_watches'.format(bdir))
27
28
29_inotify = None
30
31
32def load_inotify():  # {{{
33    ''' Initialize the inotify ctypes wrapper '''
34    global _inotify
35    if _inotify is None:
36        if hasattr(sys, 'getwindowsversion'):
37            # On windows abort before loading the C library. Windows has
38            # multiple, incompatible C runtimes, and we have no way of knowing
39            # if the one chosen by ctypes is compatible with the currently
40            # loaded one.
41            raise INotifyError('INotify not available on windows')
42        if sys.platform == 'darwin':
43            raise INotifyError('INotify not available on OS X')
44        import ctypes
45        if not hasattr(ctypes, 'c_ssize_t'):
46            raise INotifyError('You need python >= 2.7 to use inotify')
47        libc = ctypes.CDLL(None, use_errno=True)
48        for function in ("inotify_add_watch", "inotify_init1", "inotify_rm_watch"):
49            if not hasattr(libc, function):
50                raise INotifyError('libc is too old')
51        # inotify_init1()
52        prototype = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, use_errno=True)
53        init1 = prototype(('inotify_init1', libc), ((1, "flags", 0),))
54
55        # inotify_add_watch()
56        prototype = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32, use_errno=True)
57        add_watch = prototype(('inotify_add_watch', libc), (
58            (1, "fd"), (1, "pathname"), (1, "mask")), use_errno=True)
59
60        # inotify_rm_watch()
61        prototype = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.c_int, use_errno=True)
62        rm_watch = prototype(('inotify_rm_watch', libc), (
63            (1, "fd"), (1, "wd")), use_errno=True)
64
65        # read()
66        prototype = ctypes.CFUNCTYPE(ctypes.c_ssize_t, ctypes.c_int, ctypes.c_void_p, ctypes.c_size_t, use_errno=True)
67        read = prototype(('read', libc), (
68            (1, "fd"), (1, "buf"), (1, "count")), use_errno=True)
69        _inotify = (init1, add_watch, rm_watch, read)
70    return _inotify
71# }}}
72
73
74class INotify:
75
76    # See <sys/inotify.h> for the flags defined below
77
78    # Supported events suitable for MASK parameter of INOTIFY_ADD_WATCH.
79    ACCESS = 0x00000001         # File was accessed.
80    MODIFY = 0x00000002         # File was modified.
81    ATTRIB = 0x00000004         # Metadata changed.
82    CLOSE_WRITE = 0x00000008    # Writtable file was closed.
83    CLOSE_NOWRITE = 0x00000010  # Unwrittable file closed.
84    OPEN = 0x00000020           # File was opened.
85    MOVED_FROM = 0x00000040     # File was moved from X.
86    MOVED_TO = 0x00000080       # File was moved to Y.
87    CREATE = 0x00000100         # Subfile was created.
88    DELETE = 0x00000200         # Subfile was deleted.
89    DELETE_SELF = 0x00000400    # Self was deleted.
90    MOVE_SELF = 0x00000800      # Self was moved.
91
92    # Events sent by the kernel.
93    UNMOUNT = 0x00002000     # Backing fs was unmounted.
94    Q_OVERFLOW = 0x00004000  # Event queued overflowed.
95    IGNORED = 0x00008000     # File was ignored.
96
97    # Helper events.
98    CLOSE = (CLOSE_WRITE | CLOSE_NOWRITE)  # Close.
99    MOVE = (MOVED_FROM | MOVED_TO)         # Moves.
100
101    # Special flags.
102    ONLYDIR = 0x01000000      # Only watch the path if it is a directory.
103    DONT_FOLLOW = 0x02000000  # Do not follow a sym link.
104    EXCL_UNLINK = 0x04000000  # Exclude events on unlinked objects.
105    MASK_ADD = 0x20000000     # Add to the mask of an already existing watch.
106    ISDIR = 0x40000000        # Event occurred against dir.
107    ONESHOT = 0x80000000      # Only send event once.
108
109    # All events which a program can wait on.
110    ALL_EVENTS = (ACCESS | MODIFY | ATTRIB | CLOSE_WRITE | CLOSE_NOWRITE |
111                    OPEN | MOVED_FROM | MOVED_TO | CREATE | DELETE |
112                    DELETE_SELF | MOVE_SELF)
113
114    # See <bits/inotify.h>
115    CLOEXEC = 0x80000
116    NONBLOCK = 0x800
117
118    def __init__(self, cloexec=True, nonblock=True):
119        import ctypes, struct
120        self._init1, self._add_watch, self._rm_watch, self._read = load_inotify()
121        flags = 0
122        if cloexec:
123            flags |= self.CLOEXEC
124        if nonblock:
125            flags |= self.NONBLOCK
126        self._inotify_fd = self._init1(flags)
127        if self._inotify_fd == -1:
128            raise INotifyError(os.strerror(ctypes.get_errno()))
129
130        self._buf = ctypes.create_string_buffer(5120)
131        self.fenc = sys.getfilesystemencoding() or 'utf-8'
132        self.hdr = struct.Struct(b'iIII')
133        if self.fenc == 'ascii':
134            self.fenc = 'utf-8'
135        # We keep a reference to os to prevent it from being deleted
136        # during interpreter shutdown, which would lead to errors in the
137        # __del__ method
138        self.os = os
139
140    def handle_error(self):
141        import ctypes
142        eno = ctypes.get_errno()
143        extra = ''
144        if eno == errno.ENOSPC:
145            extra = 'You may need to increase the inotify limits on your system, via /proc/sys/inotify/max_user_*'
146        raise OSError(eno, self.os.strerror(eno) + extra)
147
148    def __del__(self):
149        # This method can be called during interpreter shutdown, which means we
150        # must do the absolute minimum here. Note that there could be running
151        # daemon threads that are trying to call other methods on this object.
152        try:
153            self.os.close(self._inotify_fd)
154        except (AttributeError, TypeError):
155            pass
156
157    def close(self):
158        if hasattr(self, '_inotify_fd'):
159            self.os.close(self._inotify_fd)
160            del self.os
161            del self._add_watch
162            del self._rm_watch
163            del self._inotify_fd
164
165    def __enter__(self):
166        return self
167
168    def __exit__(self, *args):
169        self.close()
170
171    def read(self, get_name=True):
172        import ctypes
173        buf = []
174        while True:
175            num = self._read(self._inotify_fd, self._buf, len(self._buf))
176            if num == 0:
177                break
178            if num < 0:
179                en = ctypes.get_errno()
180                if en == errno.EAGAIN:
181                    break  # No more data
182                if en == errno.EINTR:
183                    continue  # Interrupted, try again
184                raise OSError(en, self.os.strerror(en))
185            buf.append(self._buf.raw[:num])
186        raw = b''.join(buf)
187        pos = 0
188        lraw = len(raw)
189        while lraw - pos >= self.hdr.size:
190            wd, mask, cookie, name_len = self.hdr.unpack_from(raw, pos)
191            pos += self.hdr.size
192            name = None
193            if get_name:
194                name = raw[pos:pos+name_len].rstrip(b'\0').decode(self.fenc)
195            pos += name_len
196            self.process_event(wd, mask, cookie, name)
197
198    def process_event(self, *args):
199        raise NotImplementedError()
200
201    def wait(self, timeout=None):
202        'Return True iff there are events waiting to be read. Blocks if timeout is None. Polls if timeout is 0.'
203        return len((select.select([self._inotify_fd], [], []) if timeout is None else select.select([self._inotify_fd], [], [], timeout))[0]) > 0
204
205
206def realpath(path):
207    return os.path.abspath(os.path.realpath(path))
208
209
210class INotifyTreeWatcher(INotify):
211
212    is_dummy = False
213
214    def __init__(self, basedir, ignore_event=None):
215        super().__init__()
216        self.basedir = realpath(basedir)
217        self.watch_tree()
218        self.modified = set()
219        self.ignore_event = (lambda path, name: False) if ignore_event is None else ignore_event
220
221    def watch_tree(self):
222        self.watched_dirs = {}
223        self.watched_rmap = {}
224        try:
225            self.add_watches(self.basedir)
226        except OSError as e:
227            if e.errno == errno.ENOSPC:
228                raise DirTooLarge(self.basedir)
229
230    def add_watches(self, base, top_level=True):
231        ''' Add watches for this directory and all its descendant directories,
232        recursively. '''
233        base = realpath(base)
234        # There may exist a link which leads to an endless
235        # add_watches loop or to maximum recursion depth exceeded
236        if not top_level and base in self.watched_dirs:
237            return
238        try:
239            is_dir = self.add_watch(base)
240        except OSError as e:
241            if e.errno == errno.ENOENT:
242                # The entry could have been deleted between listdir() and
243                # add_watch().
244                if top_level:
245                    raise NoSuchDir('The dir {} does not exist'.format(base))
246                return
247            if e.errno == errno.EACCES:
248                # We silently ignore entries for which we dont have permission,
249                # unless they are the top level dir
250                if top_level:
251                    raise NoSuchDir('You do not have permission to monitor {}'.format(base))
252                return
253            raise
254        else:
255            if is_dir:
256                try:
257                    files = os.listdir(base)
258                except OSError as e:
259                    if e.errno in (errno.ENOTDIR, errno.ENOENT):
260                        # The dir was deleted/replaced between the add_watch()
261                        # and listdir()
262                        if top_level:
263                            raise NoSuchDir('The dir {} does not exist'.format(base))
264                        return
265                    raise
266                for x in files:
267                    self.add_watches(os.path.join(base, x), top_level=False)
268            elif top_level:
269                # The top level dir is a file, not good.
270                raise NoSuchDir('The dir {} does not exist'.format(base))
271
272    def add_watch(self, path):
273        import ctypes
274        bpath = path if isinstance(path, bytes) else path.encode(self.fenc)
275        wd = self._add_watch(self._inotify_fd, ctypes.c_char_p(bpath),
276                # Ignore symlinks and watch only directories
277                self.DONT_FOLLOW | self.ONLYDIR |
278
279                self.MODIFY | self.CREATE | self.DELETE |
280                self.MOVE_SELF | self.MOVED_FROM | self.MOVED_TO |
281                self.ATTRIB | self.DELETE_SELF)
282        if wd == -1:
283            eno = ctypes.get_errno()
284            if eno == errno.ENOTDIR:
285                return False
286            raise OSError(eno, 'Failed to add watch for: {}: {}'.format(path, self.os.strerror(eno)))
287        self.watched_dirs[path] = wd
288        self.watched_rmap[wd] = path
289        return True
290
291    def process_event(self, wd, mask, cookie, name):
292        if wd == -1 and (mask & self.Q_OVERFLOW):
293            # We missed some INOTIFY events, so we dont
294            # know the state of any tracked dirs.
295            self.watch_tree()
296            self.modified.add(None)
297            return
298        path = self.watched_rmap.get(wd, None)
299        if path is not None:
300            if not self.ignore_event(path, name):
301                self.modified.add(os.path.join(path, name or ''))
302            if mask & self.CREATE:
303                # A new sub-directory might have been created, monitor it.
304                try:
305                    self.add_watch(os.path.join(path, name))
306                except OSError as e:
307                    if e.errno == errno.ENOENT:
308                        # Deleted before add_watch()
309                        pass
310                    elif e.errno == errno.ENOSPC:
311                        raise DirTooLarge(self.basedir)
312                    else:
313                        raise
314            if (mask & self.DELETE_SELF or mask & self.MOVE_SELF) and path == self.basedir:
315                raise BaseDirChanged('The directory %s was moved/deleted' % path)
316
317    def __call__(self):
318        self.read()
319        ret = self.modified
320        self.modified = set()
321        return ret
322
323
324if __name__ == '__main__':
325    w = INotifyTreeWatcher(sys.argv[-1])
326    w()
327    print('Monitoring', sys.argv[-1], 'press Ctrl-C to stop')
328    try:
329        while w.wait():
330            modified = w()
331            for path in modified:
332                print(path or sys.argv[-1], 'changed')
333        raise SystemExit('inotify flaked out')
334    except KeyboardInterrupt:
335        pass
336