Skip to content

bug(reload exclude): Allow for subdirectories to be excluded #2602

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <glob-pattern>` - 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 <glob-pattern>` - 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 <glob-pattern>` - 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 `<glob-pattern>`.
* `--reload-exclude <glob-pattern>` - 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 `<glob-pattern>` 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 `<glob-pattern>`.

!!! tip
When using Uvicorn through [WSL](https://en.wikipedia.org/wiki/Windows_Subsystem_for_Linux), you might
Expand Down
36 changes: 36 additions & 0 deletions tests/supervisors/test_reload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
56 changes: 40 additions & 16 deletions uvicorn/supervisors/watchfilesreload.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,41 +13,65 @@
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:
if path.match(include_pattern):
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

Expand Down