A file watcher that matches nothing does not look broken. It looks like a reloader you forgot to restart. So I spent an afternoon on an app where edits under src/ were invisible while edits to app.py in the project root triggered a reload every time, on the same machine, in the same process.

The line that moved was one method call in watchdog. Version 6 filtered events with PurePath.match(pattern). Version 7.1 filters with PurePath.full_match(pattern). Those two names differ by four characters and by everything that matters: match() is right-anchored and can match a trailing segment of the path, full_match() matches the whole thing. Werkzeug's reloader hands watchdog a pattern list that has been sitting in the codebase for years:

patterns = ["*.py", "*.pyc", "*.zip"] 

Under match(), *.py matched src/app.py, because it was allowed to match the tail. Under full_match() it does not.

I did not want to take anyone's word for the semantics, so I pulled both wheels and compared watchdog/utils/patterns.py:

  • 6.0.0: return any(path.match(p) for p in included_patterns) and not any(path.match(p) for p in excluded_patterns)
  • 7.1.0: _full_match(path, pattern) → return path.full_match(pattern) (with a translate() fallback for Python < 3.13)

Then I ran the 7.1 pattern translation directly, on CPython 3.11.18, and printed the regexes and the verdicts instead of reasoning about them:

*.py => (?s:[^/]*\.py)\Z **/*.py => (?s:(?:.+/)?[^/]*\.py)\Z ** => (?s:.*)\Z app.py *.py -> True src/app.py *.py -> False src/deep/app.py *.py -> False app.py **/*.py -> True src/app.py **/*.py -> True src/deep/app.py **/*.py -> True 

That first regex is the whole bug: [^/]* cannot cross a separator. The old match() semantics did the crossing for you by letting the pattern match the tail of the path. The new one hands you the raw glob and expects the pattern to describe the entire path.

The failure is silent by construction. filter_paths() returns a generator; a pattern set that matches nothing yields nothing, which is indistinguishable from "no file changed". No exception, no warning, no log line, no deprecation notice. The reloader keeps running and reports nothing because nothing reached it.

Why the test suite stayed green

watchdog's own tests filter events built from bare filenames — FileModifiedEvent("fake.py") and friends. Run those through full_match, and *.py matches, because there is no directory component to fail on. I checked the one case that matters and it behaves exactly as you would fear: fake.py against *.py returns True under the new semantics.

So the library's tests pass, the app's tests pass, and the only configuration that breaks is the one nobody wrote a test for: a pattern list applied to a nested path. This is the general shape of it — when a helper changes from "matches a suffix" to "matches everything", every caller that passed a suffix-shaped pattern keeps compiling, keeps passing, and quietly matches less. A test that feeds the helper the same bare filename it was always fed cannot see the change.

Reproducing it in twenty lines

Start the dev server for real, not through the test client:

from werkzeug.serving import run_simple run_simple("127.0.0.1", 5000, app, use_reloader=True, reloader_type="watchdog") 

Wait for the boot to settle (four seconds is enough), append a byte to src/app.py, and watch stdout. Before the change, the only line you get is Restarting with watchdog (inotify) and no Detected change in ... follows. After replacing the pattern list with ["**/*.py", "**/*.pyc", "**/*.zip"], the same edit prints Detected change in '.../src/app.py', reloading within a second.

I ran that before/after pair with watchdog 7.1.0 on CPython 3.11.18, and separately with the stat reloader to confirm it was the watcher and not the reload path: DETECTED: False before, DETECTED: True after, stat reloader unaffected in both runs.

What the fix actually is

The pattern has to describe the whole path, which means it has to be able to absorb the directories that * no longer crosses:

  • *.py → **/*.py
  • *.pyc → **/*.pyc
  • *.zip → **/*.zip
  • __pycache__/* → **/__pycache__/*

The (?:.+/)? in the translated regex is the part people miss: **/ absorbs zero or more directories, so root-level app.py still matches **/*.py — verified above, not assumed. If you only test the nested case, you may "fix" it with something that breaks the root case.

What I'd test first

A pattern list is configuration, and configuration that matches nothing should fail at boot rather than at 2 a.m. One assertion covers the whole class of bug:

from watchdog.utils.patterns import filter_paths assert list(filter_paths(["src/app.py"], included_patterns=PATTERNS)), \ "reloader patterns match no nested path: %r" % (PATTERNS,) 

Cheap, deterministic, no filesystem, no sleep. It fails on ["*.py"] and passes on ["**/*.py"], which is exactly the discrimination the library's own tests lacked.

The broader lesson is not about watchdog. It is that a matching-semantics change is a silent interface change: the caller's arguments still type-check, the module still imports, and the only signal is work that stops happening. If you upgrade a watcher, a router, or a permission matcher and the diff mentions match → full_match, fnmatch → glob, or anything with "anchored", spend ten minutes writing the nested case first — an event that never fires has no stack trace to read.

If you are on watchdog 7.1 behind a Werkzeug dev server: does your reloader catch a change inside a nested package directory, or only files next to the entrypoint? Run the four-second check above before answering — the pattern list in your codebase was almost certainly written for the old semantics.