439 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			439 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
import contextlib
 | 
						|
import json
 | 
						|
import logging
 | 
						|
import os
 | 
						|
import re
 | 
						|
import shlex
 | 
						|
import signal
 | 
						|
import subprocess
 | 
						|
import sys
 | 
						|
from importlib import import_module
 | 
						|
from multiprocessing import get_context
 | 
						|
from multiprocessing.context import SpawnProcess
 | 
						|
from pathlib import Path
 | 
						|
from time import sleep
 | 
						|
from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Union
 | 
						|
 | 
						|
import anyio
 | 
						|
 | 
						|
from .filters import DefaultFilter
 | 
						|
from .main import Change, FileChange, awatch, watch
 | 
						|
 | 
						|
if TYPE_CHECKING:
 | 
						|
    from typing import Literal
 | 
						|
 | 
						|
__all__ = 'run_process', 'arun_process', 'detect_target_type', 'import_string'
 | 
						|
logger = logging.getLogger('watchfiles.main')
 | 
						|
 | 
						|
 | 
						|
def run_process(
 | 
						|
    *paths: Union[Path, str],
 | 
						|
    target: Union[str, Callable[..., Any]],
 | 
						|
    args: Tuple[Any, ...] = (),
 | 
						|
    kwargs: Optional[Dict[str, Any]] = None,
 | 
						|
    target_type: "Literal['function', 'command', 'auto']" = 'auto',
 | 
						|
    callback: Optional[Callable[[Set[FileChange]], None]] = None,
 | 
						|
    watch_filter: Optional[Callable[[Change, str], bool]] = DefaultFilter(),
 | 
						|
    grace_period: float = 0,
 | 
						|
    debounce: int = 1_600,
 | 
						|
    step: int = 50,
 | 
						|
    debug: Optional[bool] = None,
 | 
						|
    sigint_timeout: int = 5,
 | 
						|
    sigkill_timeout: int = 1,
 | 
						|
    recursive: bool = True,
 | 
						|
    ignore_permission_denied: bool = False,
 | 
						|
) -> int:
 | 
						|
    """
 | 
						|
    Run a process and restart it upon file changes.
 | 
						|
 | 
						|
    `run_process` can work in two ways:
 | 
						|
 | 
						|
    * Using `multiprocessing.Process` † to run a python function
 | 
						|
    * Or, using `subprocess.Popen` to run a command
 | 
						|
 | 
						|
    !!! note
 | 
						|
 | 
						|
        **†** technically `multiprocessing.get_context('spawn').Process` to avoid forking and improve
 | 
						|
        code reload/import.
 | 
						|
 | 
						|
    Internally, `run_process` uses [`watch`][watchfiles.watch] with `raise_interrupt=False` so the function
 | 
						|
    exits cleanly upon `Ctrl+C`.
 | 
						|
 | 
						|
    Args:
 | 
						|
        *paths: matches the same argument of [`watch`][watchfiles.watch]
 | 
						|
        target: function or command to run
 | 
						|
        args: arguments to pass to `target`, only used if `target` is a function
 | 
						|
        kwargs: keyword arguments to pass to `target`, only used if `target` is a function
 | 
						|
        target_type: type of target. Can be `'function'`, `'command'`, or `'auto'` in which case
 | 
						|
            [`detect_target_type`][watchfiles.run.detect_target_type] is used to determine the type.
 | 
						|
        callback: function to call on each reload, the function should accept a set of changes as the sole argument
 | 
						|
        watch_filter: matches the same argument of [`watch`][watchfiles.watch]
 | 
						|
        grace_period: number of seconds after the process is started before watching for changes
 | 
						|
        debounce: matches the same argument of [`watch`][watchfiles.watch]
 | 
						|
        step: matches the same argument of [`watch`][watchfiles.watch]
 | 
						|
        debug: matches the same argument of [`watch`][watchfiles.watch]
 | 
						|
        sigint_timeout: the number of seconds to wait after sending sigint before sending sigkill
 | 
						|
        sigkill_timeout: the number of seconds to wait after sending sigkill before raising an exception
 | 
						|
        recursive: matches the same argument of [`watch`][watchfiles.watch]
 | 
						|
 | 
						|
    Returns:
 | 
						|
        number of times the function was reloaded.
 | 
						|
 | 
						|
    ```py title="Example of run_process running a function"
 | 
						|
    from watchfiles import run_process
 | 
						|
 | 
						|
    def callback(changes):
 | 
						|
        print('changes detected:', changes)
 | 
						|
 | 
						|
    def foobar(a, b):
 | 
						|
        print('foobar called with:', a, b)
 | 
						|
 | 
						|
    if __name__ == '__main__':
 | 
						|
        run_process('./path/to/dir', target=foobar, args=(1, 2), callback=callback)
 | 
						|
    ```
 | 
						|
 | 
						|
    As well as using a `callback` function, changes can be accessed from within the target function,
 | 
						|
    using the `WATCHFILES_CHANGES` environment variable.
 | 
						|
 | 
						|
    ```py title="Example of run_process accessing changes"
 | 
						|
    from watchfiles import run_process
 | 
						|
 | 
						|
    def foobar(a, b, c):
 | 
						|
        # changes will be an empty list "[]" the first time the function is called
 | 
						|
        changes = os.getenv('WATCHFILES_CHANGES')
 | 
						|
        changes = json.loads(changes)
 | 
						|
        print('foobar called due to changes:', changes)
 | 
						|
 | 
						|
    if __name__ == '__main__':
 | 
						|
        run_process('./path/to/dir', target=foobar, args=(1, 2, 3))
 | 
						|
    ```
 | 
						|
 | 
						|
    Again with the target as `command`, `WATCHFILES_CHANGES` can be used
 | 
						|
    to access changes.
 | 
						|
 | 
						|
    ```bash title="example.sh"
 | 
						|
    echo "changers: ${WATCHFILES_CHANGES}"
 | 
						|
    ```
 | 
						|
 | 
						|
    ```py title="Example of run_process running a command"
 | 
						|
    from watchfiles import run_process
 | 
						|
 | 
						|
    if __name__ == '__main__':
 | 
						|
        run_process('.', target='./example.sh')
 | 
						|
    ```
 | 
						|
    """
 | 
						|
    if target_type == 'auto':
 | 
						|
        target_type = detect_target_type(target)
 | 
						|
 | 
						|
    logger.debug('running "%s" as %s', target, target_type)
 | 
						|
    catch_sigterm()
 | 
						|
    process = start_process(target, target_type, args, kwargs)
 | 
						|
    reloads = 0
 | 
						|
 | 
						|
    if grace_period:
 | 
						|
        logger.debug('sleeping for %s seconds before watching for changes', grace_period)
 | 
						|
        sleep(grace_period)
 | 
						|
 | 
						|
    try:
 | 
						|
        for changes in watch(
 | 
						|
            *paths,
 | 
						|
            watch_filter=watch_filter,
 | 
						|
            debounce=debounce,
 | 
						|
            step=step,
 | 
						|
            debug=debug,
 | 
						|
            raise_interrupt=False,
 | 
						|
            recursive=recursive,
 | 
						|
            ignore_permission_denied=ignore_permission_denied,
 | 
						|
        ):
 | 
						|
            callback and callback(changes)
 | 
						|
            process.stop(sigint_timeout=sigint_timeout, sigkill_timeout=sigkill_timeout)
 | 
						|
            process = start_process(target, target_type, args, kwargs, changes)
 | 
						|
            reloads += 1
 | 
						|
    finally:
 | 
						|
        process.stop()
 | 
						|
    return reloads
 | 
						|
 | 
						|
 | 
						|
