Encountering a bug in pywatchman
and exploring an alterate file reloading solution.
TLDR: On Python-3.10 watchman bindings are broken. My recommendation is to use: django-watchfiles
What the Docs Say
I recently went down a bit of a rabbit hole debugging auto reloading in Django.
The official documentation for Django runserver
states
The development server automatically reloads Python code for each request, as needed. You don’t need to restart the server for code changes to take effect...
And that:
If you’re using Linux or MacOS and install both pywatchman and the Watchman service, kernel signals will be used to autoreload the server (rather than polling file modification timestamps each second). This offers better performance on large projects...
So by default ./manage.py runserver
Should work out of the box, but it's not
particularly efficient for bigger projects.
Optionally: installing the watchman
binary (a file watching service by Facebook) and the
pywatchman
(the python bindings) should result in a smoother experience.
My Experience
In the past I've found the default reloader doesn't always restart the server when I'd expect it to.
Recently I started a new project (with Python 3.10.6
) so I figured I'd give
the watchman reloader a try.
I installed the watchman
binary via my distribution package manager and
pywatchman
via pip
To my confusion: the StatReloader
(the default/fallback) was still being used 🤔
❯ ./manage.py runserver
Watching for file changes with StatReloader
Performing system checks...
Investigation
Let's have a look at why this is happening 🕵️
Django's reloading implementation can be found in django/utils/autoreload.py
def get_reloader():
"""Return the most suitable reloader for this environment."""
try:
WatchmanReloader.check_availability()
except WatchmanUnavailable:
return StatReloader()
return WatchmanReloader()
It looks like by default Django will first check to see if watchman is available, and call back to the default stat reloader if the compatibility check fails.
The Smoking Gun
If the check fails: get_reloader
will swallow the exception and silently
fall back to using the stat StatReloader
. From the end user perspective
(someone executing ./manage.py runserver
) there's no indication why the
availability check failed.
Reproducing
So lets dig a little further:
The check_availability
method on WatchmanReloader
implementation can be found here
The important lines are:
if not pywatchman:
raise WatchmanUnavailable("pywatchman not installed.")
client = pywatchman.client(timeout=0.1)
try:
result = client.capabilityCheck()
except Exception:
# The service is down?
raise WatchmanUnavailable("Cannot connect to the watchman service.")
Where the pywatchman
variable being checked against is just a module level
import wrapped with a try/except
block (to check if the pywatchman
python
package is available)
try:
import pywatchman
except ImportError:
pywatchman = None
If I open a shell and execute this code:
>>> import pywatchman
>>> client = pywatchman.client(timeout=0.1)
>>> result = client.capabilityCheck()
I get the following exception 💥 (the same exception unfortunately gets swallowed by Django).
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/jackevans/.local/share/virtualenvs/blog-ZV8xlUiZ/lib/python3.10/site-packages/pywatchman/__init__.py", line 1071, in capabilityCheck
res = self.query('version', {
File "/home/jackevans/.local/share/virtualenvs/blog-ZV8xlUiZ/lib/python3.10/site-packages/pywatchman/__init__.py", line 1048, in query
self._connect()
File "/home/jackevans/.local/share/virtualenvs/blog-ZV8xlUiZ/lib/python3.10/site-packages/pywatchman/__init__.py", line 917, in _connect
self.sockpath = self._resolvesockname()
File "/home/jackevans/.local/share/virtualenvs/blog-ZV8xlUiZ/lib/python3.10/site-packages/pywatchman/__init__.py", line 904, in _resolvesockname
result = bser.loads(stdout)
SystemError: PY_SSIZE_T_CLEAN macro must be defined for '#' formats
It looks like the capabilityCheck()
call is failing due to an exception raised
inside pywatchman
.
Mystery solved 🔮 this explains why StatReloader
(not WatchmanReloader
) is
being used, despite all the necessary pre-requisite dependencies being available.
An Alternative Solution
After a bit of Googling I ended up in this github issue thread, which was originally opened on 2 Nov 2021.
It appears like there was a change in Python 3.10 that caused things to break in pywatchman library. Consequently bindings will need updating to work for newer Python releases.
According to the thread: a pre-release fix is available, it just hasn't been tagged/released on PyPI. There's a lot of frustration in the thread with commenters requesting that the patch/fix be officially merged/released.
Luckily, in the mean time one of the suggestions in the thread pointed me towards django-watchfiles.
django-watchfiles
provides Django integration with watchfiles
, which itself is a set of Python bindings for the underlying notify
file-system notification library written in rust.
Similar to the way pywathman
provides bindings to the Watchman
file-watching service, both solutions implement performance sensitive
operations (low file watching events) in a lower level language, providing
higher level language wrappers for the Python ecosystem.
I've found that django-watchfiles
works very nicely out of the box, so gets
my approval 👍
Conclusion
Hopefully either the pywatchman
library will be updated in the future or the
Django documentation will include a disclaimer about broken behaviour in
Python-3.10+.
Otherwise future developers are destined to retrace my footsteps (maybe you even found this blog post)
In the meantime, I'll continue to use django-watchfiles
.