|
| 1 | +import logging |
| 2 | +import os |
| 3 | +import pickle |
| 4 | +import time |
| 5 | +from hashlib import sha1 |
| 6 | +from tempfile import gettempdir |
| 7 | +from typing import Dict, NamedTuple, Literal |
| 8 | + |
| 9 | +from django.apps import AppConfig |
| 10 | +from django.conf import settings |
| 11 | +from django.core.files.base import ContentFile |
| 12 | +from django.core.files.storage import FileSystemStorage |
| 13 | +from django.utils.autoreload import DJANGO_AUTORELOAD_ENV |
| 14 | +from django.utils.functional import cached_property |
| 15 | + |
| 16 | +from livereloadish.patches import ( |
| 17 | + do_patch_static_serve, |
| 18 | + do_patch_select_template, |
| 19 | + do_patch_get_template, |
| 20 | + do_patch_templateresponse_resolve_template, |
| 21 | + do_patch_engine_find_template, |
| 22 | + do_patch_staticnode_url, |
| 23 | + do_patch_filesystemstorage_url, |
| 24 | +) |
| 25 | + |
| 26 | +logger = logging.getLogger(__name__) |
| 27 | + |
| 28 | + |
| 29 | +Seen = NamedTuple( |
| 30 | + "Seen", [("relative_path", str), ("absolute_path", str), ("mtime", float)] |
| 31 | +) |
| 32 | + |
| 33 | + |
| 34 | +class LiveReloadishConfig(AppConfig): |
| 35 | + default_auto_field = "django.db.models.BigAutoField" |
| 36 | + name = "livereloadish" |
| 37 | + label = "livereloadish" |
| 38 | + |
| 39 | + # Assuming multiple projects, and each one is a separate venv, is probably enough... |
| 40 | + lockfile: str = sha1(os.path.dirname(__file__).encode("utf-8")).hexdigest() |
| 41 | + # How long before a file (either the lockfile or the individual entries therein) |
| 42 | + # is considered stale, in seconds. |
| 43 | + stale_after: int = 60 * 15 |
| 44 | + |
| 45 | + # Sleep durations for the SSE connection |
| 46 | + sleep_quick = 0.35 |
| 47 | + sleep_slow = 1.0 |
| 48 | + |
| 49 | + # This is intentionally mutable, fwiw. |
| 50 | + # It's also in a precise order, being that dicts are insertion ordered nowawdays. |
| 51 | + # CSS is most likely to change, then templates (which /may/ be a partial reload) |
| 52 | + # then finally JS which is most likely a full page reload (cos I ain't implemented |
| 53 | + # any form of module.hot style accept/reject) to throw away state and keep things |
| 54 | + # lovely and stateless. |
| 55 | + # And then a bunch of stuff where there may not be a specific reliable |
| 56 | + # strategy (eg: images. Easy enough to replace <img> but then what about <picture> |
| 57 | + # and srcset and CSS backgrounds etc) |
| 58 | + seen: Dict[str, Dict[str, Seen]] = { |
| 59 | + "text/css": {}, |
| 60 | + "text/html": {}, |
| 61 | + "application/xhtml+xml": {}, |
| 62 | + "text/javascript": {}, |
| 63 | + "image/png": {}, |
| 64 | + "image/jpeg": {}, |
| 65 | + "image/svg+xml": {}, |
| 66 | + "image/webp": {}, |
| 67 | + "image/gif": {}, |
| 68 | + "font/ttf": {}, |
| 69 | + "font/woff": {}, |
| 70 | + "font/woff2": {}, |
| 71 | + # "application/json": {}, |
| 72 | + } |
| 73 | + |
| 74 | + def ready(self) -> bool: |
| 75 | + if not self._should_be_enabled(): |
| 76 | + logger.debug("Livereloadish is not applying patches") |
| 77 | + logger.info("Livereloadish applying patches for the process") |
| 78 | + return all( |
| 79 | + ( |
| 80 | + do_patch_static_serve(), |
| 81 | + do_patch_select_template(), |
| 82 | + do_patch_get_template(), |
| 83 | + do_patch_templateresponse_resolve_template(), |
| 84 | + do_patch_engine_find_template(), |
| 85 | + do_patch_filesystemstorage_url(), |
| 86 | + do_patch_staticnode_url(), |
| 87 | + self.load_from_lockfile(), |
| 88 | + ) |
| 89 | + ) |
| 90 | + |
| 91 | + def add_to_seen( |
| 92 | + self, content_type: str, relative_path: str, absolute_path: str, mtime: float |
| 93 | + ) -> Literal[True]: |
| 94 | + self.seen[content_type][absolute_path] = Seen( |
| 95 | + relative_path, absolute_path, mtime |
| 96 | + ) |
| 97 | + return True |
| 98 | + |
| 99 | + @cached_property |
| 100 | + def lockfile_storage(self) -> FileSystemStorage: |
| 101 | + return FileSystemStorage( |
| 102 | + location=os.path.join(gettempdir(), "livereloadish"), |
| 103 | + base_url=None, |
| 104 | + ) |
| 105 | + |
| 106 | + def _should_be_enabled(self) -> bool: |
| 107 | + return settings.DEBUG is True and os.environ.get(DJANGO_AUTORELOAD_ENV, "false") == "true" |
| 108 | + |
| 109 | + def load_from_lockfile(self) -> bool: |
| 110 | + if not self._should_be_enabled(): |
| 111 | + logger.debug("Livereloadish skipping loading previously seen file cache") |
| 112 | + return False |
| 113 | + if not self.lockfile_storage.exists(self.lockfile): |
| 114 | + logger.debug("Livereloadish has no previously seen file cache") |
| 115 | + return False |
| 116 | + last_modified = os.path.getmtime(self.lockfile_storage.path(self.lockfile)) |
| 117 | + # If it's there but older than we'd like, assume a refresh is needed |
| 118 | + # to collect files to watch. |
| 119 | + if last_modified < (time.time() - self.stale_after): |
| 120 | + logger.info("Livereloadish has a stale cache of seen files") |
| 121 | + return False |
| 122 | + with self.lockfile_storage.open(self.lockfile) as f: |
| 123 | + try: |
| 124 | + self.seen = pickle.loads(f.read()) |
| 125 | + except EOFError: |
| 126 | + logger.warning( |
| 127 | + "Livereloadish previously seen files cache is corrupt: %s", |
| 128 | + self.lockfile, |
| 129 | + ) |
| 130 | + else: |
| 131 | + file_count = sum(len(values) for values in self.seen.values()) |
| 132 | + logger.debug( |
| 133 | + "Livereloadish %s previously seen files are being tracked from cache (< 15 minutes old): %s", |
| 134 | + file_count, |
| 135 | + self.lockfile, |
| 136 | + ) |
| 137 | + return True |
| 138 | + |
| 139 | + def dump_to_lockfile(self) -> bool: |
| 140 | + if not self._should_be_enabled(): |
| 141 | + logger.debug("Livereloadish skipping dumping previously seen file cache") |
| 142 | + return False |
| 143 | + file_count = sum(len(values) for values in self.seen.values()) |
| 144 | + logger.debug( |
| 145 | + "Livereloadish dumping %s previously seen files to cache: %s", |
| 146 | + file_count, |
| 147 | + self.lockfile, |
| 148 | + ) |
| 149 | + self.lockfile_storage.delete(self.lockfile) |
| 150 | + self.lockfile_storage.save(self.lockfile, ContentFile(pickle.dumps(self.seen))) |
| 151 | + return True |
0 commit comments