async def arun_process(
 | 
						|
    *paths: Union[Path, str],
 | 
						|
    target: Union[str, Callable[..., Any]],
 | 
						|
    args: Tuple[Any, ...] = (),
 | 
						|
    kwargs: Optional[Dict[str, Any]] = None,
 | 
						|
    target_type: "Literal['function', 'command', 'auto']" = 'auto',
 | 
						|
    callback: Optional[Callable[[Set[FileChange]], Any]] = None,
 | 
						|
    watch_filter: Optional[Callable[[Change, str], bool]] = DefaultFilter(),
 | 
						|
    grace_period: float = 0,
 | 
						|
    debounce: int = 1_600,
 | 
						|
    step: int = 50,
 | 
						|
    debug: Optional[bool] = None,
 | 
						|
    recursive: bool = True,
 | 
						|
    ignore_permission_denied: bool = False,
 | 
						|
) -> int:
 | 
						|
    """
 | 
						|
    Async equivalent of [`run_process`][watchfiles.run_process], all arguments match those of `run_process` except
 | 
						|
    `callback` which can be a coroutine.
 | 
						|
 | 
						|
    Starting and stopping the process and watching for changes is done in a separate thread.
 | 
						|
 | 
						|
    As with `run_process`, internally `arun_process` uses [`awatch`][watchfiles.awatch], however `KeyboardInterrupt`
 | 
						|
    cannot be caught and suppressed in `awatch` so these errors need to be caught separately, see below.
 | 
						|
 | 
						|
    ```py title="Example of arun_process usage"
 | 
						|
    import asyncio
 | 
						|
    from watchfiles import arun_process
 | 
						|
 | 
						|
    async def callback(changes):
 | 
						|
        await asyncio.sleep(0.1)
 | 
						|
        print('changes detected:', changes)
 | 
						|
 | 
						|
    def foobar(a, b):
 | 
						|
        print('foobar called with:', a, b)
 | 
						|
 | 
						|
    async def main():
 | 
						|
        await arun_process('.', target=foobar, args=(1, 2), callback=callback)
 | 
						|
 | 
						|
    if __name__ == '__main__':
 | 
						|
        try:
 | 
						|
            asyncio.run(main())
 | 
						|
        except KeyboardInterrupt:
 | 
						|
            print('stopped via KeyboardInterrupt')
 | 
						|
    ```
 | 
						|
    """
 | 
						|
    import inspect
 | 
						|
 | 
						|
    if target_type == 'auto':
 | 
						|
        target_type = detect_target_type(target)
 | 
						|
 | 
						|
    logger.debug('running "%s" as %s', target, target_type)
 | 
						|
    catch_sigterm()
 | 
						|
    process = await anyio.to_thread.run_sync(start_process, target, target_type, args, kwargs)
 | 
						|
    reloads = 0
 | 
						|
 | 
						|
    if grace_period:
 | 
						|
        logger.debug('sleeping for %s seconds before watching for changes', grace_period)
 | 
						|
        await anyio.sleep(grace_period)
 | 
						|
 | 
						|
    async for changes in awatch(
 | 
						|
        *paths,
 | 
						|
        watch_filter=watch_filter,
 | 
						|
        debounce=debounce,
 | 
						|
        step=step,
 | 
						|
        debug=debug,
 | 
						|
        recursive=recursive,
 | 
						|
        ignore_permission_denied=ignore_permission_denied,
 | 
						|
    ):
 | 
						|
        if callback is not None:
 | 
						|
            r = callback(changes)
 | 
						|
            if inspect.isawaitable(r):
 | 
						|
                await r
 | 
						|
 | 
						|
        await anyio.to_thread.run_sync(process.stop)
 | 
						|
        process = await anyio.to_thread.run_sync(start_process, target, target_type, args, kwargs, changes)
 | 
						|
        reloads += 1
 | 
						|
    await anyio.to_thread.run_sync(process.stop)
 | 
						|
    return reloads
 | 
						|
 | 
						|
 | 
						|
