--- /dev/null
+# Changelog
+
+## 0.2 (2014-09-15)
+
+* Nicer cache choosing: use settings.SSIFY_CACHE_ALIASES if set,
+ otherwise use 'ssify' cache if configure, otherwise
+ fall back to 'default'.
+
+* Renamed `csrf_token` template tag to `ssi_csrf_token` to avoid
+ simple mistakes.
+
+* Cache control: `ssi_variable` now takes `patch_response` instead
+ of `vary` parameter, `ssi_include` takes `timeout`, `version` and
+ also `patch_reponse` parameters. Also added some helper functions
+ in ssify.utils.
+
+* Added `flush_ssi_includes` function.
+
+* Debug rendering: renamed SSIFY_DEBUG to SSIFY_RENDER, added support
+ for including streaming responses when SSIFY_RENDER=True.
+
+* Dropped Django 1.4 support.
+
+
+## 0.1 (2014-09-02)
+
+* Initial release.
============
* Python >= 2.6
- * Django >= 1.4
+ * Django >= 1.5
Installation
* ssify.middleware.LocaleMiddleware instead of stock LocaleMiddleware.
3. Make sure you have 'django.core.context_processors.request' in your
TEMPLATE_CONTEXT_PROCESSORS.
-3. Define your caches in CACHES and SSIFY_INCLUDES_CACHES
- for storing ssi includes.
4. Configure your webserver to use SSI ('ssi=on' with Nginx).
Usage
STATIC_URL='/static/',
ROOT_URLCONF='tests.urls',
SITE_ID=1,
- SSIFY_DEBUG_VERBOSE=False,
TEMPLATE_CONTEXT_PROCESSORS=(
"django.core.context_processors.debug",
"django.core.context_processors.i18n",
setup(
name='django-ssify',
- version='0.1',
+ version='0.2',
author='Radek Czajka',
author_email='radekczajka@nowoczesnapolska.org.pl',
- url='http://git.nowoczesnapolska.org.pl/?p=django-ssify.git',
+ url='http://git.mdrn.pl/django-ssify.git',
packages=find_packages(exclude=['tests*']),
license='LICENSE',
description='Two-phased rendering using SSI.',
long_description=open('README.md').read(),
install_requires=[
- 'Django>=1.4',
+ 'Django>=1.5',
],
test_suite="runtests.runtests",
classifiers=[
- "Development Status :: 3 - Alpha",
+ "Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Framework :: Django",
"Intended Audience :: Developers",
__version__ = '1.0'
__date__ = '2014-08-26'
-__all__ = ('ssi_expect', 'SsiVariable', 'ssi_included', 'ssi_variable')
-
-from django.conf import settings
-from django.utils.functional import lazy
-
-SETTING = lazy(
- lambda name, default: getattr(settings, name, default),
- bool, int, list, tuple, str)
-
-INCLUDES_CACHES = SETTING('SSIFY_INCLUDES_CACHES', ('ssify',))
-DEBUG = SETTING('SSIFY_DEBUG', False)
-DEBUG_VERBOSE = SETTING('SSIFY_DEBUG_VERBOSE', True)
-
+__all__ = ('flush_ssi_includes', 'ssi_expect', 'SsiVariable', 'ssi_included', 'ssi_variable')
from .variables import ssi_expect, SsiVariable
from .decorators import ssi_included, ssi_variable
+from .cache import flush_ssi_includes
# Copyright © Fundacja Nowoczesna Polska. See README.md for more information.
#
from __future__ import unicode_literals
-import os
-from django.core.cache.backends.filebased import FileBasedCache
-
-
-class StaticFileBasedCache(FileBasedCache):
- def __init__(self, *args, **kwargs):
- super(StaticFileBasedCache, self).__init__(*args, **kwargs)
- self._dir = os.path.abspath(self._dir)
-
- def make_key(self, key, version=None):
- assert version is None, \
- 'StaticFileBasedCache does not support versioning.'
- return key
-
- def _key_to_file(self, key):
- key = os.path.abspath(os.path.join(self._dir, key.lstrip('/')))
- assert key.startswith(self._dir), 'Trying to save path outside root.'
- if key.endswith('/'):
- key += 'index.html'
- return key
-
- def get(self, key, default=None, version=None):
- key = self.make_key(key, version=version)
- self.validate_key(key)
- fname = self._key_to_file(key)
- try:
- with open(fname, 'rb') as inf:
- return inf.read()
- except (IOError, OSError):
- pass
- return default
-
- def set(self, key, value, timeout=None, version=None):
- assert timeout is None, \
- 'StaticFileBasedCache does not support timeouts.'
- key = self.make_key(key, version=version)
- self.validate_key(key)
- fname = self._key_to_file(key)
- dirname = os.path.dirname(fname)
+from django.core.cache import InvalidCacheBackendError
+from .conf import conf
+
+
+DEFAULT_TIMEOUT = object()
+
+
+try:
+ from django.core.cache import caches
+except ImportError:
+ from django.core.cache import get_cache
+else:
+ get_cache = lambda alias: caches[alias]
+
+
+def get_caches():
+ try:
+ return [get_cache(c) for c in conf.CACHE_ALIASES]
+ except:
try:
- if not os.path.exists(dirname):
- os.makedirs(dirname)
- with open(fname, 'wb') as outf:
- outf.write(value)
- except (IOError, OSError):
- pass
+ return [get_cache('ssify')]
+ except InvalidCacheBackendError:
+ return [get_cache('default')]
+
+
+def cache_include(path, content, timeout=DEFAULT_TIMEOUT, version=None):
+ for cache in get_caches():
+ if timeout is DEFAULT_TIMEOUT:
+ cache.set(path, content, version=version)
+ else:
+ cache.set(path, content, timeout=timeout, version=version)
+
+
+def flush_ssi_includes(paths=None):
+ for cache in get_caches():
+ if paths is None:
+ cache.clear()
+ else:
+ cache.delete_many(paths)
--- /dev/null
+# -*- coding: utf-8 -*-
+# This file is part of django-ssify, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See README.md for more information.
+#
+from __future__ import unicode_literals
+import os
+from django.core.cache.backends.filebased import FileBasedCache
+
+
+class StaticFileBasedCache(FileBasedCache):
+ def __init__(self, *args, **kwargs):
+ super(StaticFileBasedCache, self).__init__(*args, **kwargs)
+ self._dir = os.path.abspath(self._dir)
+
+ def make_key(self, key, version=None):
+ assert version is None, \
+ 'StaticFileBasedCache does not support versioning.'
+ return key
+
+ def _key_to_file(self, key):
+ key = os.path.abspath(os.path.join(self._dir, key.lstrip('/')))
+ assert key.startswith(self._dir), 'Trying to save path outside root.'
+ if key.endswith('/'):
+ key += 'index.html'
+ return key
+
+ def get(self, key, default=None, version=None):
+ key = self.make_key(key, version=version)
+ self.validate_key(key)
+ fname = self._key_to_file(key)
+ try:
+ with open(fname, 'rb') as inf:
+ return inf.read()
+ except (IOError, OSError):
+ pass
+ return default
+
+ def set(self, key, value, timeout=None, version=None):
+ assert timeout is None, \
+ 'StaticFileBasedCache does not support timeouts.'
+ key = self.make_key(key, version=version)
+ self.validate_key(key)
+ fname = self._key_to_file(key)
+ dirname = os.path.dirname(fname)
+ try:
+ if not os.path.exists(dirname):
+ os.makedirs(dirname)
+ with open(fname, 'wb') as outf:
+ outf.write(value)
+ except (IOError, OSError):
+ pass
--- /dev/null
+# -*- coding: utf-8 -*-
+# This file is part of django-ssify, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Nowoczesna Polska. See README.md for more information.
+#
+from django.conf import settings
+
+
+class AppSettings(object):
+ prefix = 'SSIFY_'
+
+ @classmethod
+ def add(cls, name, default):
+ setattr(cls, name, property(lambda self:
+ getattr(settings, self.prefix + name, default)))
+
+
+AppSettings.add('CACHE_ALIASES', None)
+AppSettings.add('RENDER', False)
+AppSettings.add('RENDER_VERBOSE', False)
+
+
+conf = AppSettings()
import warnings
from django.template.base import parse_bits
from django.utils.translation import get_language, activate
-from .store import cache_include
+from .cache import cache_include, DEFAULT_TIMEOUT
from . import exceptions
from .variables import SsiVariable
-def ssi_included(view=None, use_lang=True, get_ssi_vars=None):
+def ssi_included(view=None, use_lang=True,
+ timeout=DEFAULT_TIMEOUT, version=None,
+ get_ssi_vars=None, patch_response=None):
"""
Marks a view to be used as a snippet to be included with SSI.
# Don't use default django response caching for this view,
# just save the contents instead.
- cache_include(request.path, response.content)
+ cache_include(request.path, response.content,
+ timeout=timeout, version=version)
if hasattr(response, 'render') and callable(response.render):
response.add_post_render_callback(_check_included_vars)
# Remember get_ssi_vars so that in can be computed from args/kwargs
# by including view.
new_view.get_ssi_vars = get_ssi_vars
+ new_view.ssi_patch_response = patch_response
return new_view
return dec(view) if view else dec
-def ssi_variable(register, vary=None, name=None):
+def ssi_variable(register, name=None, patch_response=None):
"""
Creates a template tag representing an SSI variable from a function.
['context'] + params[1:], varargs, varkw,
defaults, takes_context=True,
name=function_name)
- return SsiVariableNode(tagpath, args, kwargs, vary, asvar)
+ return SsiVariableNode(tagpath, args, kwargs, patch_response, asvar)
_ssi_var_tag.get_value = func
#return _ssi_var_tag
return func
"""
from __future__ import unicode_literals
from django.conf import settings
-from django.utils.cache import patch_vary_headers
from django.middleware import locale
+from django.utils.cache import patch_vary_headers
+from .conf import conf
from .serializers import json_decode, json_encode
+from .utils import ssi_vary_on_cookie
from .variables import SsiVariable, provide_vars
-from . import DEBUG
+
+
+CACHE_HEADERS = ('Pragma', 'Cache-Control', 'Vary')
class PrepareForCacheMiddleware(object):
@staticmethod
def process_response(request, response):
"""Adds a 'X-Ssi-Vars-Needed' header to the response."""
- if getattr(request, 'ssi_vars_needed', None):
+ if ('X-Ssi-Vars-Needed' not in response and
+ getattr(request, 'ssi_vars_needed', None)):
vars_needed = {}
for (k, v) in request.ssi_vars_needed.items():
vars_needed[k] = v.definition
response['X-Ssi-Vars-Needed'] = json_encode(
vars_needed, sort_keys=True)
+
+ if ('X-ssi-restore' not in response and
+ getattr(request, 'ssi_patch_response', None)):
+ # We have some response modifiers set by ssi_includes and
+ # ssi_variables. Those are used, because unrendered SSI
+ # templates Django cache receives should have different
+ # caching headers, than pages rendered with request-specific
+ # information.
+ # What we do here is apply the modifiers, but restore
+ # previous values of any cache-relevant headers and set
+ # a custom header with modified values to set them
+ # after-cache.
+ original_fields = {}
+ for field in CACHE_HEADERS:
+ original_fields[field] = response.get(field, None)
+ for modifier in request.ssi_patch_response:
+ modifier(response)
+ restore_fields = {}
+ for field in CACHE_HEADERS:
+ new_value = response.get(field, None)
+ if new_value != original_fields[field]:
+ restore_fields[field] = new_value
+ if original_fields[field] is None:
+ del response[field]
+ else:
+ response[field] = original_fields[field]
+ response['X-ssi-restore'] = json_encode(restore_fields)
+
return response
It also patches the Vary header with the values given by
the SSI variables.
- If SSIFY_DEBUG is set, it also passes the response through
- DebugUnSsiMiddleware, which interprets and renders the SSI
+ If SSIFY_RENDER is set, it also passes the response through
+ SsiRenderMiddleware, which interprets and renders the SSI
statements, so you can see the output without an actual
SSI-enabled webserver.
"""
def process_request(self, request):
- request.ssi_vary = set()
- #request.ssi_cache_control_after = set()
+ request.ssi_patch_response = []
def process_view(self, request, view_func, view_args, view_kwargs):
request.ssi_vars_needed = {}
response.content = provide_vars(request, vars_needed) + \
response.content
- # Add the Vary headers declared by all the SSI vars.
- patch_vary_headers(response, sorted(request.ssi_vary))
- # TODO: cache control?
+ if 'X-ssi-restore' in response:
+ # The modifiers have already been applied to the response
+ # by the PrepareForCacheMiddleware.
+ # All we need to do is restore cache-relevant headers.
+ for header, content in json_decode(response['X-ssi-restore']).items():
+ if content is None:
+ del response[header]
+ else:
+ response[header] = content
+ else:
+ for response_modifier in getattr(request, 'ssi_patch_response', []):
+ response_modifier(response)
def process_response(self, request, response):
if hasattr(response, 'render') and callable(response.render):
else:
self._process_rendered_response(request, response)
- if DEBUG:
- from .middleware_debug import DebugUnSsiMiddleware
- response = DebugUnSsiMiddleware().process_response(
+ if conf.RENDER:
+ from .middleware_debug import SsiRenderMiddleware
+ response = SsiRenderMiddleware().process_response(
request, response)
return response
if (request.session.accessed and
(settings.USE_I18N or settings.USE_L10N)):
request.session.accessed = False
- request.ssi_vary.add('Cookie')
+ request.ssi_patch_response.append(ssi_vary_on_cookie)
"""
This module should only be used for debugging SSI statements.
-Using DebugUnSsiMiddleware in production defeats the purpose of using SSI
+Using SsiRenderMiddleware in production defeats the purpose of using SSI
in the first place, and is unsafe. You should use a proper webserver with SSI
support as a proxy (i.e. Nginx with ssi=on).
except ImportError:
from urlparse import urlparse
from django.core.urlresolvers import resolve
-from ssify import DEBUG_VERBOSE
+from .cache import get_caches
+
+from .conf import conf
SSI_SET = re.compile(r"<!--#set var='(?P<var>[^']+)' "
SSI_VAR = re.compile(r"\$\{(?P<var>.+)\}") # TODO: escaped?
-class DebugUnSsiMiddleware(object):
+class SsiRenderMiddleware(object):
"""
Emulates a webserver with SSI support.
This middleware should only be used for debugging purposes.
- SsiMiddleware will enable it automatically, if SSIFY_DEBUG setting
+ SsiMiddleware will enable it automatically, if SSIFY_RENDER setting
is set to True, so you don't normally need to include it in
MIDDLEWARE_CLASSES.
- If SSIFY_DEBUG_VERBOSE setting is True, it will also leave some
+ If SSIFY_RENDER_VERBOSE setting is True, it will also leave some
information in HTML comments.
"""
def ssi_include(match):
"""Replaces SSI include with contents rendered by relevant view."""
path = process_value(match.group('path'))
- func, args, kwargs = resolve(path)
- parsed = urlparse(path)
-
- # Reuse the original request, but reset some attributes.
- request.META['PATH_INFO'] = request.path_info = \
- request.path = parsed.path
- request.META['QUERY_STRING'] = parsed.query
- request.ssi_vars_needed = {}
-
- content = func(request, *args, **kwargs).content.decode('ascii')
- content = process_content(content)
- if DEBUG_VERBOSE:
+ content = None
+ for cache in get_caches():
+ content = cache.get(path)
+ if content is not None:
+ break
+ if content is None:
+ func, args, kwargs = resolve(path)
+ parsed = urlparse(path)
+
+ # Reuse the original request, but reset some attributes.
+ request.META['PATH_INFO'] = request.path_info = \
+ request.path = parsed.path
+ request.META['QUERY_STRING'] = parsed.query
+ request.ssi_vars_needed = {}
+
+ subresponse = func(request, *args, **kwargs)
+ # FIXME: we should deal directly with bytes here.
+ if subresponse.streaming:
+ content = b"".join(subresponse.streaming_content)
+ else:
+ content = subresponse.content
+ content = process_content(content.decode('utf-8'))
+ if conf.RENDER_VERBOSE:
return "".join((
match.group(0),
content,
def ssi_set(match):
"""Interprets SSI set statement."""
variables[match.group('var')] = match.group('value')
- if DEBUG_VERBOSE:
+ if conf.RENDER_VERBOSE:
return match.group(0)
else:
return ""
def ssi_echo(match):
"""Interprets SSI echo, outputting the value of the variable."""
content = variables[match.group('var')]
- if DEBUG_VERBOSE:
+ if conf.RENDER_VERBOSE:
return "".join((
match.group(0),
content,
if expr:
content = match.group('value')
else:
- content = match.group('else')
- if DEBUG_VERBOSE:
+ content = match.group('else') or ''
+ if conf.RENDER_VERBOSE:
return "".join((
match.group('header'),
content,
variables = {}
response.content = process_content(
- response.content.decode('ascii')).encode('ascii')
+ response.content.decode('utf-8')).encode('utf-8')
response['Content-Length'] = len(response.content)
def process_response(self, request, response):
"""Support for unrendered responses."""
+ if response.streaming:
+ return response
if hasattr(response, 'render') and callable(response.render):
response.add_post_render_callback(
lambda r: self._process_rendered_response(request, r)
+++ /dev/null
-# -*- coding: utf-8 -*-
-# This file is part of django-ssify, licensed under GNU Affero GPLv3 or later.
-# Copyright © Fundacja Nowoczesna Polska. See README.md for more information.
-#
-from __future__ import unicode_literals
-from django.core.cache import get_cache
-from ssify import INCLUDES_CACHES
-
-
-includes_caches = [get_cache(c) for c in INCLUDES_CACHES]
-
-
-def cache_include(path, content):
- for cache in includes_caches:
- cache.set(path, content)
from __future__ import absolute_import, unicode_literals
from django.conf import settings
from django.core.urlresolvers import NoReverseMatch, reverse, resolve
-from django.middleware.csrf import get_token, _sanitize_token
+from django.middleware.csrf import get_token, _sanitize_token, rotate_token
from django import template
from django.utils.translation import get_language
from ssify.decorators import ssi_variable
+from ssify.utils import ssi_vary_on_cookie
from ssify.variables import SsiVariable
-try:
- from django.middleware.csrf import rotate_token
-except ImportError:
- from django.middleware.csrf import _get_new_csrf_key
-
- # Missing in Django 1.4
- def rotate_token(request):
- request.META.update({
- "CSRF_COOKIE_USED": True,
- "CSRF_COOKIE": _get_new_csrf_key(),
-
- })
-
register = template.Library()
var = SsiVariable(*var)
request.ssi_vars_needed[var.name] = var
+ # Remember the decorators to use on the including view.
+ patch_response = getattr(view, 'ssi_patch_response', None)
+ if patch_response:
+ request.ssi_patch_response.extend(patch_response)
+
# Output the SSI include.
return "<!--#include file='%s'-->" % url
-@ssi_variable(register, vary=('Cookie',))
+@ssi_variable(register, patch_response=[ssi_vary_on_cookie])
def get_csrf_token(request):
"""
CsrfViewMiddleware.process_view is never called for cached
@register.inclusion_tag('ssify/csrf_token.html', takes_context=True)
-def csrf_token(context):
+def ssi_csrf_token(context):
return {'request': context['request']}
--- /dev/null
+from functools import partial
+from django.utils.cache import patch_cache_control, patch_vary_headers
+
+
+def ssi_cache_control(**kwargs):
+ return partial(patch_cache_control, **kwargs)
+
+
+def ssi_vary(newheaders):
+ return partial(patch_vary_headers, newheaders=newheaders)
+
+
+ssi_vary_on_cookie = ssi_vary(('Cookie',))
class SsiVariableNode(template.Node):
""" Node for the SsiVariable tags. """
- def __init__(self, tagpath, args, kwargs, vary=None, asvar=None):
+ def __init__(self, tagpath, args, kwargs, patch_response=None, asvar=None):
self.tagpath = tagpath
self.args = args
self.kwargs = kwargs
- self.vary = vary
+ self.patch_response = patch_response
self.asvar = asvar
def __repr__(self):
request = context['request']
request.ssi_vars_needed[var.name] = var
- if self.vary:
- request.ssi_vary.update(self.vary)
+ if self.patch_response:
+ request.ssi_patch_response.extend(self.patch_response)
if self.asvar:
context.dicts[0][self.asvar] = var
{% load ssify test_tags %}
-{# Django 1.4 compatibility: TemplateView sets `params` from the URL. #}
-{% if params.limit %}
- {% random_number limit=params.limit as a %}
-{% else %}
- {% random_number limit=limit as a %}
-{% endif %}
+{% random_number limit=limit as a %}
{% random_number a as b %}
{% random_number limit=b %}
\ No newline at end of file
-{% load ssify %}
+{% load ssi_csrf_token from ssify %}
-{% csrf_token %}
\ No newline at end of file
+{% ssi_csrf_token %}
\ No newline at end of file
]),
)
- @override_settings(SSIFY_DEBUG=True)
- def test_debug_render_include_args(self):
- pass
- """Renders the complete view using the DebugSsiMiddleware."""
+ @override_settings(SSIFY_RENDER=True)
+ def test_render_include_args(self):
+ """Renders the complete view using the SsiRenderMiddleware."""
response = self.client.get('/include_args')
if hasattr(response, 'render') and callable(response.render):
response.render()
b"file='/quote/${v3e7f638af74c9f420b6d2c5fe4dda51d}'-->"
)
- @override_settings(SSIFY_DEBUG=True)
+ @override_settings(SSIFY_RENDER=True)
def test_debug_render_random_quote(self):
- """Renders the complete view using the DebugSsiMiddleware."""
+ """Renders the complete view using the SsiRenderMiddleware."""
response = self.client.get('/')
if hasattr(response, 'render') and callable(response.render):
response.render()
#
[tox]
envlist=clear,
- d14-py26,
d15-py26, d15-py27, d15-py32, d15-py33,
d16-py26, d16-py27, d16-py32, d16-py33,
d17-py27, d17-py32, d17-py33, d17-py34,
[base]
-[testenv:d14-py26]
-basepython=python2.6
-deps=
- Django>=1.4,<1.5
- {[testenv]deps}
-
[testenv:d15-py26]
basepython=python2.6
deps=
[testenv:d17-py32]
basepython=python3.2
deps=
- https://www.djangoproject.com/download/1.7c3/tarball/
+ Django>=1.7,<1.8
{[testenv]deps}
[testenv:d17-py33]
basepython=python3.3
deps=
- https://www.djangoproject.com/download/1.7c3/tarball/
+ Django>=1.7,<1.8
{[testenv]deps}
[testenv:d17-py34]
basepython=python3.4
deps=
- https://www.djangoproject.com/download/1.7c3/tarball/
+ Django>=1.7,<1.8
{[testenv]deps}
[testenv:dd-py27]