Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
main.py374 linesDownload Raw Back to watchfiles
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 
codekingpro/portable-devtools · Team Ai