# Use spawn context to make sure code run in subprocess
 | 
						|
# does not reuse imported modules in main process/context
 | 
						|
spawn_context = get_context('spawn')
 | 
						|
 | 
						|
 | 
						|
def split_cmd(cmd: str) -> List[str]:
 | 
						|
    import platform
 | 
						|
 | 
						|
    posix = platform.uname().system.lower() != 'windows'
 | 
						|
    return shlex.split(cmd, posix=posix)
 | 
						|
 | 
						|
 | 
						|
def start_process(
 | 
						|
    target: Union[str, Callable[..., Any]],
 | 
						|
    target_type: "Literal['function', 'command']",
 | 
						|
    args: Tuple[Any, ...],
 | 
						|
    kwargs: Optional[Dict[str, Any]],
 | 
						|
    changes: Optional[Set[FileChange]] = None,
 | 
						|
) -> 'CombinedProcess':
 | 
						|
    if changes is None:
 | 
						|
        changes_env_var = '[]'
 | 
						|
    else:
 | 
						|
        changes_env_var = json.dumps([[c.raw_str(), p] for c, p in changes])
 | 
						|
 | 
						|
    os.environ['WATCHFILES_CHANGES'] = changes_env_var
 | 
						|
 | 
						|
    process: Union[SpawnProcess, subprocess.Popen[bytes]]
 | 
						|
    if target_type == 'function':
 | 
						|
        kwargs = kwargs or {}
 | 
						|
        if isinstance(target, str):
 | 
						|
            args = target, get_tty_path(), args, kwargs
 | 
						|
            target_ = run_function
 | 
						|
            kwargs = {}
 | 
						|
        else:
 | 
						|
            target_ = target
 | 
						|
 | 
						|
        process = spawn_context.Process(target=target_, args=args, kwargs=kwargs)
 | 
						|
        process.start()
 | 
						|
    else:
 | 
						|
        if args or kwargs:
 | 
						|
            logger.warning('ignoring args and kwargs for "command" target')
 | 
						|
 | 
						|
        assert isinstance(target, str), 'target must be a string to run as a command'
 | 
						|
        popen_args = split_cmd(target)
 | 
						|
        process = subprocess.Popen(popen_args)
 | 
						|
    return CombinedProcess(process)
 | 
						|
 | 
						|
 | 
						|
