You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
224 lines
7.5 KiB
224 lines
7.5 KiB
import argparse |
|
import logging |
|
import os |
|
import shlex |
|
import sys |
|
from pathlib import Path |
|
from textwrap import dedent |
|
from typing import Any, Callable, List, Optional, Tuple, Union, cast |
|
|
|
from . import Change |
|
from .filters import BaseFilter, DefaultFilter, PythonFilter |
|
from .run import detect_target_type, import_string, run_process |
|
from .version import VERSION |
|
|
|
logger = logging.getLogger('watchfiles.cli') |
|
|
|
|
|
def resolve_path(path_str: str) -> Path: |
|
path = Path(path_str) |
|
if not path.exists(): |
|
raise FileNotFoundError(path) |
|
else: |
|
return path.resolve() |
|
|
|
|
|
def cli(*args_: str) -> None: |
|
""" |
|
Watch one or more directories and execute either a shell command or a python function on file changes. |
|
|
|
Example of watching the current directory and calling a python function: |
|
|
|
watchfiles foobar.main |
|
|
|
Example of watching python files in two local directories and calling a shell command: |
|
|
|
watchfiles --filter python 'pytest --lf' src tests |
|
|
|
See https://watchfiles.helpmanual.io/cli/ for more information. |
|
""" |
|
args = args_ or sys.argv[1:] |
|
parser = argparse.ArgumentParser( |
|
prog='watchfiles', |
|
description=dedent((cli.__doc__ or '').strip('\n')), |
|
formatter_class=argparse.RawTextHelpFormatter, |
|
) |
|
parser.add_argument('target', help='Command or dotted function path to run') |
|
parser.add_argument( |
|
'paths', nargs='*', default='.', help='Filesystem paths to watch, defaults to current directory' |
|
) |
|
|
|
parser.add_argument( |
|
'--ignore-paths', |
|
nargs='?', |
|
type=str, |
|
help=( |
|
'Specify directories to ignore, ' |
|
'to ignore multiple paths use a comma as separator, e.g. "env" or "env,node_modules"' |
|
), |
|
) |
|
parser.add_argument( |
|
'--target-type', |
|
nargs='?', |
|
type=str, |
|
default='auto', |
|
choices=['command', 'function', 'auto'], |
|
help=( |
|
'Whether the target should be intercepted as a shell command or a python function, ' |
|
'defaults to "auto" which infers the target type from the target string' |
|
), |
|
) |
|
parser.add_argument( |
|
'--filter', |
|
nargs='?', |
|
type=str, |
|
default='default', |
|
help=( |
|
'Which files to watch, defaults to "default" which uses the "DefaultFilter", ' |
|
'"python" uses the "PythonFilter", "all" uses no filter, ' |
|
'any other value is interpreted as a python function/class path which is imported' |
|
), |
|
) |
|
parser.add_argument( |
|
'--args', |
|
nargs='?', |
|
type=str, |
|
help='Arguments to set on sys.argv before calling target function, used only if the target is a function', |
|
) |
|
parser.add_argument('--verbose', action='store_true', help='Set log level to "debug", wins over `--verbosity`') |
|
parser.add_argument( |
|
'--non-recursive', action='store_true', help='Do not watch for changes in sub-directories recursively' |
|
) |
|
parser.add_argument( |
|
'--verbosity', |
|
nargs='?', |
|
type=str, |
|
default='info', |
|
choices=['warning', 'info', 'debug'], |
|
help='Log level, defaults to "info"', |
|
) |
|
parser.add_argument( |
|
'--sigint-timeout', |
|
nargs='?', |
|
type=int, |
|
default=5, |
|
help='How long to wait for the sigint timeout before sending sigkill.', |
|
) |
|
parser.add_argument( |
|
'--grace-period', |
|
nargs='?', |
|
type=float, |
|
default=0, |
|
help='Number of seconds after the process is started before watching for changes.', |
|
) |
|
parser.add_argument( |
|
'--sigkill-timeout', |
|
nargs='?', |
|
type=int, |
|
default=1, |
|
help='How long to wait for the sigkill timeout before issuing a timeout exception.', |
|
) |
|
parser.add_argument( |
|
'--ignore-permission-denied', |
|
action='store_true', |
|
help='Ignore permission denied errors while watching files and directories.', |
|
) |
|
parser.add_argument('--version', '-V', action='version', version=f'%(prog)s v{VERSION}') |
|
arg_namespace = parser.parse_args(args) |
|
|
|
if arg_namespace.verbose: |
|
log_level = logging.DEBUG |
|
else: |
|
log_level = getattr(logging, arg_namespace.verbosity.upper()) |
|
|
|
hdlr = logging.StreamHandler() |
|
hdlr.setLevel(log_level) |
|
hdlr.setFormatter(logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%H:%M:%S')) |
|
wg_logger = logging.getLogger('watchfiles') |
|
wg_logger.addHandler(hdlr) |
|
wg_logger.setLevel(log_level) |
|
|
|
if arg_namespace.target_type == 'auto': |
|
target_type = detect_target_type(arg_namespace.target) |
|
else: |
|
target_type = arg_namespace.target_type |
|
|
|
if target_type == 'function': |
|
logger.debug('target_type=function, attempting import of "%s"', arg_namespace.target) |
|
import_exit(arg_namespace.target) |
|
if arg_namespace.args: |
|
sys.argv = [arg_namespace.target] + shlex.split(arg_namespace.args) |
|
elif arg_namespace.args: |
|
logger.warning('--args is only used when the target is a function') |
|
|
|
try: |
|
paths = [resolve_path(p) for p in arg_namespace.paths] |
|
except FileNotFoundError as e: |
|
print(f'path "{e}" does not exist', file=sys.stderr) |
|
sys.exit(1) |
|
|
|
watch_filter, watch_filter_str = build_filter(arg_namespace.filter, arg_namespace.ignore_paths) |
|
|
|
logger.info( |
|
'watchfiles v%s 👀 path=%s target="%s" (%s) filter=%s...', |
|
VERSION, |
|
', '.join(f'"{p}"' for p in paths), |
|
arg_namespace.target, |
|
target_type, |
|
watch_filter_str, |
|
) |
|
|
|
run_process( |
|
*paths, |
|
target=arg_namespace.target, |
|
target_type=target_type, |
|
watch_filter=watch_filter, |
|
debug=log_level == logging.DEBUG, |
|
sigint_timeout=arg_namespace.sigint_timeout, |
|
sigkill_timeout=arg_namespace.sigkill_timeout, |
|
recursive=not arg_namespace.non_recursive, |
|
ignore_permission_denied=arg_namespace.ignore_permission_denied, |
|
grace_period=arg_namespace.grace_period, |
|
) |
|
|
|
|
|
def import_exit(function_path: str) -> Any: |
|
cwd = os.getcwd() |
|
if cwd not in sys.path: |
|
sys.path.append(cwd) |
|
|
|
try: |
|
return import_string(function_path) |
|
except ImportError as e: |
|
print(f'ImportError: {e}', file=sys.stderr) |
|
sys.exit(1) |
|
|
|
|
|
def build_filter( |
|
filter_name: str, ignore_paths_str: Optional[str] |
|
) -> Tuple[Union[None, DefaultFilter, Callable[[Change, str], bool]], str]: |
|
ignore_paths: List[Path] = [] |
|
if ignore_paths_str: |
|
ignore_paths = [Path(p).resolve() for p in ignore_paths_str.split(',')] |
|
|
|
if filter_name == 'default': |
|
return DefaultFilter(ignore_paths=ignore_paths), 'DefaultFilter' |
|
elif filter_name == 'python': |
|
return PythonFilter(ignore_paths=ignore_paths), 'PythonFilter' |
|
elif filter_name == 'all': |
|
if ignore_paths: |
|
logger.warning('"--ignore-paths" argument ignored as "all" filter was selected') |
|
return None, '(no filter)' |
|
|
|
watch_filter_cls = import_exit(filter_name) |
|
if isinstance(watch_filter_cls, type) and issubclass(watch_filter_cls, DefaultFilter): |
|
return watch_filter_cls(ignore_paths=ignore_paths), watch_filter_cls.__name__ |
|
|
|
if ignore_paths: |
|
logger.warning('"--ignore-paths" argument ignored as filter is not a subclass of DefaultFilter') |
|
|
|
if isinstance(watch_filter_cls, type) and issubclass(watch_filter_cls, BaseFilter): |
|
return watch_filter_cls(), watch_filter_cls.__name__ |
|
else: |
|
watch_filter = cast(Callable[[Change, str], bool], watch_filter_cls) |
|
return watch_filter, repr(watch_filter_cls)
|
|
|