Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
run.py439 linesDownload Raw Back to watchfiles
1import contextlib
2import json
3import logging
4import os
5import re
6import shlex
7import signal
8import subprocess
9import sys
10from importlib import import_module
11from multiprocessing import get_context
12from multiprocessing.context import SpawnProcess
13from pathlib import Path
14from time import sleep
15from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Union
16
17import anyio
18
19from .filters import DefaultFilter
20from .main import Change, FileChange, awatch, watch
21
22if TYPE_CHECKING:
23    from typing import Literal
24
25__all__ = 'run_process', 'arun_process', 'detect_target_type', 'import_string'
26logger = logging.getLogger('watchfiles.main')
27
28
29def run_process(
30    *paths: Union[Path, str],
31    target: Union[str, Callable[..., Any]],
32    args: Tuple[Any, ...] = (),
33    kwargs: Optional[Dict[str, Any]] = None,
34    target_type: "Literal['function', 'command', 'auto']" = 'auto',
35    callback: Optional[Callable[[Set[FileChange]], None]] = None,
36    watch_filter: Optional[Callable[[Change, str], bool]] = DefaultFilter(),
37    grace_period: float = 0,
38    debounce: int = 1_600,
39    step: int = 50,
40    debug: Optional[bool] = None,
41    sigint_timeout: int = 5,
42    sigkill_timeout: int = 1,
43    recursive: bool = True,
44    ignore_permission_denied: bool = False,
45) -> int:
46    """
47    Run a process and restart it upon file changes.
48
49    `run_process` can work in two ways:
50
51    * Using `multiprocessing.Process` † to run a python function
52    * Or, using `subprocess.Popen` to run a command
53
54    !!! note
55
56        **†** technically `multiprocessing.get_context('spawn').Process` to avoid forking and improve
57        code reload/import.
58
59    Internally, `run_process` uses [`watch`][watchfiles.watch] with `raise_interrupt=False` so the function
60    exits cleanly upon `Ctrl+C`.
61
62    Args:
63        *paths: matches the same argument of [`watch`][watchfiles.watch]
64        target: function or command to run
65        args: arguments to pass to `target`, only used if `target` is a function
66        kwargs: keyword arguments to pass to `target`, only used if `target` is a function
67        target_type: type of target. Can be `'function'`, `'command'`, or `'auto'` in which case
68            [`detect_target_type`][watchfiles.run.detect_target_type] is used to determine the type.
69        callback: function to call on each reload, the function should accept a set of changes as the sole argument
70        watch_filter: matches the same argument of [`watch`][watchfiles.watch]
71        grace_period: number of seconds after the process is started before watching for changes
72        debounce: matches the same argument of [`watch`][watchfiles.watch]
73        step: matches the same argument of [`watch`][watchfiles.watch]
74        debug: matches the same argument of [`watch`][watchfiles.watch]
75        sigint_timeout: the number of seconds to wait after sending sigint before sending sigkill
76        sigkill_timeout: the number of seconds to wait after sending sigkill before raising an exception
77        recursive: matches the same argument of [`watch`][watchfiles.watch]
78
79    Returns:
80        number of times the function was reloaded.
81
82    ```py title="Example of run_process running a function"
83    from watchfiles import run_process
84
85    def callback(changes):
86        print('changes detected:', changes)
87
88    def foobar(a, b):
89        print('foobar called with:', a, b)
90
91    if __name__ == '__main__':
92        run_process('./path/to/dir', target=foobar, args=(1, 2), callback=callback)
93    ```
94
95    As well as using a `callback` function, changes can be accessed from within the target function,
96    using the `WATCHFILES_CHANGES` environment variable.
97
98    ```py title="Example of run_process accessing changes"
99    from watchfiles import run_process
100
101    def foobar(a, b, c):
102        # changes will be an empty list "[]" the first time the function is called
103        changes = os.getenv('WATCHFILES_CHANGES')
104        changes = json.loads(changes)
105        print('foobar called due to changes:', changes)
106
107    if __name__ == '__main__':
108        run_process('./path/to/dir', target=foobar, args=(1, 2, 3))
109    ```
110
111    Again with the target as `command`, `WATCHFILES_CHANGES` can be used
112    to access changes.
113
114    ```bash title="example.sh"
115    echo "changers: ${WATCHFILES_CHANGES}"
116    ```
117
118    ```py title="Example of run_process running a command"
119    from watchfiles import run_process
120
121    if __name__ == '__main__':
122        run_process('.', target='./example.sh')
123    ```
124    """
125    if target_type == 'auto':
126        target_type = detect_target_type(target)
127
128    logger.debug('running "%s" as %s', target, target_type)
129    catch_sigterm()
130    process = start_process(target, target_type, args, kwargs)
131    reloads = 0
132
133    if grace_period:
134        logger.debug('sleeping for %s seconds before watching for changes', grace_period)
135        sleep(grace_period)
136
137    try:
138        for changes in watch(
139            *paths,
140            watch_filter=watch_filter,
141            debounce=debounce,
142            step=step,
143            debug=debug,
144            raise_interrupt=False,
145            recursive=recursive,
146            ignore_permission_denied=ignore_permission_denied,
147        ):
148            callback and callback(changes)
149            process.stop(sigint_timeout=sigint_timeout, sigkill_timeout=sigkill_timeout)
150            process = start_process(target, target_type, args, kwargs, changes)
151            reloads += 1
152    finally:
153        process.stop()
154    return reloads
155
156
157async def arun_process(
158    *paths: Union[Path, str],
159    target: Union[str, Callable[..., Any]],
160    args: Tuple[Any, ...] = (),
161    kwargs: Optional[Dict[str, Any]] = None,
162    target_type: "Literal['function', 'command', 'auto']" = 'auto',
163    callback: Optional[Callable[[Set[FileChange]], Any]] = None,
164    watch_filter: Optional[Callable[[Change, str], bool]] = DefaultFilter(),
165    grace_period: float = 0,
166    debounce: int = 1_600,
167    step: int = 50,
168    debug: Optional[bool] = None,
169    recursive: bool = True,
170    ignore_permission_denied: bool = False,
171) -> int:
172    """
173    Async equivalent of [`run_process`][watchfiles.run_process], all arguments match those of `run_process` except
174    `callback` which can be a coroutine.
175
176    Starting and stopping the process and watching for changes is done in a separate thread.
177
178    As with `run_process`, internally `arun_process` uses [`awatch`][watchfiles.awatch], however `KeyboardInterrupt`
179    cannot be caught and suppressed in `awatch` so these errors need to be caught separately, see below.
180
181    ```py title="Example of arun_process usage"
182    import asyncio
183    from watchfiles import arun_process
184
185    async def callback(changes):
186        await asyncio.sleep(0.1)
187        print('changes detected:', changes)
188
189    def foobar(a, b):
190        print('foobar called with:', a, b)
191
192    async def main():
193        await arun_process('.', target=foobar, args=(1, 2), callback=callback)
194
195    if __name__ == '__main__':
196        try:
197            asyncio.run(main())
198        except KeyboardInterrupt:
199            print('stopped via KeyboardInterrupt')
200    ```
201    """
202    import inspect
203
204    if target_type == 'auto':
205        target_type = detect_target_type(target)
206
207    logger.debug('running "%s" as %s', target, target_type)
208    catch_sigterm()
209    process = await anyio.to_thread.run_sync(start_process, target, target_type, args, kwargs)
210    reloads = 0
211
212    if grace_period:
213        logger.debug('sleeping for %s seconds before watching for changes', grace_period)
214        await anyio.sleep(grace_period)
215
216    async for changes in awatch(
217        *paths,
218        watch_filter=watch_filter,
219        debounce=debounce,
220        step=step,
221        debug=debug,
222        recursive=recursive,
223        ignore_permission_denied=ignore_permission_denied,
224    ):
225        if callback is not None:
226            r = callback(changes)
227            if inspect.isawaitable(r):
228                await r
229
230        await anyio.to_thread.run_sync(process.stop)
231        process = await anyio.to_thread.run_sync(start_process, target, target_type, args, kwargs, changes)
232        reloads += 1
233    await anyio.to_thread.run_sync(process.stop)
234    return reloads
235
236
237# Use spawn context to make sure code run in subprocess
238# does not reuse imported modules in main process/context
239spawn_context = get_context('spawn')
240
241
242def split_cmd(cmd: str) -> List[str]:
243    import platform
244
245    posix = platform.uname().system.lower() != 'windows'
246    return shlex.split(cmd, posix=posix)
247
248
249def start_process(
250    target: Union[str, Callable[..., Any]],
251    target_type: "Literal['function', 'command']",
252    args: Tuple[Any, ...],
253    kwargs: Optional[Dict[str, Any]],
254    changes: Optional[Set[FileChange]] = None,
255) -> 'CombinedProcess':
256    if changes is None:
257        changes_env_var = '[]'
258    else:
259        changes_env_var = json.dumps([[c.raw_str(), p] for c, p in changes])
260
261    os.environ['WATCHFILES_CHANGES'] = changes_env_var
262
263    process: Union[SpawnProcess, subprocess.Popen[bytes]]
264    if target_type == 'function':
265        kwargs = kwargs or {}
266        if isinstance(target, str):
267            args = target, get_tty_path(), args, kwargs
268            target_ = run_function
269            kwargs = {}
270        else:
271            target_ = target
272
273        process = spawn_context.Process(target=target_, args=args, kwargs=kwargs)
274        process.start()
275    else:
276        if args or kwargs:
277            logger.warning('ignoring args and kwargs for "command" target')
278
279        assert isinstance(target, str), 'target must be a string to run as a command'
280        popen_args = split_cmd(target)
281        process = subprocess.Popen(popen_args)
282    return CombinedProcess(process)
283
284
285def detect_target_type(target: Union[str, Callable[..., Any]]) -> "Literal['function', 'command']":
286    """
287    Used by [`run_process`][watchfiles.run_process], [`arun_process`][watchfiles.arun_process]
288    and indirectly the CLI to determine the target type with `target_type` is `auto`.
289
290    Detects the target type - either `function` or `command`. This method is only called with `target_type='auto'`.
291
292    The following logic is employed:
293
294    * If `target` is not a string, it is assumed to be a function
295    * If `target` ends with `.py` or `.sh`, it is assumed to be a command
296    * Otherwise, the target is assumed to be a function if it matches the regex `[a-zA-Z0-9_]+(\\.[a-zA-Z0-9_]+)+`
297
298    If this logic does not work for you, specify the target type explicitly using the `target_type` function argument
299    or `--target-type` command line argument.
300
301    Args:
302        target: The target value
303
304    Returns:
305        either `'function'` or `'command'`
306    """
307    if not isinstance(target, str):
308        return 'function'
309    elif target.endswith(('.py', '.sh')):
310        return 'command'
311    elif re.fullmatch(r'[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)+', target):
312        return 'function'
313    else:
314        return 'command'
315
316
317class CombinedProcess:
318    def __init__(self, p: 'Union[SpawnProcess, subprocess.Popen[bytes]]'):
319        self._p = p
320        assert self.pid is not None, 'process not yet spawned'
321
322    def stop(self, sigint_timeout: int = 5, sigkill_timeout: int = 1) -> None:
323        os.environ.pop('WATCHFILES_CHANGES', None)
324        if self.is_alive():
325            logger.debug('stopping process...')
326
327            os.kill(self.pid, signal.SIGINT)
328
329            try:
330                self.join(sigint_timeout)
331            except subprocess.TimeoutExpired:
332                # Capture this exception to allow the self.exitcode to be reached.
333                # This will allow the SIGKILL to be sent, otherwise it is swallowed up.
334                logger.warning('SIGINT timed out after %r seconds', sigint_timeout)
335                pass
336
337            if self.exitcode is None:
338                logger.warning('process has not terminated, sending SIGKILL')
339                os.kill(self.pid, signal.SIGKILL)
340                self.join(sigkill_timeout)
341            else:
342                logger.debug('process stopped')
343        else:
344            logger.warning('process already dead, exit code: %d', self.exitcode)
345
346    def is_alive(self) -> bool:
347        if isinstance(self._p, SpawnProcess):
348            return self._p.is_alive()
349        else:
350            return self._p.poll() is None
351
352    @property
353    def pid(self) -> int:
354        # we check the process has always been spawned when CombinedProcess is initialised
355        return self._p.pid  # type: ignore[return-value]
356
357    def join(self, timeout: int) -> None:
358        if isinstance(self._p, SpawnProcess):
359            self._p.join(timeout)
360        else:
361            self._p.wait(timeout)
362
363    @property
364    def exitcode(self) -> Optional[int]:
365        if isinstance(self._p, SpawnProcess):
366            return self._p.exitcode
367        else:
368            return self._p.returncode
369
370
371def run_function(function: str, tty_path: Optional[str], args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> None:
372    with set_tty(tty_path):
373        func = import_string(function)
374        func(*args, **kwargs)
375
376
377def import_string(dotted_path: str) -> Any:
378    """
379    Stolen approximately from django. Import a dotted module path and return the attribute/class designated by the
380    last name in the path. Raise ImportError if the import fails.
381    """
382    try:
383        module_path, class_name = dotted_path.strip(' ').rsplit('.', 1)
384    except ValueError as e:
385        raise ImportError(f'"{dotted_path}" doesn\'t look like a module path') from e
386
387    module = import_module(module_path)
388    try:
389        return getattr(module, class_name)
390    except AttributeError as e:
391        raise ImportError(f'Module "{module_path}" does not define a "{class_name}" attribute') from e
392
393
394def get_tty_path() -> Optional[str]:  # pragma: no cover
395    """
396    Return the path to the current TTY, if any.
397
398    Virtually impossible to test in pytest, hence no cover.
399    """
400    try:
401        return os.ttyname(sys.stdin.fileno())
402    except OSError:
403        # fileno() always fails with pytest
404        return '/dev/tty'
405    except AttributeError:
406        # on Windows. No idea of a better solution
407        return None
408
409
410@contextlib.contextmanager
411def set_tty(tty_path: Optional[str]) -> Generator[None, None, None]:
412    if tty_path:
413        try:
414            with open(tty_path) as tty:  # pragma: no cover
415                sys.stdin = tty
416                yield
417        except OSError:
418            # eg. "No such device or address: '/dev/tty'", see https://github.com/samuelcolvin/watchfiles/issues/40
419            yield
420    else:
421        # currently on windows tty_path is None and there's nothing we can do here
422        yield
423
424
425def raise_keyboard_interrupt(signum: int, _frame: Any) -> None:  # pragma: no cover
426    logger.warning('received signal %s, raising KeyboardInterrupt', signal.Signals(signum))
427    raise KeyboardInterrupt
428
429
430def catch_sigterm() -> None:
431    """
432    Catch SIGTERM and raise KeyboardInterrupt instead. This means watchfiles will stop quickly
433    on `docker compose stop` and other cases where SIGTERM is sent.
434
435    Without this the watchfiles process will be killed while a running process will continue uninterrupted.
436    """
437    logger.debug('registering handler for SIGTERM on watchfiles process %d', os.getpid())
438    signal.signal(signal.SIGTERM, raise_keyboard_interrupt)
439 
codekingpro/portable-devtools · Team Ai