def detect_target_type(target: Union[str, Callable[..., Any]]) -> "Literal['function', 'command']":
 | 
						|
    """
 | 
						|
    Used by [`run_process`][watchfiles.run_process], [`arun_process`][watchfiles.arun_process]
 | 
						|
    and indirectly the CLI to determine the target type with `target_type` is `auto`.
 | 
						|
 | 
						|
    Detects the target type - either `function` or `command`. This method is only called with `target_type='auto'`.
 | 
						|
 | 
						|
    The following logic is employed:
 | 
						|
 | 
						|
    * If `target` is not a string, it is assumed to be a function
 | 
						|
    * If `target` ends with `.py` or `.sh`, it is assumed to be a command
 | 
						|
    * Otherwise, the target is assumed to be a function if it matches the regex `[a-zA-Z0-9_]+(\\.[a-zA-Z0-9_]+)+`
 | 
						|
 | 
						|
    If this logic does not work for you, specify the target type explicitly using the `target_type` function argument
 | 
						|
    or `--target-type` command line argument.
 | 
						|
 | 
						|
    Args:
 | 
						|
        target: The target value
 | 
						|
 | 
						|
    Returns:
 | 
						|
        either `'function'` or `'command'`
 | 
						|
    """
 | 
						|
    if not isinstance(target, str):
 | 
						|
        return 'function'
 | 
						|
    elif target.endswith(('.py', '.sh')):
 | 
						|
        return 'command'
 | 
						|
    elif re.fullmatch(r'[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)+', target):
 | 
						|
        return 'function'
 | 
						|
    else:
 | 
						|
        return 'command'
 | 
						|
 | 
						|
 | 
						|
class CombinedProcess:
 | 
						|
    def __init__(self, p: 'Union[SpawnProcess, subprocess.Popen[bytes]]'):
 | 
						|
        self._p = p
 | 
						|
        assert self.pid is not None, 'process not yet spawned'
 | 
						|
 | 
						|
    def stop(self, sigint_timeout: int = 5, sigkill_timeout: int = 1) -> None:
 | 
						|
        os.environ.pop('WATCHFILES_CHANGES', None)
 | 
						|
        if self.is_alive():
 | 
						|
            logger.debug('stopping process...')
 | 
						|
 | 
						|
            os.kill(self.pid, signal.SIGINT)
 | 
						|
 | 
						|
            try:
 | 
						|
                self.join(sigint_timeout)
 | 
						|
            except subprocess.TimeoutExpired:
 | 
						|
                # Capture this exception to allow the self.exitcode to be reached.
 | 
						|
                # This will allow the SIGKILL to be sent, otherwise it is swallowed up.
 | 
						|
                logger.warning('SIGINT timed out after %r seconds', sigint_timeout)
 | 
						|
                pass
 | 
						|
 | 
						|
            if self.exitcode is None:
 | 
						|
                logger.warning('process has not terminated, sending SIGKILL')
 | 
						|
                os.kill(self.pid, signal.SIGKILL)
 | 
						|
                self.join(sigkill_timeout)
 | 
						|
            else:
 | 
						|
                logger.debug('process stopped')
 | 
						|
        else:
 | 
						|
            logger.warning('process already dead, exit code: %d', self.exitcode)
 | 
						|
 | 
						|
    def is_alive(self) -> bool:
 | 
						|
        if isinstance(self._p, SpawnProcess):
 | 
						|
            return self._p.is_alive()
 | 
						|
        else:
 | 
						|
            return self._p.poll() is None
 | 
						|
 | 
						|
    @property
 | 
						|
    def pid(self) -> int:
 | 
						|
        # we check the process has always been spawned when CombinedProcess is initialised
 | 
						|
        return self._p.pid  # type: ignore[return-value]
 | 
						|
 | 
						|
    def join(self, timeout: int) -> None:
 | 
						|
        if isinstance(self._p, SpawnProcess):
 | 
						|
            self._p.join(timeout)
 | 
						|
        else:
 | 
						|
            self._p.wait(timeout)
 | 
						|
 | 
						|
    @property
 | 
						|
    def exitcode(self) -> Optional[int]:
 | 
						|
        if isinstance(self._p, SpawnProcess):
 | 
						|
            return self._p.exitcode
 | 
						|
        else:
 | 
						|
            return self._p.returncode
 | 
						|
 | 
						|
 | 
						|
