watcher.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. # -*- coding: utf-8 -*-
  2. """
  3. livereload.watcher
  4. ~~~~~~~~~~~~~~~~~~
  5. A file watch management for LiveReload Server.
  6. :copyright: (c) 2013 - 2015 by Hsiaoming Yang
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. import glob
  10. import logging
  11. import os
  12. import time
  13. import sys
  14. if sys.version_info.major < 3:
  15. import inspect
  16. else:
  17. from inspect import signature
  18. try:
  19. import pyinotify
  20. except ImportError:
  21. pyinotify = None
  22. logger = logging.getLogger('livereload')
  23. class Watcher(object):
  24. """A file watcher registry."""
  25. def __init__(self):
  26. self._tasks = {}
  27. # modification time of filepaths for each task,
  28. # before and after checking for changes
  29. self._task_mtimes = {}
  30. self._new_mtimes = {}
  31. # setting changes
  32. self._changes = []
  33. # filepath that is changed
  34. self.filepath = None
  35. self._start = time.time()
  36. # list of ignored dirs
  37. self.ignored_dirs = ['.git', '.hg', '.svn', '.cvs']
  38. def ignore_dirs(self, *args):
  39. self.ignored_dirs.extend(args)
  40. def remove_dirs_from_ignore(self, *args):
  41. for a in args:
  42. self.ignored_dirs.remove(a)
  43. def ignore(self, filename):
  44. """Ignore a given filename or not."""
  45. _, ext = os.path.splitext(filename)
  46. return ext in ['.pyc', '.pyo', '.o', '.swp']
  47. def watch(self, path, func=None, delay=0, ignore=None):
  48. """Add a task to watcher.
  49. :param path: a filepath or directory path or glob pattern
  50. :param func: the function to be executed when file changed
  51. :param delay: Delay sending the reload message. Use 'forever' to
  52. not send it. This is useful to compile sass files to
  53. css, but reload on changed css files then only.
  54. :param ignore: A function return True to ignore a certain pattern of
  55. filepath.
  56. """
  57. self._tasks[path] = {
  58. 'func': func,
  59. 'delay': delay,
  60. 'ignore': ignore,
  61. 'mtimes': {},
  62. }
  63. def start(self, callback):
  64. """Start the watcher running, calling callback when changes are
  65. observed. If this returns False, regular polling will be used."""
  66. return False
  67. def examine(self):
  68. """Check if there are changes. If so, run the given task.
  69. Returns a tuple of modified filepath and reload delay.
  70. """
  71. if self._changes:
  72. return self._changes.pop()
  73. # clean filepath
  74. self.filepath = None
  75. delays = set()
  76. for path in self._tasks:
  77. item = self._tasks[path]
  78. self._task_mtimes = item['mtimes']
  79. changed = self.is_changed(path, item['ignore'])
  80. if changed:
  81. func = item['func']
  82. delay = item['delay']
  83. if delay and isinstance(delay, float):
  84. delays.add(delay)
  85. if func:
  86. name = getattr(func, 'name', None)
  87. if not name:
  88. name = getattr(func, '__name__', 'anonymous')
  89. logger.info(
  90. "Running task: {} (delay: {})".format(name, delay))
  91. if sys.version_info.major < 3:
  92. sig_len = len(inspect.getargspec(func)[0])
  93. else:
  94. sig_len = len(signature(func).parameters)
  95. if sig_len > 0 and isinstance(changed, list):
  96. func(changed)
  97. else:
  98. func()
  99. if delays:
  100. delay = max(delays)
  101. else:
  102. delay = None
  103. return self.filepath, delay
  104. def is_changed(self, path, ignore=None):
  105. """Check if any filepaths have been added, modified, or removed.
  106. Updates filepath modification times in self._task_mtimes.
  107. """
  108. self._new_mtimes = {}
  109. changed = False
  110. if os.path.isfile(path):
  111. changed = self.is_file_changed(path, ignore)
  112. elif os.path.isdir(path):
  113. changed = self.is_folder_changed(path, ignore)
  114. else:
  115. changed = self.get_changed_glob_files(path, ignore)
  116. if not changed:
  117. changed = self.is_file_removed()
  118. self._task_mtimes.update(self._new_mtimes)
  119. return changed
  120. def is_file_removed(self):
  121. """Check if any filepaths have been removed since last check.
  122. Deletes removed paths from self._task_mtimes.
  123. Sets self.filepath to one of the removed paths.
  124. """
  125. removed_paths = set(self._task_mtimes) - set(self._new_mtimes)
  126. if not removed_paths:
  127. return False
  128. for path in removed_paths:
  129. self._task_mtimes.pop(path)
  130. # self.filepath seems purely informational, so setting one
  131. # of several removed files seems sufficient
  132. self.filepath = path
  133. return True
  134. def is_file_changed(self, path, ignore=None):
  135. """Check if filepath has been added or modified since last check.
  136. Updates filepath modification times in self._new_mtimes.
  137. Sets self.filepath to changed path.
  138. """
  139. if not os.path.isfile(path):
  140. return False
  141. if self.ignore(path):
  142. return False
  143. if ignore and ignore(path):
  144. return False
  145. mtime = os.path.getmtime(path)
  146. if path not in self._task_mtimes:
  147. self._new_mtimes[path] = mtime
  148. self.filepath = path
  149. return mtime > self._start
  150. if self._task_mtimes[path] != mtime:
  151. self._new_mtimes[path] = mtime
  152. self.filepath = path
  153. return True
  154. self._new_mtimes[path] = mtime
  155. return False
  156. def is_folder_changed(self, path, ignore=None):
  157. """Check if directory path has any changed filepaths."""
  158. for root, dirs, files in os.walk(path, followlinks=True):
  159. for d in self.ignored_dirs:
  160. if d in dirs:
  161. dirs.remove(d)
  162. for f in files:
  163. if self.is_file_changed(os.path.join(root, f), ignore):
  164. return True
  165. return False
  166. def get_changed_glob_files(self, path, ignore=None):
  167. """Check if glob path has any changed filepaths."""
  168. if sys.version_info[0] >=3 and sys.version_info[1] >=5:
  169. files = glob.glob(path, recursive=True)
  170. else:
  171. files = glob.glob(path)
  172. changed_files = [f for f in files if self.is_file_changed(f, ignore)]
  173. return changed_files
  174. class INotifyWatcher(Watcher):
  175. def __init__(self):
  176. Watcher.__init__(self)
  177. self.wm = pyinotify.WatchManager()
  178. self.notifier = None
  179. self.callback = None
  180. def watch(self, path, func=None, delay=None, ignore=None):
  181. flag = pyinotify.IN_CREATE | pyinotify.IN_DELETE | pyinotify.IN_MODIFY
  182. self.wm.add_watch(path, flag, rec=True, do_glob=True, auto_add=True)
  183. Watcher.watch(self, path, func, delay, ignore)
  184. def inotify_event(self, event):
  185. self.callback()
  186. def start(self, callback):
  187. if not self.notifier:
  188. self.callback = callback
  189. from tornado import ioloop
  190. self.notifier = pyinotify.TornadoAsyncNotifier(
  191. self.wm, ioloop.IOLoop.instance(),
  192. default_proc_fun=self.inotify_event
  193. )
  194. callback()
  195. return True
  196. def get_watcher_class():
  197. if pyinotify is None or not hasattr(pyinotify, 'TornadoAsyncNotifier'):
  198. return Watcher
  199. return INotifyWatcher