blog Django Autoreload and File Changed

Tags:
django

As part of my Markdown Virtual Table project, when running the development runserver , I occasionally want to run some code when we update a file.

There are two signals that we can use for this, that are not well documented in Django itself:

from django.utils.autoreload import autoreload_started, file_changed

These are mostly used internally with the development runserver.

autoreload_started

The first half of this is used to register our file watches. We can find some of these examples in Django itself.

In the Django translation system, we find this watcher that ultimately looks for all **/*.mo translation files.

https://github.com/django/django/blob/5.2/django/utils/translation/reloader.py#L9-L22

In the Django template system, we find this watcher that looks for **/* for each template directory

https://github.com/django/django/blob/5.2/django/template/autoreload.py#L48-L51

In our own app, we can use code like this to watch for any changes to markdown files.

from django.dispatch import receiver
from django.utils.autoreload import autoreload_started, BaseReloader

@receiver(autoreload_started)
def register_watches(sender: BaseReloader, **kwargs):
    sender.watch_dir(settings.BASE_DIR, "**/*.md")
    sender.watch_dir(settings.BASE_DIR, "**/*.markdown")

file_changed

With our watch signals configured, we then want to register a file_changed signal.

In the Django translation system, we have a translation_file_changed that matches our above watch. Any time the changed path ends with .mo Django will handle the translation cache.

https://github.com/django/django/blob/5.2/django/utils/translation/reloader.py#L25-L36

In the Django template system, we exit early if we find a file with a .py extension, otherwise, we reset the template loaders, similar to how we reset the translation cache.

https://github.com/django/django/blob/5.2/django/template/autoreload.py#L54-L61

In a similar way, we can register our file change signal to do something on update to files.

from django.dispatch import receiver
from django.utils.autoreload import file_changed, BaseReloader

@receiver(file_changed)
def process_file_changed(file_path: Path, **kwargs):
    if file_path.suffix in ['.md', '.markdown']:
        # Just log debug information for now
        print(file_path, kwargs)

The most confusing part of the file_changed signal, is what value we need to return.

Since it is not well documented, we need to look at the code.

https://github.com/django/django/blob/5.2/django/utils/autoreload.py#L368-L372

# Annotated version of the above
def notify_file_changed(self, path):
    # Whenever a file is changed, send the path of the changed file to each
    # registered signal. We will get the result. For the results, we will get
    # a list of tuples with the receiver and return code
    # [ (receiver_method, result), (receiver_method, result)  ]
    results = file_changed.send(sender=self, file_path=path)

    # We then check any() on *just* the results, which could look something like
    # any([True, False, None])
    # So ultimately, if any of our file_changed receivers, return True, then we
    # will *not* trigger a restart of our runserver
    if not any(res[1] for res in results):
        trigger_reload(path)

Since we likely do not want to restart on changes to markdown files in our case, we can update our above receiver to return True when we handle our files.

from django.dispatch import receiver
from django.utils.autoreload import file_changed, BaseReloader

@receiver(file_changed)
def process_file_changed(file_path: Path, **kwargs):
    if file_path.suffix in ['.md', '.markdown']:
        # Just log debug information for now
        print(file_path, kwargs)
        # Since we've handled our markdown files, we return True as to *not*
        # trigger a runserver restart
        return True
    # But if we fall through, our method returns None which will yield to
    # other file_changed handlers that could potentially restart the server

So for our file_changed receivers, we can interpret returning True as a file_changed event being handled without requiring a runserver restart.

Bonus

If you’re working with file watchers, running with pywatchman can result in more efficent running.

# Install the watchman daemon to run in the background
brew install watchman
# install pywatchman to our Django project in dev mode
uv add --dev pywatchman

See also Efficient Reloading