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