main.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import logging
  2. import os
  3. import sys
  4. import warnings
  5. from collections.abc import AsyncGenerator, Callable, Generator
  6. from enum import IntEnum
  7. from pathlib import Path
  8. from typing import TYPE_CHECKING
  9. import anyio
  10. from ._rust_notify import RustNotify
  11. from .filters import DefaultFilter
  12. __all__ = 'watch', 'awatch', 'Change', 'FileChange'
  13. logger = logging.getLogger('watchfiles.main')
  14. class Change(IntEnum):
  15. """
  16. Enum representing the type of change that occurred.
  17. """
  18. added = 1
  19. """A new file or directory was added."""
  20. modified = 2
  21. """A file or directory was modified, can be either a metadata or data change."""
  22. deleted = 3
  23. """A file or directory was deleted."""
  24. def raw_str(self) -> str:
  25. return self.name
  26. FileChange = tuple[Change, str]
  27. """
  28. A tuple representing a file change, first element is a [`Change`][watchfiles.Change] member, second is the path
  29. of the file or directory that changed.
  30. """
  31. if TYPE_CHECKING:
  32. import asyncio
  33. from typing import Protocol
  34. import trio
  35. AnyEvent = anyio.Event | asyncio.Event | trio.Event
  36. class AbstractEvent(Protocol):
  37. def is_set(self) -> bool: ...
  38. def watch(
  39. *paths: Path | str,
  40. watch_filter: Callable[['Change', str], bool] | None = DefaultFilter(),
  41. debounce: int = 1_600,
  42. step: int = 50,
  43. stop_event: 'AbstractEvent | None' = None,
  44. rust_timeout: int = 5_000,
  45. yield_on_timeout: bool = False,
  46. debug: bool | None = None,
  47. raise_interrupt: bool = True,
  48. force_polling: bool | None = None,
  49. poll_delay_ms: int = 300,
  50. recursive: bool = True,
  51. ignore_permission_denied: bool | None = None,
  52. ) -> Generator[set[FileChange], None, None]:
  53. """
  54. Watch one or more paths and yield a set of changes whenever files change.
  55. The paths watched can be directories or files, directories are watched recursively - changes in subdirectories
  56. are also detected.
  57. #### Force polling
  58. Notify will fall back to file polling if it can't use file system notifications, but we also force Notify
  59. to use polling if the `force_polling` argument is `True`; if `force_polling` is unset (or `None`), we enable
  60. force polling thus:
  61. * if the `WATCHFILES_FORCE_POLLING` environment variable exists and is not empty:
  62. * if the value is `false`, `disable` or `disabled`, force polling is disabled
  63. * otherwise, force polling is enabled
  64. * otherwise, we enable force polling only if we detect we're running on WSL (Windows Subsystem for Linux)
  65. It is also possible to change the poll delay between iterations, it can be changed to maintain a good response time
  66. and an appropiate CPU consumption using the `poll_delay_ms` argument, we change poll delay thus:
  67. * if file polling is enabled and the `WATCHFILES_POLL_DELAY_MS` env var exists and it is numeric, we use that
  68. * otherwise, we use the argument value
  69. Args:
  70. *paths: filesystem paths to watch.
  71. watch_filter: callable used to filter out changes which are not important, you can either use a raw callable
  72. or a [`BaseFilter`][watchfiles.BaseFilter] instance,
  73. defaults to an instance of [`DefaultFilter`][watchfiles.DefaultFilter]. To keep all changes, use `None`.
  74. debounce: maximum time in milliseconds to group changes over before yielding them.
  75. step: time to wait for new changes in milliseconds, if no changes are detected in this time, and
  76. at least one change has been detected, the changes are yielded.
  77. stop_event: event to stop watching, if this is set, the generator will stop iteration,
  78. this can be anything with an `is_set()` method which returns a bool, e.g. `threading.Event()`.
  79. rust_timeout: maximum time in milliseconds to wait in the rust code for changes, `0` means no timeout.
  80. yield_on_timeout: if `True`, the generator will yield upon timeout in rust even if no changes are detected.
  81. debug: whether to print information about all filesystem changes in rust to stdout, if `None` will use the
  82. `WATCHFILES_DEBUG` environment variable.
  83. raise_interrupt: whether to re-raise `KeyboardInterrupt`s, or suppress the error and just stop iterating.
  84. force_polling: See [Force polling](#force-polling) above.
  85. poll_delay_ms: delay between polling for changes, only used if `force_polling=True`.
  86. recursive: if `True`, watch for changes in sub-directories recursively, otherwise watch only for changes in the
  87. top-level directory, default is `True`.
  88. ignore_permission_denied: if `True`, will ignore permission denied errors, otherwise will raise them by default.
  89. Setting the `WATCHFILES_IGNORE_PERMISSION_DENIED` environment variable will set this value too.
  90. Yields:
  91. The generator yields sets of [`FileChange`][watchfiles.main.FileChange]s.
  92. ```py title="Example of watch usage"
  93. from watchfiles import watch
  94. for changes in watch('./first/dir', './second/dir', raise_interrupt=False):
  95. print(changes)
  96. ```
  97. """
  98. force_polling = _default_force_polling(force_polling)
  99. poll_delay_ms = _default_poll_delay_ms(poll_delay_ms)
  100. ignore_permission_denied = _default_ignore_permission_denied(ignore_permission_denied)
  101. debug = _default_debug(debug)
  102. with RustNotify(
  103. [str(p) for p in paths], debug, force_polling, poll_delay_ms, recursive, ignore_permission_denied
  104. ) as watcher:
  105. while True:
  106. raw_changes = watcher.watch(debounce, step, rust_timeout, stop_event)
  107. if raw_changes == 'timeout':
  108. if yield_on_timeout:
  109. yield set()
  110. else:
  111. logger.debug('rust notify timeout, continuing')
  112. elif raw_changes == 'signal':
  113. if raise_interrupt:
  114. raise KeyboardInterrupt
  115. else:
  116. logger.warning('KeyboardInterrupt caught, stopping watch')
  117. return
  118. elif raw_changes == 'stop':
  119. return
  120. else:
  121. changes = _prep_changes(raw_changes, watch_filter)
  122. if changes:
  123. _log_changes(changes)
  124. yield changes
  125. else:
  126. logger.debug('all changes filtered out, raw_changes=%s', raw_changes)
  127. async def awatch( # C901
  128. *paths: Path | str,
  129. watch_filter: Callable[[Change, str], bool] | None = DefaultFilter(),
  130. debounce: int = 1_600,
  131. step: int = 50,
  132. stop_event: 'AnyEvent | None' = None,
  133. rust_timeout: int | None = None,
  134. yield_on_timeout: bool = False,
  135. debug: bool | None = None,
  136. raise_interrupt: bool | None = None,
  137. force_polling: bool | None = None,
  138. poll_delay_ms: int = 300,
  139. recursive: bool = True,
  140. ignore_permission_denied: bool | None = None,
  141. ) -> AsyncGenerator[set[FileChange], None]:
  142. """
  143. Asynchronous equivalent of [`watch`][watchfiles.watch] using threads to wait for changes.
  144. Arguments match those of [`watch`][watchfiles.watch] except `stop_event`.
  145. All async methods use [anyio](https://anyio.readthedocs.io/en/latest/) to run the event loop.
  146. Unlike [`watch`][watchfiles.watch] `KeyboardInterrupt` cannot be suppressed by `awatch` so they need to be caught
  147. where `asyncio.run` or equivalent is called.
  148. Args:
  149. *paths: filesystem paths to watch.
  150. watch_filter: matches the same argument of [`watch`][watchfiles.watch].
  151. debounce: matches the same argument of [`watch`][watchfiles.watch].
  152. step: matches the same argument of [`watch`][watchfiles.watch].
  153. stop_event: `anyio.Event` which can be used to stop iteration, see example below.
  154. rust_timeout: matches the same argument of [`watch`][watchfiles.watch], except that `None` means
  155. use `1_000` on Windows and `5_000` on other platforms thus helping with exiting on `Ctrl+C` on Windows,
  156. see [#110](https://github.com/samuelcolvin/watchfiles/issues/110).
  157. yield_on_timeout: matches the same argument of [`watch`][watchfiles.watch].
  158. debug: matches the same argument of [`watch`][watchfiles.watch].
  159. raise_interrupt: This is deprecated, `KeyboardInterrupt` will cause this coroutine to be cancelled and then
  160. be raised by the top level `asyncio.run` call or equivalent, and should be caught there.
  161. See [#136](https://github.com/samuelcolvin/watchfiles/issues/136)
  162. force_polling: if true, always use polling instead of file system notifications, default is `None` where
  163. `force_polling` is set to `True` if the `WATCHFILES_FORCE_POLLING` environment variable exists.
  164. poll_delay_ms: delay between polling for changes, only used if `force_polling=True`.
  165. `poll_delay_ms` can be changed via the `WATCHFILES_POLL_DELAY_MS` environment variable.
  166. recursive: if `True`, watch for changes in sub-directories recursively, otherwise watch only for changes in the
  167. top-level directory, default is `True`.
  168. ignore_permission_denied: if `True`, will ignore permission denied errors, otherwise will raise them by default.
  169. Setting the `WATCHFILES_IGNORE_PERMISSION_DENIED` environment variable will set this value too.
  170. Yields:
  171. The generator yields sets of [`FileChange`][watchfiles.main.FileChange]s.
  172. ```py title="Example of awatch usage"
  173. import asyncio
  174. from watchfiles import awatch
  175. async def main():
  176. async for changes in awatch('./first/dir', './second/dir'):
  177. print(changes)
  178. if __name__ == '__main__':
  179. try:
  180. asyncio.run(main())
  181. except KeyboardInterrupt:
  182. print('stopped via KeyboardInterrupt')
  183. ```
  184. ```py title="Example of awatch usage with a stop event"
  185. import asyncio
  186. from watchfiles import awatch
  187. async def main():
  188. stop_event = asyncio.Event()
  189. async def stop_soon():
  190. await asyncio.sleep(3)
  191. stop_event.set()
  192. stop_soon_task = asyncio.create_task(stop_soon())
  193. async for changes in awatch('/path/to/dir', stop_event=stop_event):
  194. print(changes)
  195. # cleanup by awaiting the (now complete) stop_soon_task
  196. await stop_soon_task
  197. asyncio.run(main())
  198. ```
  199. """
  200. if raise_interrupt is not None:
  201. warnings.warn(
  202. 'raise_interrupt is deprecated, KeyboardInterrupt will cause this coroutine to be cancelled and then '
  203. 'be raised by the top level asyncio.run call or equivalent, and should be caught there. See #136.',
  204. DeprecationWarning,
  205. )
  206. if stop_event is None:
  207. stop_event_: AnyEvent = anyio.Event()
  208. else:
  209. stop_event_ = stop_event
  210. force_polling = _default_force_polling(force_polling)
  211. poll_delay_ms = _default_poll_delay_ms(poll_delay_ms)
  212. ignore_permission_denied = _default_ignore_permission_denied(ignore_permission_denied)
  213. debug = _default_debug(debug)
  214. with RustNotify(
  215. [str(p) for p in paths], debug, force_polling, poll_delay_ms, recursive, ignore_permission_denied
  216. ) as watcher:
  217. timeout = _calc_async_timeout(rust_timeout)
  218. CancelledError = anyio.get_cancelled_exc_class()
  219. while True:
  220. async with anyio.create_task_group() as tg:
  221. try:
  222. raw_changes = await anyio.to_thread.run_sync(watcher.watch, debounce, step, timeout, stop_event_)
  223. except (CancelledError, KeyboardInterrupt):
  224. stop_event_.set()
  225. # suppressing KeyboardInterrupt wouldn't stop it getting raised by the top level asyncio.run call
  226. raise
  227. tg.cancel_scope.cancel()
  228. if raw_changes == 'timeout':
  229. if yield_on_timeout:
  230. yield set()
  231. else:
  232. logger.debug('rust notify timeout, continuing')
  233. elif raw_changes == 'stop':
  234. return
  235. elif raw_changes == 'signal':
  236. # in theory the watch thread should never get a signal
  237. raise RuntimeError('watch thread unexpectedly received a signal')
  238. else:
  239. changes = _prep_changes(raw_changes, watch_filter)
  240. if changes:
  241. _log_changes(changes)
  242. yield changes
  243. else:
  244. logger.debug('all changes filtered out, raw_changes=%s', raw_changes)
  245. def _prep_changes(
  246. raw_changes: set[tuple[int, str]], watch_filter: Callable[[Change, str], bool] | None
  247. ) -> set[FileChange]:
  248. # if we wanted to be really snazzy, we could move this into rust
  249. changes = {(Change(change), path) for change, path in raw_changes}
  250. if watch_filter:
  251. changes = {c for c in changes if watch_filter(c[0], c[1])}
  252. return changes
  253. def _log_changes(changes: set[FileChange]) -> None:
  254. if logger.isEnabledFor(logging.INFO): # pragma: no branch
  255. count = len(changes)
  256. plural = '' if count == 1 else 's'
  257. if logger.isEnabledFor(logging.DEBUG):
  258. logger.debug('%d change%s detected: %s', count, plural, changes)
  259. else:
  260. logger.info('%d change%s detected', count, plural)
  261. def _calc_async_timeout(timeout: int | None) -> int:
  262. """
  263. see https://github.com/samuelcolvin/watchfiles/issues/110
  264. """
  265. if timeout is None:
  266. if sys.platform == 'win32':
  267. return 1_000
  268. else:
  269. return 5_000
  270. else:
  271. return timeout
  272. def _default_force_polling(force_polling: bool | None) -> bool:
  273. """
  274. See docstring for `watch` above for details.
  275. See samuelcolvin/watchfiles#167 and samuelcolvin/watchfiles#187 for discussion and rationale.
  276. """
  277. if force_polling is not None:
  278. return force_polling
  279. env_var = os.getenv('WATCHFILES_FORCE_POLLING')
  280. if env_var:
  281. return env_var.lower() not in {'false', 'disable', 'disabled'}
  282. else:
  283. return _auto_force_polling()
  284. def _default_poll_delay_ms(poll_delay_ms: int) -> int:
  285. """
  286. See docstring for `watch` above for details.
  287. """
  288. env_var = os.getenv('WATCHFILES_POLL_DELAY_MS')
  289. if env_var and env_var.isdecimal():
  290. return int(env_var)
  291. else:
  292. return poll_delay_ms
  293. def _default_debug(debug: bool | None) -> bool:
  294. if debug is not None:
  295. return debug
  296. env_var = os.getenv('WATCHFILES_DEBUG')
  297. return bool(env_var)
  298. def _auto_force_polling() -> bool:
  299. """
  300. Whether to auto-enable force polling, it should be enabled automatically only on WSL.
  301. See samuelcolvin/watchfiles#187 for discussion.
  302. """
  303. import platform
  304. uname = platform.uname()
  305. return 'microsoft-standard' in uname.release.lower() and uname.system.lower() == 'linux'
  306. def _default_ignore_permission_denied(ignore_permission_denied: bool | None) -> bool:
  307. if ignore_permission_denied is not None:
  308. return ignore_permission_denied
  309. env_var = os.getenv('WATCHFILES_IGNORE_PERMISSION_DENIED')
  310. return bool(env_var)