Skip to content

Commit be65686

Browse files
committed
After a couple of days of faffing and tilting at windmills, this mostly seems to work, somewhat surprisingly.
0 parents  commit be65686

19 files changed

+1939
-0
lines changed

CHANGELOG

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Change history for django-livereloadish
2+
---------------------------------------
3+
4+
?next?
5+
^^^^^^
6+
* ...

LICENSE

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Copyright (c) 2021, Keryn Knight
2+
All rights reserved.
3+
4+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
5+
6+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
7+
8+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9+
10+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

MANIFEST.in

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
include LICENSE
2+
include README.rst
3+
include Makefile
4+
include CHANGELOG
5+
global-include *.rst *.py *.html

Makefile

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
help:
2+
@echo "clean-build - get rid of build artifacts & metadata"
3+
@echo "clean-pyc - get rid of dross files"
4+
@echo "dist - build a distribution; calls test, clean-build and clean-pyc"
5+
@echo "check - check the quality of the built distribution; calls dist for you"
6+
@echo "release - register and upload to PyPI"
7+
8+
clean-build:
9+
rm -fr build/
10+
rm -fr htmlcov/
11+
rm -fr dist/
12+
rm -fr .eggs/
13+
find . -name '*.egg-info' -exec rm -fr {} +
14+
find . -name '*.egg' -exec rm -f {} +
15+
16+
17+
clean-pyc:
18+
find . -name '*.pyc' -exec rm -f {} +
19+
find . -name '*.pyo' -exec rm -f {} +
20+
find . -name '*~' -exec rm -f {} +
21+
find . -name '__pycache__' -exec rm -fr {} +
22+
23+
dist: test clean-build clean-pyc
24+
python setup.py sdist bdist_wheel
25+
26+
check: dist
27+
pip install check-manifest pyroma restview
28+
check-manifest
29+
pyroma .
30+
restview --long-description
31+
32+
release:
33+
@echo "INSTRUCTIONS:"
34+
@echo "- pip install wheel twine"
35+
@echo "- python setup.py sdist bdist_wheel"
36+
@echo "- ls dist/"
37+
@echo "- twine register dist/???"
38+
@echo "- twine upload dist/*"

README.rst

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
django-livereloadish
2+
====================
3+
4+
:author: OWNER
5+
:version: 0.1.0
6+
7+
8+
The license
9+
-----------
10+
11+
It's `FreeBSD`_. There's should be a ``LICENSE`` file in the root of the repository, and in any archives.
12+
13+
.. _FreeBSD: http://en.wikipedia.org/wiki/BSD_licenses#2-clause_license_.28.22Simplified_BSD_License.22_or_.22FreeBSD_License.22.29

livereloadish/__init__.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .apps import LiveReloadishConfig
2+
3+
from .middleware import LivereloadishMiddleware
4+
5+
6+
__all__ = ["LiveReloadishConfig", "LivereloadishMiddleware"]
7+
default_app_config = "livereloadish.apps.LiveReloadishConfig"

livereloadish/apps.py

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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

livereloadish/middleware.py

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import logging
2+
from collections import namedtuple
3+
from typing import Any
4+
from uuid import uuid4
5+
6+
from django.conf import settings
7+
from django.core.exceptions import MiddlewareNotUsed
8+
from django.core.handlers.wsgi import WSGIRequest
9+
from django.http.response import HttpResponseBase
10+
from django.urls import include, path
11+
12+
import livereloadish.urls
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class NamedUrlconf(namedtuple("NamedUrl", "included_patterns")):
18+
def __str__(self) -> str:
19+
return "livereloadish.middleware.LivereloadishMiddleware"
20+
21+
22+
class LivereloadishMiddleware:
23+
prefix = "livereloadish"
24+
content_types = ("text/html", "application/xhtml+xml")
25+
insert_js_before = "</body>"
26+
insert_js_content = f'<script data-livereloadish-id="{{uuid}}" type="text/javascript" data-livereloadish-url="/{prefix}/watch/?livereloadish={{uuid}}" src="/{{prefix}}/watcher/livereloadish.js" defer async data-turbolinks="false" data-turbolinks-eval="false"></script></body>'
27+
28+
def __init__(self, get_response: Any) -> None:
29+
if not settings.DEBUG:
30+
raise MiddlewareNotUsed("Livereloadish is only available if DEBUG=True")
31+
self.get_response = get_response
32+
33+
def __call__(self, request: WSGIRequest) -> HttpResponseBase:
34+
if request.path[0:15] == f"/{self.prefix}/" and settings.DEBUG:
35+
request.urlconf = NamedUrlconf(
36+
path(f"{self.prefix}/", include(livereloadish.urls.urlpatterns))
37+
)
38+
return self.get_response(request)
39+
return self.insert_js(request, self.get_response(request))
40+
41+
def insert_js(
42+
self, request: WSGIRequest, response: HttpResponseBase
43+
) -> HttpResponseBase:
44+
# This prelude is taken from Django-debug-toolbar's middleware, because
45+
# it's been rock solid for my usage for 10 years, can't be totally wrong.
46+
content_encoding = response.headers.get("Content-Encoding", "")
47+
content_type = response.headers.get("Content-Type", "").partition(";")[0]
48+
# Additionally I don't want to load the SSE connection for 401/403/404 etc
49+
# because those cannot be rectified by a CSS/JS/HTML change so the auto-reloader
50+
# would kick in for the Python/Django change.
51+
# Note that it is still turned on for 500 errors (ie: the technical debug page)
52+
# because those may stem from TemplateSyntaxError, which is resolvable.
53+
if (
54+
getattr(response, "streaming", False)
55+
or "gzip" in content_encoding
56+
or content_type not in self.content_types
57+
or (400 < response.status_code < 500)
58+
):
59+
logger.debug(
60+
"Livereloadish not being mounted for path %s with HTTP status=%s",
61+
request.path,
62+
response.status_code,
63+
)
64+
return response
65+
content = response.content.decode(response.charset)
66+
if self.insert_js_before in content:
67+
logger.debug("Livereloadish is being mounted for path %s", request.path)
68+
response.content = content.replace(
69+
self.insert_js_before,
70+
self.insert_js_content.format(prefix=self.prefix, uuid=uuid4()),
71+
)
72+
if "Content-Length" in response.headers:
73+
response["Content-Length"] = len(response.content)
74+
return response

0 commit comments

Comments
 (0)