diff --git a/docs/settings.md b/docs/settings.md index d93744627..31ec97db3 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -63,8 +63,8 @@ For more nuanced control over which file modifications trigger reloads, install Using Uvicorn with watchfiles will enable the following options (which are otherwise ignored): -* `--reload-include ` - Specify a glob pattern to match files or directories which will be watched. May be used multiple times. By default the following patterns are included: `*.py`. These defaults can be overwritten by including them in `--reload-exclude`. -* `--reload-exclude ` - Specify a glob pattern to match files or directories which will excluded from watching. May be used multiple times. By default the following patterns are excluded: `.*, .py[cod], .sw.*, ~*`. These defaults can be overwritten by including them in `--reload-include`. +* `--reload-include ` - Specify a glob pattern to [match](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.match) files or directories which will be watched. May be used multiple times. By default the following patterns are included: `*.py`. These defaults can be overwritten by including them in `--reload-exclude`. Note, `**` is not supported in ``. +* `--reload-exclude ` - Specify a glob pattern to [match](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.match) files or directories which will be excluded from watching. May be used multiple times. If `` does not contain a `/` or `*`, it will be compared against every path part of the resolved watched file path (e.g. `--reload-exclude '__pycache__'` will exclude any file matches who have `__pycache__` as an ancestor directory). By default the following patterns are excluded: `.*, .py[cod], .sw.*, ~*`. These defaults can be overwritten by including them in `--reload-include`. Note, `**` is not supported in ``. !!! tip When using Uvicorn through [WSL](https://en.wikipedia.org/wiki/Windows_Subsystem_for_Linux), you might diff --git a/tests/supervisors/test_reload.py b/tests/supervisors/test_reload.py index bd40e7230..11b3fde91 100644 --- a/tests/supervisors/test_reload.py +++ b/tests/supervisors/test_reload.py @@ -233,6 +233,42 @@ def test_should_not_reload_when_only_subdirectory_is_watched( reloader.shutdown() + @pytest.mark.parametrize("reloader_class", [WatchFilesReload]) + def test_should_not_reload_when_exact_subdirectory_is_watched(self, touch_soon: Callable[[Path], None]): + included_file = self.reload_path / "ext" / "ext.jpg" + + sub_file = self.reload_path / "app" / "src" / "main.py" + relative_file = self.reload_path / "app_first" / "css" / "main.css" + relative_sub_file = self.reload_path / "app_second" / "js" / "main.js" + absolute_file = self.reload_path / "app_third" / "js" / "main.js" + + with as_cwd(self.reload_path): + config = Config( + app="tests.test_config:asgi_app", + reload=True, + reload_includes=["*"], + reload_excludes=[ + # Sub directory + "src", + # Relative directory + "app_first", + # Relative directory with sub directory + "app_second/js", + # Absolute path + str(self.reload_path / "app_third"), + ], + ) + reloader = self._setup_reloader(config) + + assert self._reload_tester(touch_soon, reloader, included_file) + + assert not self._reload_tester(touch_soon, reloader, sub_file) + assert not self._reload_tester(touch_soon, reloader, relative_file) + assert not self._reload_tester(touch_soon, reloader, relative_sub_file) + assert not self._reload_tester(touch_soon, reloader, absolute_file) + + reloader.shutdown() + @pytest.mark.parametrize("reloader_class", [pytest.param(WatchFilesReload, marks=skip_non_linux)]) def test_override_defaults(self, touch_soon: Callable[[Path], None]) -> None: # pragma: py-not-linux dotted_file = self.reload_path / ".dotted" diff --git a/uvicorn/supervisors/watchfilesreload.py b/uvicorn/supervisors/watchfilesreload.py index 0d3b9b77e..7b2e44ec7 100644 --- a/uvicorn/supervisors/watchfilesreload.py +++ b/uvicorn/supervisors/watchfilesreload.py @@ -13,26 +13,38 @@ class FileFilter: def __init__(self, config: Config): default_includes = ["*.py"] - self.includes = [default for default in default_includes if default not in config.reload_excludes] - self.includes.extend(config.reload_includes) - self.includes = list(set(self.includes)) + self.includes = list( + # Remove any included defaults that are excluded + (set(default_includes) - set(config.reload_excludes)) + # Merge with any user-provided includes + | set(config.reload_includes) + ) - default_excludes = [".*", ".py[cod]", ".sw.*", "~*"] - self.excludes = [default for default in default_excludes if default not in config.reload_includes] self.exclude_dirs = [] + """List of excluded directories resolved to absolute paths""" + for e in config.reload_excludes: p = Path(e) try: - is_dir = p.is_dir() + if p.is_dir(): + # Storing absolute path to always match `path.parents` values (which are absolute) + self.exclude_dirs.append(p.absolute()) except OSError: # pragma: no cover # gets raised on Windows for values like "*.py" - is_dir = False + pass - if is_dir: - self.exclude_dirs.append(p) - else: - self.excludes.append(e) # pragma: full coverage - self.excludes = list(set(self.excludes)) + default_excludes = [".*", ".py[cod]", ".sw.*", "~*"] + self.excludes = list( + # Remove any excluded defaults that are included + (set(default_excludes) - set(config.reload_includes)) + # Merge with any user-provided excludes (excluding directories) + | (set(config.reload_excludes) - set(str(ex_dir) for ex_dir in self.exclude_dirs)) + ) + + self._exclude_dir_names_set = set( + exclude for exclude in config.reload_excludes if "*" not in exclude and "/" not in exclude + ) + """Set of excluded directory names that do not contain a wildcard or path separator""" def __call__(self, path: Path) -> bool: for include_pattern in self.includes: @@ -40,14 +52,26 @@ def __call__(self, path: Path) -> bool: if str(path).endswith(include_pattern): return True # pragma: full coverage - for exclude_dir in self.exclude_dirs: - if exclude_dir in path.parents: - return False - + # Exclude if the pattern matches the file path for exclude_pattern in self.excludes: if path.match(exclude_pattern): return False # pragma: full coverage + # Exclude if any parent of the path is an excluded directory + # Ex: `/www/xxx/yyy/z.txt` will be excluded if + # * `/www` or `/www/xxx` is in the exclude list + # * `xxx/yyy` is in the exclude list and the current directory is `/www` + path_parents = path.parents + for exclude_dir in self.exclude_dirs: + if exclude_dir in path_parents: + return False + + # Exclude if any parent directory name is an exact match to an excluded value + # Ex: `aaa/bbb/ccc/d.txt` will be excluded if `bbb` is in the exclude list, + # but not `bb` or `bb*` or `bbb/**` + if set(path.parent.parts) & self._exclude_dir_names_set: + return False + return True return False