def run_function(function: str, tty_path: Optional[str], args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> None:
 | 
						|
    with set_tty(tty_path):
 | 
						|
        func = import_string(function)
 | 
						|
        func(*args, **kwargs)
 | 
						|
 | 
						|
 | 
						|
def import_string(dotted_path: str) -> Any:
 | 
						|
    """
 | 
						|
    Stolen approximately from django. Import a dotted module path and return the attribute/class designated by the
 | 
						|
    last name in the path. Raise ImportError if the import fails.
 | 
						|
    """
 | 
						|
    try:
 | 
						|
        module_path, class_name = dotted_path.strip(' ').rsplit('.', 1)
 | 
						|
    except ValueError as e:
 | 
						|
        raise ImportError(f'"{dotted_path}" doesn\'t look like a module path') from e
 | 
						|
 | 
						|
    module = import_module(module_path)
 | 
						|
    try:
 | 
						|
        return getattr(module, class_name)
 | 
						|
    except AttributeError as e:
 | 
						|
        raise ImportError(f'Module "{module_path}" does not define a "{class_name}" attribute') from e
 | 
						|
 | 
						|
 | 
						|
def get_tty_path() -> Optional[str]:  # pragma: no cover
 | 
						|
    """
 | 
						|
    Return the path to the current TTY, if any.
 | 
						|
 | 
						|
    Virtually impossible to test in pytest, hence no cover.
 | 
						|
    """
 | 
						|
    try:
 | 
						|
        return os.ttyname(sys.stdin.fileno())
 | 
						|
    except OSError:
 | 
						|
        # fileno() always fails with pytest
 | 
						|
        return '/dev/tty'
 | 
						|
    except AttributeError:
 | 
						|
        # on Windows. No idea of a better solution
 | 
						|
        return None
 | 
						|
 | 
						|
 | 
						|
@contextlib.contextmanager
 | 
						|
def set_tty(tty_path: Optional[str]) -> Generator[None, None, None]:
 | 
						|
    if tty_path:
 | 
						|
        try:
 | 
						|
            with open(tty_path) as tty:  # pragma: no cover
 | 
						|
                sys.stdin = tty
 | 
						|
                yield
 | 
						|
        except OSError:
 | 
						|
            # eg. "No such device or address: '/dev/tty'", see https://github.com/samuelcolvin/watchfiles/issues/40
 | 
						|
            yield
 | 
						|
    else:
 | 
						|
        # currently on windows tty_path is None and there's nothing we can do here
 | 
						|
        yield
 | 
						|
 | 
						|
 | 
						|
def raise_keyboard_interrupt(signum: int, _frame: Any) -> None:  # pragma: no cover
 | 
						|
    logger.warning('received signal %s, raising KeyboardInterrupt', signal.Signals(signum))
 | 
						|
    raise KeyboardInterrupt
 | 
						|
 | 
						|
 | 
						|
def catch_sigterm() -> None:
 | 
						|
    """
 | 
						|
    Catch SIGTERM and raise KeyboardInterrupt instead. This means watchfiles will stop quickly
 | 
						|
    on `docker compose stop` and other cases where SIGTERM is sent.
 | 
						|
 | 
						|
    Without this the watchfiles process will be killed while a running process will continue uninterrupted.
 | 
						|
    """
 | 
						|
    logger.debug('registering handler for SIGTERM on watchfiles process %d', os.getpid())
 | 
						|
    signal.signal(signal.SIGTERM, raise_keyboard_interrupt)
 |