123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685 |
- import functools
- import itertools
- import logging
- import os
- import signal
- import subprocess
- import sys
- import threading
- import time
- import traceback
- import weakref
- from collections import defaultdict
- from pathlib import Path
- from types import ModuleType
- from zipimport import zipimporter
-
- import django
- from django.apps import apps
- from django.core.signals import request_finished
- from django.dispatch import Signal
- from django.utils.functional import cached_property
- from django.utils.version import get_version_tuple
-
- autoreload_started = Signal()
- file_changed = Signal()
-
- DJANGO_AUTORELOAD_ENV = "RUN_MAIN"
-
- logger = logging.getLogger("django.utils.autoreload")
-
- # If an error is raised while importing a file, it's not placed in sys.modules.
- # This means that any future modifications aren't caught. Keep a list of these
- # file paths to allow watching them in the future.
- _error_files = []
- _exception = None
-
- try:
- import termios
- except ImportError:
- termios = None
-
-
- try:
- import pywatchman
- except ImportError:
- pywatchman = None
-
-
- def is_django_module(module):
- """Return True if the given module is nested under Django."""
- return module.__name__.startswith("django.")
-
-
- def is_django_path(path):
- """Return True if the given file path is nested under Django."""
- return Path(django.__file__).parent in Path(path).parents
-
-
- def check_errors(fn):
- @functools.wraps(fn)
- def wrapper(*args, **kwargs):
- global _exception
- try:
- fn(*args, **kwargs)
- except Exception:
- _exception = sys.exc_info()
-
- et, ev, tb = _exception
-
- if getattr(ev, "filename", None) is None:
- # get the filename from the last item in the stack
- filename = traceback.extract_tb(tb)[-1][0]
- else:
- filename = ev.filename
-
- if filename not in _error_files:
- _error_files.append(filename)
-
- raise
-
- return wrapper
-
-
- def raise_last_exception():
- global _exception
- if _exception is not None:
- raise _exception[1]
-
-
- def ensure_echo_on():
- """
- Ensure that echo mode is enabled. Some tools such as PDB disable
- it which causes usability issues after reload.
- """
- if not termios or not sys.stdin.isatty():
- return
- attr_list = termios.tcgetattr(sys.stdin)
- if not attr_list[3] & termios.ECHO:
- attr_list[3] |= termios.ECHO
- if hasattr(signal, "SIGTTOU"):
- old_handler = signal.signal(signal.SIGTTOU, signal.SIG_IGN)
- else:
- old_handler = None
- termios.tcsetattr(sys.stdin, termios.TCSANOW, attr_list)
- if old_handler is not None:
- signal.signal(signal.SIGTTOU, old_handler)
-
-
- def iter_all_python_module_files():
- # This is a hot path during reloading. Create a stable sorted list of
- # modules based on the module name and pass it to iter_modules_and_files().
- # This ensures cached results are returned in the usual case that modules
- # aren't loaded on the fly.
- keys = sorted(sys.modules)
- modules = tuple(
- m
- for m in map(sys.modules.__getitem__, keys)
- if not isinstance(m, weakref.ProxyTypes)
- )
- return iter_modules_and_files(modules, frozenset(_error_files))
-
-
- @functools.lru_cache(maxsize=1)
- def iter_modules_and_files(modules, extra_files):
- """Iterate through all modules needed to be watched."""
- sys_file_paths = []
- for module in modules:
- # During debugging (with PyDev) the 'typing.io' and 'typing.re' objects
- # are added to sys.modules, however they are types not modules and so
- # cause issues here.
- if not isinstance(module, ModuleType):
- continue
- if module.__name__ in ("__main__", "__mp_main__"):
- # __main__ (usually manage.py) doesn't always have a __spec__ set.
- # Handle this by falling back to using __file__, resolved below.
- # See https://docs.python.org/reference/import.html#main-spec
- # __file__ may not exists, e.g. when running ipdb debugger.
- if hasattr(module, "__file__"):
- sys_file_paths.append(module.__file__)
- continue
- if getattr(module, "__spec__", None) is None:
- continue
- spec = module.__spec__
- # Modules could be loaded from places without a concrete location. If
- # this is the case, skip them.
- if spec.has_location:
- origin = (
- spec.loader.archive
- if isinstance(spec.loader, zipimporter)
- else spec.origin
- )
- sys_file_paths.append(origin)
-
- results = set()
- for filename in itertools.chain(sys_file_paths, extra_files):
- if not filename:
- continue
- path = Path(filename)
- try:
- if not path.exists():
- # The module could have been removed, don't fail loudly if this
- # is the case.
- continue
- except ValueError as e:
- # Network filesystems may return null bytes in file paths.
- logger.debug('"%s" raised when resolving path: "%s"', e, path)
- continue
- resolved_path = path.resolve().absolute()
- results.add(resolved_path)
- return frozenset(results)
-
-
- @functools.lru_cache(maxsize=1)
- def common_roots(paths):
- """
- Return a tuple of common roots that are shared between the given paths.
- File system watchers operate on directories and aren't cheap to create.
- Try to find the minimum set of directories to watch that encompass all of
- the files that need to be watched.
- """
- # Inspired from Werkzeug:
- # https://github.com/pallets/werkzeug/blob/7477be2853df70a022d9613e765581b9411c3c39/werkzeug/_reloader.py
- # Create a sorted list of the path components, longest first.
- path_parts = sorted([x.parts for x in paths], key=len, reverse=True)
- tree = {}
- for chunks in path_parts:
- node = tree
- # Add each part of the path to the tree.
- for chunk in chunks:
- node = node.setdefault(chunk, {})
- # Clear the last leaf in the tree.
- node.clear()
-
- # Turn the tree into a list of Path instances.
- def _walk(node, path):
- for prefix, child in node.items():
- yield from _walk(child, path + (prefix,))
- if not node:
- yield Path(*path)
-
- return tuple(_walk(tree, ()))
-
-
- def sys_path_directories():
- """
- Yield absolute directories from sys.path, ignoring entries that don't
- exist.
- """
- for path in sys.path:
- path = Path(path)
- if not path.exists():
- continue
- resolved_path = path.resolve().absolute()
- # If the path is a file (like a zip file), watch the parent directory.
- if resolved_path.is_file():
- yield resolved_path.parent
- else:
- yield resolved_path
-
-
- def get_child_arguments():
- """
- Return the executable. This contains a workaround for Windows if the
- executable is reported to not have the .exe extension which can cause bugs
- on reloading.
- """
- import __main__
-
- py_script = Path(sys.argv[0])
-
- args = [sys.executable] + ["-W%s" % o for o in sys.warnoptions]
- if sys.implementation.name == "cpython":
- args.extend(
- f"-X{key}" if value is True else f"-X{key}={value}"
- for key, value in sys._xoptions.items()
- )
- # __spec__ is set when the server was started with the `-m` option,
- # see https://docs.python.org/3/reference/import.html#main-spec
- # __spec__ may not exist, e.g. when running in a Conda env.
- if getattr(__main__, "__spec__", None) is not None:
- spec = __main__.__spec__
- if (spec.name == "__main__" or spec.name.endswith(".__main__")) and spec.parent:
- name = spec.parent
- else:
- name = spec.name
- args += ["-m", name]
- args += sys.argv[1:]
- elif not py_script.exists():
- # sys.argv[0] may not exist for several reasons on Windows.
- # It may exist with a .exe extension or have a -script.py suffix.
- exe_entrypoint = py_script.with_suffix(".exe")
- if exe_entrypoint.exists():
- # Should be executed directly, ignoring sys.executable.
- return [exe_entrypoint, *sys.argv[1:]]
- script_entrypoint = py_script.with_name("%s-script.py" % py_script.name)
- if script_entrypoint.exists():
- # Should be executed as usual.
- return [*args, script_entrypoint, *sys.argv[1:]]
- raise RuntimeError("Script %s does not exist." % py_script)
- else:
- args += sys.argv
- return args
-
-
- def trigger_reload(filename):
- logger.info("%s changed, reloading.", filename)
- sys.exit(3)
-
-
- def restart_with_reloader():
- new_environ = {**os.environ, DJANGO_AUTORELOAD_ENV: "true"}
- args = get_child_arguments()
- while True:
- p = subprocess.run(args, env=new_environ, close_fds=False)
- if p.returncode != 3:
- return p.returncode
-
-
- class BaseReloader:
- def __init__(self):
- self.extra_files = set()
- self.directory_globs = defaultdict(set)
- self._stop_condition = threading.Event()
-
- def watch_dir(self, path, glob):
- path = Path(path)
- try:
- path = path.absolute()
- except FileNotFoundError:
- logger.debug(
- "Unable to watch directory %s as it cannot be resolved.",
- path,
- exc_info=True,
- )
- return
- logger.debug("Watching dir %s with glob %s.", path, glob)
- self.directory_globs[path].add(glob)
-
- def watched_files(self, include_globs=True):
- """
- Yield all files that need to be watched, including module files and
- files within globs.
- """
- yield from iter_all_python_module_files()
- yield from self.extra_files
- if include_globs:
- for directory, patterns in self.directory_globs.items():
- for pattern in patterns:
- yield from directory.glob(pattern)
-
- def wait_for_apps_ready(self, app_reg, django_main_thread):
- """
- Wait until Django reports that the apps have been loaded. If the given
- thread has terminated before the apps are ready, then a SyntaxError or
- other non-recoverable error has been raised. In that case, stop waiting
- for the apps_ready event and continue processing.
-
- Return True if the thread is alive and the ready event has been
- triggered, or False if the thread is terminated while waiting for the
- event.
- """
- while django_main_thread.is_alive():
- if app_reg.ready_event.wait(timeout=0.1):
- return True
- else:
- logger.debug("Main Django thread has terminated before apps are ready.")
- return False
-
- def run(self, django_main_thread):
- logger.debug("Waiting for apps ready_event.")
- self.wait_for_apps_ready(apps, django_main_thread)
- from django.urls import get_resolver
-
- # Prevent a race condition where URL modules aren't loaded when the
- # reloader starts by accessing the urlconf_module property.
- try:
- get_resolver().urlconf_module
- except Exception:
- # Loading the urlconf can result in errors during development.
- # If this occurs then swallow the error and continue.
- pass
- logger.debug("Apps ready_event triggered. Sending autoreload_started signal.")
- autoreload_started.send(sender=self)
- self.run_loop()
-
- def run_loop(self):
- ticker = self.tick()
- while not self.should_stop:
- try:
- next(ticker)
- except StopIteration:
- break
- self.stop()
-
- def tick(self):
- """
- This generator is called in a loop from run_loop. It's important that
- the method takes care of pausing or otherwise waiting for a period of
- time. This split between run_loop() and tick() is to improve the
- testability of the reloader implementations by decoupling the work they
- do from the loop.
- """
- raise NotImplementedError("subclasses must implement tick().")
-
- @classmethod
- def check_availability(cls):
- raise NotImplementedError("subclasses must implement check_availability().")
-
- def notify_file_changed(self, path):
- results = file_changed.send(sender=self, file_path=path)
- logger.debug("%s notified as changed. Signal results: %s.", path, results)
- if not any(res[1] for res in results):
- trigger_reload(path)
-
- # These are primarily used for testing.
- @property
- def should_stop(self):
- return self._stop_condition.is_set()
-
- def stop(self):
- self._stop_condition.set()
-
-
- class StatReloader(BaseReloader):
- SLEEP_TIME = 1 # Check for changes once per second.
-
- def tick(self):
- mtimes = {}
- while True:
- for filepath, mtime in self.snapshot_files():
- old_time = mtimes.get(filepath)
- mtimes[filepath] = mtime
- if old_time is None:
- logger.debug("File %s first seen with mtime %s", filepath, mtime)
- continue
- elif mtime > old_time:
- logger.debug(
- "File %s previous mtime: %s, current mtime: %s",
- filepath,
- old_time,
- mtime,
- )
- self.notify_file_changed(filepath)
-
- time.sleep(self.SLEEP_TIME)
- yield
-
- def snapshot_files(self):
- # watched_files may produce duplicate paths if globs overlap.
- seen_files = set()
- for file in self.watched_files():
- if file in seen_files:
- continue
- try:
- mtime = file.stat().st_mtime
- except OSError:
- # This is thrown when the file does not exist.
- continue
- seen_files.add(file)
- yield file, mtime
-
- @classmethod
- def check_availability(cls):
- return True
-
-
- class WatchmanUnavailable(RuntimeError):
- pass
-
-
- class WatchmanReloader(BaseReloader):
- def __init__(self):
- self.roots = defaultdict(set)
- self.processed_request = threading.Event()
- self.client_timeout = int(os.environ.get("DJANGO_WATCHMAN_TIMEOUT", 5))
- super().__init__()
-
- @cached_property
- def client(self):
- return pywatchman.client(timeout=self.client_timeout)
-
- def _watch_root(self, root):
- # In practice this shouldn't occur, however, it's possible that a
- # directory that doesn't exist yet is being watched. If it's outside of
- # sys.path then this will end up a new root. How to handle this isn't
- # clear: Not adding the root will likely break when subscribing to the
- # changes, however, as this is currently an internal API, no files
- # will be being watched outside of sys.path. Fixing this by checking
- # inside watch_glob() and watch_dir() is expensive, instead this could
- # could fall back to the StatReloader if this case is detected? For
- # now, watching its parent, if possible, is sufficient.
- if not root.exists():
- if not root.parent.exists():
- logger.warning(
- "Unable to watch root dir %s as neither it or its parent exist.",
- root,
- )
- return
- root = root.parent
- result = self.client.query("watch-project", str(root.absolute()))
- if "warning" in result:
- logger.warning("Watchman warning: %s", result["warning"])
- logger.debug("Watchman watch-project result: %s", result)
- return result["watch"], result.get("relative_path")
-
- @functools.lru_cache
- def _get_clock(self, root):
- return self.client.query("clock", root)["clock"]
-
- def _subscribe(self, directory, name, expression):
- root, rel_path = self._watch_root(directory)
- # Only receive notifications of files changing, filtering out other types
- # like special files: https://facebook.github.io/watchman/docs/type
- only_files_expression = [
- "allof",
- ["anyof", ["type", "f"], ["type", "l"]],
- expression,
- ]
- query = {
- "expression": only_files_expression,
- "fields": ["name"],
- "since": self._get_clock(root),
- "dedup_results": True,
- }
- if rel_path:
- query["relative_root"] = rel_path
- logger.debug(
- "Issuing watchman subscription %s, for root %s. Query: %s",
- name,
- root,
- query,
- )
- self.client.query("subscribe", root, name, query)
-
- def _subscribe_dir(self, directory, filenames):
- if not directory.exists():
- if not directory.parent.exists():
- logger.warning(
- "Unable to watch directory %s as neither it or its parent exist.",
- directory,
- )
- return
- prefix = "files-parent-%s" % directory.name
- filenames = ["%s/%s" % (directory.name, filename) for filename in filenames]
- directory = directory.parent
- expression = ["name", filenames, "wholename"]
- else:
- prefix = "files"
- expression = ["name", filenames]
- self._subscribe(directory, "%s:%s" % (prefix, directory), expression)
-
- def _watch_glob(self, directory, patterns):
- """
- Watch a directory with a specific glob. If the directory doesn't yet
- exist, attempt to watch the parent directory and amend the patterns to
- include this. It's important this method isn't called more than one per
- directory when updating all subscriptions. Subsequent calls will
- overwrite the named subscription, so it must include all possible glob
- expressions.
- """
- prefix = "glob"
- if not directory.exists():
- if not directory.parent.exists():
- logger.warning(
- "Unable to watch directory %s as neither it or its parent exist.",
- directory,
- )
- return
- prefix = "glob-parent-%s" % directory.name
- patterns = ["%s/%s" % (directory.name, pattern) for pattern in patterns]
- directory = directory.parent
-
- expression = ["anyof"]
- for pattern in patterns:
- expression.append(["match", pattern, "wholename"])
- self._subscribe(directory, "%s:%s" % (prefix, directory), expression)
-
- def watched_roots(self, watched_files):
- extra_directories = self.directory_globs.keys()
- watched_file_dirs = [f.parent for f in watched_files]
- sys_paths = list(sys_path_directories())
- return frozenset((*extra_directories, *watched_file_dirs, *sys_paths))
-
- def _update_watches(self):
- watched_files = list(self.watched_files(include_globs=False))
- found_roots = common_roots(self.watched_roots(watched_files))
- logger.debug("Watching %s files", len(watched_files))
- logger.debug("Found common roots: %s", found_roots)
- # Setup initial roots for performance, shortest roots first.
- for root in sorted(found_roots):
- self._watch_root(root)
- for directory, patterns in self.directory_globs.items():
- self._watch_glob(directory, patterns)
- # Group sorted watched_files by their parent directory.
- sorted_files = sorted(watched_files, key=lambda p: p.parent)
- for directory, group in itertools.groupby(sorted_files, key=lambda p: p.parent):
- # These paths need to be relative to the parent directory.
- self._subscribe_dir(
- directory, [str(p.relative_to(directory)) for p in group]
- )
-
- def update_watches(self):
- try:
- self._update_watches()
- except Exception as ex:
- # If the service is still available, raise the original exception.
- if self.check_server_status(ex):
- raise
-
- def _check_subscription(self, sub):
- subscription = self.client.getSubscription(sub)
- if not subscription:
- return
- logger.debug("Watchman subscription %s has results.", sub)
- for result in subscription:
- # When using watch-project, it's not simple to get the relative
- # directory without storing some specific state. Store the full
- # path to the directory in the subscription name, prefixed by its
- # type (glob, files).
- root_directory = Path(result["subscription"].split(":", 1)[1])
- logger.debug("Found root directory %s", root_directory)
- for file in result.get("files", []):
- self.notify_file_changed(root_directory / file)
-
- def request_processed(self, **kwargs):
- logger.debug("Request processed. Setting update_watches event.")
- self.processed_request.set()
-
- def tick(self):
- request_finished.connect(self.request_processed)
- self.update_watches()
- while True:
- if self.processed_request.is_set():
- self.update_watches()
- self.processed_request.clear()
- try:
- self.client.receive()
- except pywatchman.SocketTimeout:
- pass
- except pywatchman.WatchmanError as ex:
- logger.debug("Watchman error: %s, checking server status.", ex)
- self.check_server_status(ex)
- else:
- for sub in list(self.client.subs.keys()):
- self._check_subscription(sub)
- yield
- # Protect against busy loops.
- time.sleep(0.1)
-
- def stop(self):
- self.client.close()
- super().stop()
-
- def check_server_status(self, inner_ex=None):
- """Return True if the server is available."""
- try:
- self.client.query("version")
- except Exception:
- raise WatchmanUnavailable(str(inner_ex)) from inner_ex
- return True
-
- @classmethod
- def check_availability(cls):
- 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.")
- version = get_version_tuple(result["version"])
- # Watchman 4.9 includes multiple improvements to watching project
- # directories as well as case insensitive filesystems.
- logger.debug("Watchman version %s", version)
- if version < (4, 9):
- raise WatchmanUnavailable("Watchman 4.9 or later is required.")
-
-
- def get_reloader():
- """Return the most suitable reloader for this environment."""
- try:
- WatchmanReloader.check_availability()
- except WatchmanUnavailable:
- return StatReloader()
- return WatchmanReloader()
-
-
- def start_django(reloader, main_func, *args, **kwargs):
- ensure_echo_on()
-
- main_func = check_errors(main_func)
- django_main_thread = threading.Thread(
- target=main_func, args=args, kwargs=kwargs, name="django-main-thread"
- )
- django_main_thread.daemon = True
- django_main_thread.start()
-
- while not reloader.should_stop:
- try:
- reloader.run(django_main_thread)
- except WatchmanUnavailable as ex:
- # It's possible that the watchman service shuts down or otherwise
- # becomes unavailable. In that case, use the StatReloader.
- reloader = StatReloader()
- logger.error("Error connecting to Watchman: %s", ex)
- logger.info(
- "Watching for file changes with %s", reloader.__class__.__name__
- )
-
-
- def run_with_reloader(main_func, *args, **kwargs):
- signal.signal(signal.SIGTERM, lambda *args: sys.exit(0))
- try:
- if os.environ.get(DJANGO_AUTORELOAD_ENV) == "true":
- reloader = get_reloader()
- logger.info(
- "Watching for file changes with %s", reloader.__class__.__name__
- )
- start_django(reloader, main_func, *args, **kwargs)
- else:
- exit_code = restart_with_reloader()
- sys.exit(exit_code)
- except KeyboardInterrupt:
- pass
|