Skip to content

Commit 98f80fb

Browse files
committed
fix: check exclude_dirs before endswith shortcut in FileFilter
When an explicit path (no wildcards) is listed in --reload-include, FileFilter.__call__ hit the str(path).endswith(include_pattern) branch and returned True immediately, without checking whether the file lives inside a directory listed in --reload-exclude. The directory exclusion was therefore silently bypassed for any non-glob include pattern. Moving the exclude_dirs loop above the endswith shortcut preserves the original intent of that shortcut (allowing hidden/dotted files and other exact paths to bypass the *pattern* excludes) while still honouring directory-level exclusions for those same files.
1 parent e8a31bc commit 98f80fb

2 files changed

Lines changed: 42 additions & 3 deletions

File tree

tests/supervisors/test_reload.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,38 @@ def test_base_reloader_closes_sockets_on_shutdown():
409409
assert sock.fileno() != -1
410410
reloader.shutdown()
411411
assert sock.fileno() == -1
412+
413+
414+
@pytest.mark.skipif(WatchFilesReload is None, reason="watchfiles not available")
415+
def test_file_filter_explicit_include_respects_exclude_dirs(tmp_path: Path):
416+
"""Exact-name entries in --reload-include must still be blocked by --reload-exclude dirs.
417+
418+
When the include pattern has no wildcards (e.g. ".dotted" or "ext/ext.jpg"),
419+
FileFilter used to return True immediately after the endswith check, skipping
420+
the exclude_dirs guard entirely. A file inside an excluded directory should
421+
be rejected even when its name matches an explicit include pattern.
422+
"""
423+
from uvicorn.supervisors.watchfilesreload import FileFilter
424+
425+
excluded_dir = tmp_path / "vendor"
426+
excluded_dir.mkdir()
427+
428+
config = Config(
429+
app="tests.test_config:asgi_app",
430+
reload=True,
431+
reload_includes=[".dotted"],
432+
reload_excludes=[str(excluded_dir)],
433+
)
434+
file_filter = FileFilter(config)
435+
436+
# Explicit include outside the excluded dir: must be watched.
437+
assert file_filter(tmp_path / "src" / ".dotted") is True
438+
439+
# Explicit include inside the excluded dir: must NOT be watched.
440+
assert file_filter(excluded_dir / ".dotted") is False
441+
442+
# Regular *.py outside excluded dir: must be watched.
443+
assert file_filter(tmp_path / "app.py") is True
444+
445+
# Regular *.py inside excluded dir: must NOT be watched.
446+
assert file_filter(excluded_dir / "app.py") is False

uvicorn/supervisors/watchfilesreload.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,17 @@ def __init__(self, config: Config):
3737
def __call__(self, path: Path) -> bool:
3838
for include_pattern in self.includes:
3939
if path.match(include_pattern):
40-
if str(path).endswith(include_pattern):
41-
return True # pragma: full coverage
42-
4340
for exclude_dir in self.exclude_dirs:
4441
if exclude_dir in path.parents:
4542
return False # pragma: no cover
4643

44+
# Exact-path includes (e.g. ".dotted" or "ext/ext.jpg") must
45+
# bypass the pattern-level excludes so that hidden files and
46+
# other non-glob entries listed in --reload-include are watched,
47+
# but they still respect directory-level excludes above.
48+
if str(path).endswith(include_pattern):
49+
return True # pragma: full coverage
50+
4751
for exclude_pattern in self.excludes:
4852
if path.match(exclude_pattern):
4953
return False # pragma: full coverage

0 commit comments

Comments
 (0)