From 196df9e8170262c31790260d4e280b9981b557d1 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Fri, 29 Aug 2014 17:06:53 +0200 Subject: [PATCH 1/1] A somewhat usable and tested version. --- .gitignore | 2 + runtests.py | 36 ++++- setup.py | 12 ++ ssify/__init__.py | 7 +- ssify/cache.py | 5 + ssify/decorators.py | 23 +++- ssify/exceptions.py | 94 ++++++++----- ssify/management/commands/ssify_nginx_conf.py | 5 +- ssify/middleware.py | 84 +++++++++--- ssify/middleware_debug.py | 21 ++- ssify/serializers.py | 14 +- ssify/store.py | 7 +- ssify/templatetags/ssify.py | 29 ++-- ssify/variables.py | 101 ++++++++------ tests/templates/tests/number_zero.html | 1 - tests/templates/tests/random_quote.html | 4 - tests/templates/tests_args/args.html | 10 ++ tests/templates/tests_args/include_args.html | 3 + .../templates/tests_basic/basic_include.html | 3 + .../{tests => tests_basic}/main.html | 0 tests/templates/tests_basic/number_zero.html | 2 + .../{tests => tests_basic}/quote.html | 0 tests/templates/tests_basic/random_quote.html | 4 + tests/templates/tests_csrf/csrf_token.html | 3 + .../templates/tests_locale/bad_language.html | 3 + .../include_language_with_lang.html | 3 + .../include_language_without_lang.html | 3 + tests/templatetags/test_tags.py | 5 + tests/tests.py | 61 --------- tests/tests/__init__.py | 16 +++ tests/tests/test_args.py | 62 +++++++++ tests/tests/test_basic.py | 84 ++++++++++++ tests/tests/test_csrf.py | 53 ++++++++ tests/tests/test_locale.py | 82 ++++++++++++ tests/tests_utils.py | 19 +++ tests/urls.py | 44 +++++- tests/views.py | 81 ++++++++++- tox.ini | 126 ++++++++++++++++++ 38 files changed, 915 insertions(+), 197 deletions(-) delete mode 100644 tests/templates/tests/number_zero.html delete mode 100644 tests/templates/tests/random_quote.html create mode 100644 tests/templates/tests_args/args.html create mode 100644 tests/templates/tests_args/include_args.html create mode 100644 tests/templates/tests_basic/basic_include.html rename tests/templates/{tests => tests_basic}/main.html (100%) create mode 100644 tests/templates/tests_basic/number_zero.html rename tests/templates/{tests => tests_basic}/quote.html (100%) create mode 100644 tests/templates/tests_basic/random_quote.html create mode 100644 tests/templates/tests_csrf/csrf_token.html create mode 100644 tests/templates/tests_locale/bad_language.html create mode 100644 tests/templates/tests_locale/include_language_with_lang.html create mode 100644 tests/templates/tests_locale/include_language_without_lang.html delete mode 100644 tests/tests.py create mode 100644 tests/tests/__init__.py create mode 100644 tests/tests/test_args.py create mode 100644 tests/tests/test_basic.py create mode 100644 tests/tests/test_csrf.py create mode 100644 tests/tests/test_locale.py create mode 100644 tests/tests_utils.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index ea2e8e1..03034ac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,13 @@ # Python garbage *.pyc .coverage +htmlcov pip-log.txt nosetests.xml build dist *.egg-info +.tox # Mac OS X garbage .DS_Store diff --git a/runtests.py b/runtests.py index 8d27ecf..3d066dc 100644 --- a/runtests.py +++ b/runtests.py @@ -1,10 +1,18 @@ #!/usr/bin/env python +# -*- 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. +# +""" +Creates a simple Django configuration and runs tests for django-ssify. +""" +from __future__ import unicode_literals import sys import os from os.path import dirname, abspath from optparse import OptionParser -from django.conf import settings, global_settings +from django.conf import settings # For convenience configure settings if they are not pre-configured or if we # haven't been provided settings to use by environment variable. @@ -30,12 +38,16 @@ if not settings.configured and not os.environ.get('DJANGO_SETTINGS_MODULE'): 'ssify', 'tests', ], + LANGUAGE_CODE='pl', MEDIA_URL='/media/', MIDDLEWARE_CLASSES=[ + 'django.middleware.csrf.CsrfViewMiddleware', 'ssify.middleware.SsiMiddleware', 'django.middleware.cache.UpdateCacheMiddleware', 'ssify.middleware.PrepareForCacheMiddleware', 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'ssify.middleware.LocaleMiddleware', 'django.middleware.cache.FetchFromCacheMiddleware', ], STATIC_URL='/static/', @@ -50,19 +62,29 @@ if not settings.configured and not os.environ.get('DJANGO_SETTINGS_MODULE'): ), ) -from django.test.simple import DjangoTestSuiteRunner +try: + from django.test.runner import DiscoverRunner +except ImportError: + # Django < 1.6 + from django.test.simple import DjangoTestSuiteRunner as DiscoverRunner def runtests(*test_args, **kwargs): - if 'south' in settings.INSTALLED_APPS: - from south.management.commands import patch_for_test_db_setup - patch_for_test_db_setup() - + """Actual test suite entry point.""" if not test_args: test_args = ['tests'] parent = dirname(abspath(__file__)) sys.path.insert(0, parent) - test_runner = DjangoTestSuiteRunner( + + # For Django 1.7+ + try: + from django import setup + except ImportError: + pass + else: + setup() + + test_runner = DiscoverRunner( verbosity=kwargs.get('verbosity', 1), interactive=kwargs.get('interactive', False), failfast=kwargs.get('failfast')) diff --git a/setup.py b/setup.py index d549279..b9e6332 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,7 @@ #!/usr/bin/env python # -*- 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 setuptools import setup, find_packages @@ -13,6 +15,9 @@ setup( license='LICENSE', description='Two-phased rendering using SSI.', long_description=open('README.md').read(), + install_requires=[ + 'Django>=1.4', + ], test_suite="runtests.runtests", classifiers=[ "Development Status :: 3 - Alpha", @@ -22,6 +27,13 @@ setup( "Intended Audience :: System Administrators", "License :: OSI Approved :: GNU Affero General Public License v3", "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.2", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Code Generators", ] diff --git a/ssify/__init__.py b/ssify/__init__.py index 591c70a..51322bc 100644 --- a/ssify/__init__.py +++ b/ssify/__init__.py @@ -1,3 +1,7 @@ +# -*- 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. +# """ Implements two-phase rendering using SSI statements. @@ -8,6 +12,7 @@ Define views to be cached and included as SSI include with `ssi_included` decorator. """ +from __future__ import unicode_literals __version__ = '1.0' __date__ = '2014-08-26' @@ -18,7 +23,7 @@ from django.utils.functional import lazy SETTING = lazy( lambda name, default: getattr(settings, name, default), - bool, int, list, tuple, unicode) + bool, int, list, tuple, str) INCLUDES_CACHES = SETTING('SSIFY_INCLUDES_CACHES', ('ssify',)) DEBUG = SETTING('SSIFY_DEBUG', False) diff --git a/ssify/cache.py b/ssify/cache.py index 7f42145..2fa846c 100644 --- a/ssify/cache.py +++ b/ssify/cache.py @@ -1,3 +1,8 @@ +# -*- 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 diff --git a/ssify/decorators.py b/ssify/decorators.py index 9b67c25..80dc2f5 100644 --- a/ssify/decorators.py +++ b/ssify/decorators.py @@ -1,3 +1,11 @@ +# -*- 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. +# +""" +Defines decorators for use in ssify-enabled projects. +""" +from __future__ import unicode_literals import functools from inspect import getargspec import warnings @@ -31,10 +39,14 @@ def ssi_included(view=None, use_lang=True, get_ssi_vars=None): raise exceptions.NoLangFieldError(request) current_lang = get_language() activate(lang) + request.LANGUAGE_CODE = lang response = view(request, *args, **kwargs) if use_lang: activate(current_lang) if response.status_code == 200: + # We don't want this view to be cached in + # UpdateCacheMiddleware. We'll just cache the contents + # ourselves, and point the webserver to use this cache. request._cache_update_cache = False def _check_included_vars(response): @@ -42,7 +54,7 @@ def ssi_included(view=None, use_lang=True, get_ssi_vars=None): if get_ssi_vars: # Remove the ssi vars that should be provided # by the including view. - pass_vars = set(get_ssi_vars(*args, **kwargs)) + pass_vars = get_ssi_vars(*args, **kwargs) for var in pass_vars: if not isinstance(var, SsiVariable): @@ -77,8 +89,17 @@ def ssi_included(view=None, use_lang=True, get_ssi_vars=None): def ssi_variable(register, vary=None, name=None): + """ + Creates a template tag representing an SSI variable from a function. + + The function must take 'request' as its first argument. + It may take other arguments, which should be provided when using + the template tag. + + """ # Cache control? def dec(func): + # Find own path. function_name = (name or getattr(func, '_decorated_function', func).__name__) diff --git a/ssify/exceptions.py b/ssify/exceptions.py index 8acc55c..d9e5319 100644 --- a/ssify/exceptions.py +++ b/ssify/exceptions.py @@ -1,68 +1,98 @@ -class SsifyError(BaseException): +# -*- 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. +# +""" +Exception classes used in django-ssify. +""" +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible + + +class RequestMixin(object): + """Lets us print request and view data in the exceptions messages.""" + + def __init__(self, request, *args): + self.request = request + super(RequestMixin, self).__init__(*args) + + def view_path(self): + """Returns full Python path to the view used in the request.""" + try: + view = self.request.resolver_match.func + return "%s.%s" % (view.__module__, view.__name__) + except AttributeError: + return "" + + +class SsifyError(RequestMixin, BaseException): + """Base class for all the errors.""" pass -class SsifyWarning(Warning): +class SsifyWarning(RequestMixin, Warning): + """Base class for all the warnings.""" pass +@python_2_unicode_compatible class UndeclaredSsiVarsError(SsifyError): + """An ssi_included view used a SSI variable, but didn't declare it.""" + def __init__(self, request, ssi_vars): super(UndeclaredSsiVarsError, self).__init__(request, ssi_vars) def __str__(self): - request = self.args[0] - view = request.resolver_match.func - return "The view '%s.%s' at '%s' is marked as `ssi_included`, "\ + return "The view '%s' at '%s' is marked as `ssi_included`, "\ "but it uses ssi variables not declared in `get_ssi_vars` "\ "argument: %s. " % ( - view.__module__, view.__name__, request.path, - repr(self.args[1])) + self.view_path(), self.request.get_full_path(), + repr(self.args[0])) +@python_2_unicode_compatible class UnusedSsiVarsWarning(SsifyWarning): + """An ssi_included declared a SSI variable, but didn't use it.""" + def __init__(self, request, ssi_vars): super(UnusedSsiVarsWarning, self).__init__(request, ssi_vars) def __str__(self): - request = self.args[0] - view = request.resolver_match.func - return "The `ssi_included` view '%s.%s' at '%s' declares "\ + return "The `ssi_included` view '%s' at '%s' declares "\ "using SSI variables %s but it looks like they're not "\ "really used. " % ( - view.__module__, view.__name__, request.path, self.args[1]) - - -class UndeclaredSsiRefError(SsifyError): - def __init__(self, request, var, ref_name): - super(UndeclaredSsiRefError, self).__init__(request, var, ref_name) - - def __str__(self): - request = self.args[0] - view = request.resolver_match.func - return "Error while rendering ssi_included view '%s.%s' at '%s': "\ - "SSI variable %s references variable %s, which doesn't match "\ - "any variable declared in `get_ssi_vars`. " % ( - view.__module__, view.__name__, request.path, - repr(self.args[1]), self.args[2]) + self.view_path(), self.request.get_full_path(), + self.args[0]) +@python_2_unicode_compatible class NoLangFieldError(SsifyError): + """ssi_included views should have a `lang` field in their URL patterns.""" + def __init__(self, request): super(NoLangFieldError, self).__init__(request) def __str__(self): - request = self.args[0] - view = request.resolver_match.func - return "The view '%s.%s' at '%s' is marked as `ssi_included` "\ + return "The view '%s' at '%s' is marked as `ssi_included` "\ "with use_lang=True, but its URL match doesn't provide "\ "a 'lang' keyword argument for language. " % ( - view.__module__, view.__name__, request.path) + self.view_path(), self.request.get_full_path()) +@python_2_unicode_compatible class SsiVarsDependencyCycleError(SsifyError): - def __init__(self, ssi_vars): - super(SsiVarsDependencyCycleError, self).__init__(ssi_vars) + """Looks like there's a dependency cycle in the SSI variables. + + Yet to find an example of a configuration that triggers that. + """ + + def __init__(self, request, ssi_vars, resolved): + super(SsiVarsDependencyCycleError, self).__init__( + request, ssi_vars, resolved) def __str__(self): - return "Dependency cycle in SSI variables: %s." % self.args[0] + return "The view '%s' at '%s' has dependency cycle. "\ + "Unresolved SSI variables:\n%s\n\n"\ + "Resolved SSI variables:\n%s." % ( + self.view_path(), self.request.get_full_path(), + self.args[0], self.args[1]) diff --git a/ssify/management/commands/ssify_nginx_conf.py b/ssify/management/commands/ssify_nginx_conf.py index 48f53dc..d177811 100644 --- a/ssify/management/commands/ssify_nginx_conf.py +++ b/ssify/management/commands/ssify_nginx_conf.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -# This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. -# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# 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 optparse import make_option from django.core.management.base import BaseCommand diff --git a/ssify/middleware.py b/ssify/middleware.py index 4351083..a1f8444 100644 --- a/ssify/middleware.py +++ b/ssify/middleware.py @@ -1,3 +1,41 @@ +# -*- 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. +# +""" +Middleware classes provide by django-ssify. + +The main middleware you should use is SsiMiddleware. It's responsible +for providing the SSI variables needed for the SSI part of rendering. + +If you're using django's UpdateCacheMiddleware, add +PrepareForCacheMiddleware *after it* also. It will add all the data +needed by SsiMiddleware to the response. + +If you're using SessionMiddleware with LocaleMiddleware and your +USE_I18N or USE_L10N is True, you should also use the provided +LocaleMiddleware instead of the stock one. + +And, last but not least, if using CsrfViewMiddleware, move it to the +top of MIDDLEWARE_CLASSES, even before SsiMiddleware, and use +`csrf_token` from `ssify` tags library in your templates, this way +your CSRF tokens will be set correctly. + +So, you should end up with something like this: + + MIDDLEWARE_CLASSES = [ + 'django.middleware.csrf.CsrfViewMiddleware', + 'ssify.middleware.SsiMiddleware', + 'django.middleware.cache.UpdateCacheMiddleware', + 'ssify.middleware.PrepareForCacheMiddleware', + ... + 'ssify.middleware.LocaleMiddleware', + ... + ] + + +""" +from __future__ import unicode_literals from django.conf import settings from django.utils.cache import patch_vary_headers from django.middleware import locale @@ -7,17 +45,39 @@ from . import DEBUG class PrepareForCacheMiddleware(object): + """ + Patches the response object with all the data SsiMiddleware needs. + + This should go after UpdateCacheMiddleware in MIDDLEWARE_CLASSES. + """ @staticmethod def process_response(request, response): + """Adds a 'X-Ssi-Vars-Needed' header to the response.""" if getattr(request, 'ssi_vars_needed', None): - vars_needed = {k: v.definition - for (k, v) in request.ssi_vars_needed.items()} + 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) return response class SsiMiddleware(object): + """ + The main django-ssify middleware. + + It prepends the response content with SSI set statements, + providing values for any SSI variables used in the templates. + + 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 + 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() @@ -31,8 +91,8 @@ class SsiMiddleware(object): vars_needed = request.ssi_vars_needed else: vars_needed = json_decode(response.get('X-Ssi-Vars-Needed', '{}')) - vars_needed = {k: SsiVariable(*v) - for (k, v) in vars_needed.items()} + for k, v in vars_needed.items(): + vars_needed[k] = SsiVariable(*v) if vars_needed: response.content = provide_vars(request, vars_needed) + \ @@ -42,22 +102,6 @@ class SsiMiddleware(object): patch_vary_headers(response, sorted(request.ssi_vary)) # TODO: cache control? - # With a cached response, CsrfViewMiddleware.process_response - # was never called, so if we used the csrf token, we must do - # its job of setting the csrf token cookie on our own. - if (not getattr(request, 'csrf_processing_done', False) - and request.META.get("CSRF_COOKIE_USED", False)): - response.set_cookie(settings.CSRF_COOKIE_NAME, - request.META["CSRF_COOKIE"], - max_age=getattr(settings, 'CSRF_COOKIE_AGE', - 60 * 60 * 24 * 7 * 52), - domain=settings.CSRF_COOKIE_DOMAIN, - path=settings.CSRF_COOKIE_PATH, - secure=settings.CSRF_COOKIE_SECURE, - httponly=settings.CSRF_COOKIE_HTTPONLY - ) - request.csrf_processing_done = True - def process_response(self, request, response): if hasattr(response, 'render') and callable(response.render): response.add_post_render_callback( diff --git a/ssify/middleware_debug.py b/ssify/middleware_debug.py index 2bd4fa0..5eb1cbd 100644 --- a/ssify/middleware_debug.py +++ b/ssify/middleware_debug.py @@ -1,3 +1,7 @@ +# -*- 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. +# """ This module should only be used for debugging SSI statements. @@ -6,8 +10,12 @@ 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). """ +from __future__ import unicode_literals import re -import urlparse +try: + from urllib.parse import urlparse +except ImportError: + from urlparse import urlparse from django.core.urlresolvers import resolve from ssify import DEBUG_VERBOSE @@ -43,11 +51,15 @@ class DebugUnSsiMiddleware(object): """Replaces SSI include with contents rendered by relevant view.""" path = process_value(match.group('path')) func, args, kwargs = resolve(path) - parsed = urlparse.urlparse(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 - content = func(request, *args, **kwargs).content + request.ssi_vars_needed = {} + + content = func(request, *args, **kwargs).content.decode('ascii') content = process_content(content) if DEBUG_VERBOSE: return "".join(( @@ -111,7 +123,8 @@ class DebugUnSsiMiddleware(object): return content variables = {} - response.content = process_content(response.content) + response.content = process_content( + response.content.decode('ascii')).encode('ascii') response['Content-Length'] = len(response.content) def process_response(self, request, response): diff --git a/ssify/serializers.py b/ssify/serializers.py index a96356c..8a44c7b 100644 --- a/ssify/serializers.py +++ b/ssify/serializers.py @@ -1,19 +1,25 @@ +# -*- 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 json from .variables import SsiVariable, SsiExpect def _json_default(o): if isinstance(o, SsiVariable): - return {'__var__': o.name} + return {'__var__': o.definition} if isinstance(o, SsiExpect): return {'__expect__': o.name} raise TypeError(o, 'not JSON serializable') def _json_obj_hook(obj): - if obj.keys() == ['__var__']: - return SsiVariable(name=obj['__var__']) - if obj.keys() == ['__expect__']: + keys = list(obj.keys()) + if keys == ['__var__']: + return SsiVariable(*obj['__var__']) + if keys == ['__expect__']: return SsiExpect(obj['__expect__']) return obj diff --git a/ssify/store.py b/ssify/store.py index 499a991..ec40bb0 100644 --- a/ssify/store.py +++ b/ssify/store.py @@ -1,4 +1,9 @@ -from django.utils.cache import get_cache +# -*- 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 diff --git a/ssify/templatetags/ssify.py b/ssify/templatetags/ssify.py index f538fe6..06be39e 100644 --- a/ssify/templatetags/ssify.py +++ b/ssify/templatetags/ssify.py @@ -1,16 +1,29 @@ # -*- coding: utf-8 -*- -# This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. -# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# 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 absolute_import +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, rotate_token, _sanitize_token +from django.middleware.csrf import get_token, _sanitize_token from django import template from django.utils.translation import get_language from ssify.decorators import ssi_variable 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() @@ -66,10 +79,10 @@ def ssi_include(context, name_, **kwargs): @ssi_variable(register, vary=('Cookie',)) def get_csrf_token(request): """ - As CsrfViewMiddleware.process_view is never for a cached response, - and we still need to provide a request-specific CSRF token as - request-info ssi variable, we must make sure here that the - CSRF token is in request.META['CSRF_COOKIE']. + CsrfViewMiddleware.process_view is never called for cached + responses, and we still need to provide a CSRF token as an + ssi variable, we must make sure here that the CSRF token + is in request.META['CSRF_COOKIE']. """ token = get_token(request) diff --git a/ssify/variables.py b/ssify/variables.py index 9ccc3f3..e969450 100644 --- a/ssify/variables.py +++ b/ssify/variables.py @@ -1,3 +1,7 @@ +# -*- 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. +# """ Utilities for defining SSI variables. @@ -5,14 +9,16 @@ SSI variables are a way of providing values that need to be computed at request time to the prerendered templates. """ +from __future__ import unicode_literals from hashlib import md5 from django import template -from django.utils.encoding import force_unicode +from django.utils.encoding import force_text, python_2_unicode_compatible from django.utils.functional import Promise from django.utils.safestring import mark_safe -from .exceptions import SsiVarsDependencyCycleError, UndeclaredSsiRefError +from .exceptions import SsiVarsDependencyCycleError +@python_2_unicode_compatible class SsiVariable(object): """ Represents a variable computed by a template tag with given arguments. @@ -27,8 +33,6 @@ class SsiVariable(object): so the user never has to deal with it directly. """ - ret_types = 'bool', 'int', 'unicode' - def __init__(self, tagpath=None, args=None, kwargs=None, name=None): self.tagpath = tagpath self.args = list(args or []) @@ -39,7 +43,7 @@ class SsiVariable(object): def name(self): """Variable name is a hash of its definition.""" if self._name is None: - self._name = 'v' + md5(json_encode(self.definition)).hexdigest() + self._name = 'v' + md5(json_encode(self.definition).encode('ascii')).hexdigest() return self._name def rehash(self): @@ -71,7 +75,7 @@ class SsiVariable(object): return template.get_library(taglib).tags[tagname].get_value( request, *self.args, **self.kwargs) - def __unicode__(self): + def __str__(self): return mark_safe("" % self.name) def as_var(self): @@ -91,6 +95,8 @@ class SsiExpect(object): """This class says: I want the real value of this variable here.""" def __init__(self, name): self.name = name + def __repr__(self): + return "SsiExpect(%s)" % (self.name,) def ssi_expect(var, type_): @@ -153,7 +159,7 @@ def ssi_set_statement(var, value): value = '' return "" % ( var, - force_unicode(value).replace(u'\\', u'\\\\').replace(u"'", u"\\'")) + force_text(value).replace('\\', '\\\\').replace("'", "\\'")) def provide_vars(request, ssi_vars): @@ -162,50 +168,61 @@ def provide_vars(request, ssi_vars): The main purpose of this function is to by called by SsifyMiddleware. """ + def resolve_expects(var): + if not hasattr(var, 'hash_dirty'): + var.hash_dirty = False + + for i, arg in enumerate(var.args): + if isinstance(arg, SsiExpect): + var.args[i] = resolved[arg.name] + var.hash_dirty = True + for k, arg in var.kwargs.items(): + if isinstance(arg, SsiExpect): + var.kwargs[k] = resolved[arg.name] + var.hash_dirty = True + + for arg in var.args + list(var.kwargs.values()): + if isinstance(arg, SsiVariable): + var.hash_dirty = resolve_expects(arg) or var.hash_dirty + + hash_dirty = var.hash_dirty + if var.hash_dirty: + # Rehash after calculating the SsiExpects with real + # values, because that's what the included views expect. + var.rehash() + var.hash_dirty = False + + return hash_dirty + + def resolve_args(var): + kwargs = {} + for k, arg in var.kwargs.items(): + kwargs[k] = resolved[arg.name] if isinstance(arg, SsiVariable) else arg + new_var = SsiVariable(var.tagpath, + [resolved[arg.name] if isinstance(arg, SsiVariable) else arg for arg in var.args], + kwargs) + return new_var + resolved = {} - queue = ssi_vars.items() + queue = list(ssi_vars.values()) + unresolved_streak = 0 while queue: - var_name, var = queue.pop(0) - hash_dirty = False - new_name = var_name - + var = queue.pop(0) try: - for i, arg in enumerate(var.args): - if isinstance(arg, SsiExpect): - var.args[i] = resolved[arg.name] - hash_dirty = True - for k, arg in var.kwargs.items(): - if isinstance(arg, SsiExpect): - var.args[k] = resolved[arg.name] - hash_dirty = True - - if hash_dirty: - # Rehash after calculating the SsiExpects with real - # values, because that's what the included views expect. - new_name = var.rehash() - - for i, arg in enumerate(var.args): - if isinstance(arg, SsiVariable): - var.args[i] = resolved[arg.name] - for k, arg in var.kwargs.items(): - if isinstance(arg, SsiVariable): - var.args[k] = resolved[arg.name] - - except KeyError: - queue.append((var_name, var)) + resolve_expects(var) + rv = resolve_args(var) + except KeyError as e: + queue.append(var) unresolved_streak += 1 - if unresolved_streak == len(queue): - if arg.name in ssi_vars: - raise SsiVarsDependencyCycleError(queue) - else: - raise UndeclaredSsiRefError(request, var, arg.name) + if unresolved_streak > len(queue): + raise SsiVarsDependencyCycleError(request, queue, resolved) continue - resolved[new_name] = var.get_value(request) + resolved[var.name] = rv.get_value(request) unresolved_streak = 0 - output = u"".join(ssi_set_statement(var, value) + output = "".join(ssi_set_statement(var, value) for (var, value) in resolved.items() ).encode('utf-8') return output diff --git a/tests/templates/tests/number_zero.html b/tests/templates/tests/number_zero.html deleted file mode 100644 index a01b50f..0000000 --- a/tests/templates/tests/number_zero.html +++ /dev/null @@ -1 +0,0 @@ -{% load test_tags %}{% random_number 1 %} \ No newline at end of file diff --git a/tests/templates/tests/random_quote.html b/tests/templates/tests/random_quote.html deleted file mode 100644 index 66611aa..0000000 --- a/tests/templates/tests/random_quote.html +++ /dev/null @@ -1,4 +0,0 @@ -{% load ssify test_tags %} -{% number_of_quotes as qlen %} -{% random_number qlen as number %} -{% ssi_include 'quote' number=number %} \ No newline at end of file diff --git a/tests/templates/tests_args/args.html b/tests/templates/tests_args/args.html new file mode 100644 index 0000000..253fcde --- /dev/null +++ b/tests/templates/tests_args/args.html @@ -0,0 +1,10 @@ +{% 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 a as b %} +{% random_number limit=b %} \ No newline at end of file diff --git a/tests/templates/tests_args/include_args.html b/tests/templates/tests_args/include_args.html new file mode 100644 index 0000000..f045266 --- /dev/null +++ b/tests/templates/tests_args/include_args.html @@ -0,0 +1,3 @@ +{% load ssify test_tags %} +{% random_number 4 as a %} +{% ssi_include 'args' limit=a %} \ No newline at end of file diff --git a/tests/templates/tests_basic/basic_include.html b/tests/templates/tests_basic/basic_include.html new file mode 100644 index 0000000..adb399b --- /dev/null +++ b/tests/templates/tests_basic/basic_include.html @@ -0,0 +1,3 @@ +{% load ssify %} + +{% ssi_include 'language_with_lang' lang='pl' %} \ No newline at end of file diff --git a/tests/templates/tests/main.html b/tests/templates/tests_basic/main.html similarity index 100% rename from tests/templates/tests/main.html rename to tests/templates/tests_basic/main.html diff --git a/tests/templates/tests_basic/number_zero.html b/tests/templates/tests_basic/number_zero.html new file mode 100644 index 0000000..996b721 --- /dev/null +++ b/tests/templates/tests_basic/number_zero.html @@ -0,0 +1,2 @@ +{% load ssify test_tags %} +{% random_number 1 %} diff --git a/tests/templates/tests/quote.html b/tests/templates/tests_basic/quote.html similarity index 100% rename from tests/templates/tests/quote.html rename to tests/templates/tests_basic/quote.html diff --git a/tests/templates/tests_basic/random_quote.html b/tests/templates/tests_basic/random_quote.html new file mode 100644 index 0000000..9fdac51 --- /dev/null +++ b/tests/templates/tests_basic/random_quote.html @@ -0,0 +1,4 @@ +{% load ssify test_tags %} +{% number_of_quotes as qlen %} +{% random_number limit=qlen as number %} +{% ssi_include 'quote' number=number %} \ No newline at end of file diff --git a/tests/templates/tests_csrf/csrf_token.html b/tests/templates/tests_csrf/csrf_token.html new file mode 100644 index 0000000..9b0845f --- /dev/null +++ b/tests/templates/tests_csrf/csrf_token.html @@ -0,0 +1,3 @@ +{% load ssify %} + +{% csrf_token %} \ No newline at end of file diff --git a/tests/templates/tests_locale/bad_language.html b/tests/templates/tests_locale/bad_language.html new file mode 100644 index 0000000..5428496 --- /dev/null +++ b/tests/templates/tests_locale/bad_language.html @@ -0,0 +1,3 @@ +{% load ssify %} + +{% ssi_include 'bad_language_with_lang' %} \ No newline at end of file diff --git a/tests/templates/tests_locale/include_language_with_lang.html b/tests/templates/tests_locale/include_language_with_lang.html new file mode 100644 index 0000000..7ddf3de --- /dev/null +++ b/tests/templates/tests_locale/include_language_with_lang.html @@ -0,0 +1,3 @@ +{% load ssify %} + +{% ssi_include 'language_with_lang' %} \ No newline at end of file diff --git a/tests/templates/tests_locale/include_language_without_lang.html b/tests/templates/tests_locale/include_language_without_lang.html new file mode 100644 index 0000000..320be2f --- /dev/null +++ b/tests/templates/tests_locale/include_language_without_lang.html @@ -0,0 +1,3 @@ +{% load ssify %} + +{% ssi_include 'language_without_lang' %} \ No newline at end of file diff --git a/tests/templatetags/test_tags.py b/tests/templatetags/test_tags.py index 8c9dfea..d0a43c1 100644 --- a/tests/templatetags/test_tags.py +++ b/tests/templatetags/test_tags.py @@ -1,3 +1,8 @@ +# -*- 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 import template from ssify import ssi_variable from tests.views import QUOTES diff --git a/tests/tests.py b/tests/tests.py deleted file mode 100644 index 82751c7..0000000 --- a/tests/tests.py +++ /dev/null @@ -1,61 +0,0 @@ -from django.test import Client, TestCase -from django.test.utils import override_settings - - -class SsifyTestCase(TestCase): - def setUp(self): - self.client = Client() - - def test_zero(self): - self.assertEqual( - self.client.get('/number_zero').content, - "" - "" - ) - - def test_single_quote(self): - self.assertEqual( - self.client.get('/quote/3').content.strip(), - """Explicit is better than implicit. -Line 3 of -Odd number of characters. -Even number of characters. -""" - ) - - def test_random_quote(self): - self.assertEqual( - self.client.get('/').content.strip(), - "" - "" - "" - "\n\n" - ) - - # Do it again, this time from cache. - self.assertEqual( - self.client.get('/').content.strip(), - "" - "" - "" - "\n\n" - ) - self.assertEqual( - self.client.get('/random_quote').content.strip(), - "" - ) - - @override_settings(SSIFY_DEBUG=True) - def test_debug_render_random_quote(self): - """Renders the complete view using the DebugSsiMiddleware.""" - response = self.client.get('/') - if hasattr(response, 'render') and callable(response.render): - response.render() - self.assertEqual( - response.content.strip(), - """Simple is better than complex. -Line 4 of 22 -Even number of characters.""" - ) diff --git a/tests/tests/__init__.py b/tests/tests/__init__.py new file mode 100644 index 0000000..0fb0ebb --- /dev/null +++ b/tests/tests/__init__.py @@ -0,0 +1,16 @@ +# -*- 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. +# +""" +This file works only for django.test.simple.DjangoTestSuiteRunner +in Django<1.6. The newer django.test.runner.DiscoverRunner finds +test_* modules by itself. + +""" +from __future__ import unicode_literals + +from .test_args import * +from .test_basic import * +from .test_csrf import * +from .test_locale import * \ No newline at end of file diff --git a/tests/tests/test_args.py b/tests/tests/test_args.py new file mode 100644 index 0000000..a45f47c --- /dev/null +++ b/tests/tests/test_args.py @@ -0,0 +1,62 @@ +# -*- 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 re +from django.test import TestCase +from django.test.utils import override_settings +from tests.tests_utils import split_ssi + + +class ArgsTestCase(TestCase): + def test_args(self): + self.assertEqual( + sorted(split_ssi(self.client.get('/args').content)), + sorted([b"", + b"", + b"", + b"", + ]) + ) + + def test_args_included(self): + self.assertEqual( + self.client.get('/args/3').content.strip(), + b"" + ) + + def test_include_args(self): + self.assertEqual( + sorted(split_ssi(self.client.get('/include_args').content)), + sorted([b"", + b"", + b"", + b"", + b"", + ]), + ) + + # Test a second time, this time from cache. + self.assertEqual( + sorted(split_ssi(self.client.get('/include_args').content)), + sorted([b"", + b"", + b"", + b"", + b"", + ]), + ) + + @override_settings(SSIFY_DEBUG=True) + def test_debug_render_include_args(self): + pass + """Renders the complete view using the DebugSsiMiddleware.""" + response = self.client.get('/include_args') + if hasattr(response, 'render') and callable(response.render): + response.render() + self.assertEqual( + response.content.strip(), + b"""0""" + ) diff --git a/tests/tests/test_basic.py b/tests/tests/test_basic.py new file mode 100644 index 0000000..0c32349 --- /dev/null +++ b/tests/tests/test_basic.py @@ -0,0 +1,84 @@ +# -*- 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 re +import warnings +from django.test import TestCase +from django.test.utils import override_settings +from ssify.exceptions import UndeclaredSsiVarsError, UnusedSsiVarsWarning +from tests.tests_utils import split_ssi + + +class BasicTestCase(TestCase): + def test_zero(self): + self.assertEqual( + self.client.get('/number_zero').content.strip(), + b"\n" + b"" + ) + + def test_basic_include(self): + self.assertEqual( + self.client.get('/basic_include').content.strip(), + b"" + ) + + def test_single_quote(self): + self.assertEqual( + self.client.get('/quote/3').content.strip(), + b"""Explicit is better than implicit. +Line 3 of +Odd number of characters. +Even number of characters. +""" + ) + + def test_undeclared_vars(self): + self.assertRaises(UndeclaredSsiVarsError, + self.client.get, + '/quote_undeclared/3') + + def test_overdeclared_vars(self): + with warnings.catch_warnings(record=True) as w: + response = self.client.get('/quote_overdeclared/3') + self.assertIs(w[-1].category, UnusedSsiVarsWarning) + + def test_random_quote(self): + self.assertEqual( + sorted(split_ssi(self.client.get('/').content)), + sorted([b"", + b"", + b"", + b""]) + ) + + # Do it again, this time from cache. + self.assertEqual( + sorted(split_ssi(self.client.get('/').content)), + sorted([b"", + b"", + b"", + b""]) + ) + self.assertEqual( + self.client.get('/random_quote').content.strip(), + b"" + ) + + @override_settings(SSIFY_DEBUG=True) + def test_debug_render_random_quote(self): + """Renders the complete view using the DebugSsiMiddleware.""" + response = self.client.get('/') + if hasattr(response, 'render') and callable(response.render): + response.render() + self.assertEqual( + response.content.strip(), + b"""Simple is better than complex. +Line 4 of 22 +Even number of characters.""" + ) diff --git a/tests/tests/test_csrf.py b/tests/tests/test_csrf.py new file mode 100644 index 0000000..62173ce --- /dev/null +++ b/tests/tests/test_csrf.py @@ -0,0 +1,53 @@ +# -*- 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.conf import settings +from django.test import Client, TestCase + + +class CsrfTestCase(TestCase): + def setUp(self): + self.client = Client(enforce_csrf_checks=True) + + def assertCsrfTokenOk(self, response): + token = response.cookies[settings.CSRF_COOKIE_NAME].value + self.assertTrue(token) + self.assertEqual( + response.content.strip(), + ("\n\n" + "' />" % token).encode('ascii') + ) + return token + + def test_csrf_token(self): + response = self.client.get('/csrf') + token = self.assertCsrfTokenOk(response) + + # And now for a second request, with the token cookie. + response = self.client.get('/csrf') + new_token = self.assertCsrfTokenOk(response) + self.assertEqual(new_token, token) + + # Make a bad request to see that CSRF protection works. + response = self.client.post('/csrf_check', { + 'test': 'some data', + }) + self.assertEqual(response.status_code, 403) + + # Make a good request. + response = self.client.post('/csrf_check', { + 'test': 'some data', + 'csrfmiddlewaretoken': token, + }) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'some data') + + def test_new_csrf_token_in_cached_response(self): + Client().get('/csrf') + response = Client().get('/csrf') + token = self.assertCsrfTokenOk(response) diff --git a/tests/tests/test_locale.py b/tests/tests/test_locale.py new file mode 100644 index 0000000..7cfacc0 --- /dev/null +++ b/tests/tests/test_locale.py @@ -0,0 +1,82 @@ +# -*- 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.conf import settings +from django.test import Client, TestCase +from django.test.utils import override_settings +from django.utils import translation +from ssify import exceptions +from ssify.middleware import SsiMiddleware + + +class LocaleTestCase(TestCase): + def setUp(self): + self.ssi_process_response = SsiMiddleware.process_response + SsiMiddleware.process_response = lambda self, req, res: res + + def tearDown(self): + SsiMiddleware.process_response = self.ssi_process_response + + def test_locale_middleware(self): + index = settings.MIDDLEWARE_CLASSES.index( + 'ssify.middleware.LocaleMiddleware') + stock_middleware = settings.MIDDLEWARE_CLASSES[:index] + \ + ['django.middleware.locale.LocaleMiddleware'] + \ + settings.MIDDLEWARE_CLASSES[index + 1:] + + for use_stock_middleware in False, True: + for with_lang in False, True: + for with_i18n in False, True: + override = {'USE_I18N': with_i18n} + + if use_stock_middleware: + override['MIDDLEWARE_CLASSES'] = stock_middleware + + if use_stock_middleware and with_i18n: + expected_vary = 'Accept-Language, Cookie' + else: + expected_vary = 'Accept-Language' + + if with_lang: + url = '/include_language_with_lang' + else: + url = '/include_language_without_lang' + + with self.settings(**override): + # Changed USE_I18N, must reload translation mechanism. + translation._trans.__dict__.clear() + response = Client().get(url) + self.assertEqual( + response['Vary'], + expected_vary, + 'Wrong Vary with: use_stock_middleware=%s, ' + 'with_lang=%s, with_i18n=%s; ' + 'expected: %s, got: %s' % ( + use_stock_middleware, with_lang, with_i18n, + expected_vary, response['Vary']) + ) + + def test_lang_arg(self): + self.assertEqual( + self.client.get('/language/uk').content.strip(), b'uk') + self.assertEqual( + self.client.get('/language').content.strip(), b'pl') + + def test_lang_arg_missing(self): + self.assertRaises( + exceptions.NoLangFieldError, + lambda: self.client.get('/bad_language')) + + def test_locale_middleware_without_session(self): + index = settings.MIDDLEWARE_CLASSES.index( + 'django.contrib.sessions.middleware.SessionMiddleware') + middleware = settings.MIDDLEWARE_CLASSES[:index] + \ + settings.MIDDLEWARE_CLASSES[index + 1:] + with self.settings(MIDDLEWARE_CLASSES=middleware): + self.assertEqual( + self.client.get('/include_language_with_lang')['Vary'], + 'Accept-Language') + diff --git a/tests/tests_utils.py b/tests/tests_utils.py new file mode 100644 index 0000000..e838bc4 --- /dev/null +++ b/tests/tests_utils.py @@ -0,0 +1,19 @@ +# -*- 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 re + + +splitter = re.compile(br'(?<=-->)\s*(?=